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

用户令「下载页只负责下载整合包和Mod/光影」:新开一页做**内容发现与装入**,
检索走 Modrinth(优先,无需 key)/ CurseForge(`ULL_CF_API_KEY` 环境变量,日志只打
set/unset),装入走 core 的下载引擎真下到实例目录,进度/取消/失败复用下载页那块安装面板。

- **入口(B1)**:`Route::Content` 是新顶层路由,但 `Route::nav()` 复用 `NavSection::Download`
  ——导航轨仍五项,页头右上是「版本管理 | 内容库」一级切换(版本管理页也加了同一个切换,
  否则内容库只有 `ULL_START_ROUTE` 能到)
- **页内四条一级侧栏**(`pages/content/layout.rs` 四档几何:992/856/648 三个门槛都是
  「Σ栏宽 + 间距」算出来的):整合包 / Mod / 资源包 / 光影;标签栏 200(`Resource packs`
  比 M107 的版本类型名长,140 会切字,已登记偏离)
- **筛选**:关键词(草稿 + 显式「搜索」按钮,不逐字打网络)、来源、游戏版本、加载器
  (Fabric/Forge/NeoForge/Quilt)、排序;**分页**(「加载更多」)而不是无限滚动——快照只有一个槽、
  翻页要看得见、状态要可截图复现
- **四态**:加载 / 空 / 失败 / 有数据 + 「没有更多了」;筛选条在任何状态下都在(加载中藏筛选条
  等于把入口收走)
- **一键装入**:`Content::blockers()` 是纯函数——版本不匹配、加载器不匹配、没有目标实例、
  整合包缺管线**四条原因逐条**给出,按钮点之前就禁用;判定与画法分开(model/tests 直接钉)
- **整合包今天不可装**:`ContentKind::Modpack.dir()` 是 `None`,UI 禁用 + 说明,host 侧再挡
  一次(绕过 UI 也只会回一条 Failed)。真流程是 M111 的导入管线
- **零 UI 网络**:`presenter/host/addon.rs` + `addon/{sources,http,install}.rs`;无 header 的请求走
  core 的 `download_bytes`(信号量限流 + 健康度 + 退避全现成),带 `X-API-KEY` 的只有 CF 一条,
  自带 429/5xx 退避(优先吃 `Retry-After`,封顶 30s);密钥不进 DTO/日志/仓库
- 文案 41 条中英进 `crates/i18n`(新域接在装配链末尾 ⇒ `Key::ALL` 与金表只追加)
- 演示数据 `ContentHit → Hit::of` 与产品路径同源(格式化不会两套),`ULL_CONTENT_KIND` /
  `ULL_CONTENT_STATE` 供出图矩阵选类型与状态

取证:`DevDocs/artifacts/m109/`(10 张:四类内容 × 浅/深 + 搜索无结果 + 加载器不兼容禁用)

门禁:fmt ✓ / clippy `-D warnings`(三 crate)✓ / app-iced 450+6、presenter 170、i18n 24 全绿;
`cargo tree` 两条零 UI 门禁仍空;全部改动文件 ≤400 行。
This commit is contained in:
AskaEth committed 2026-11-05 03:20:21 +08:00
1 parent 5adac230fe
commit 6b161227d8
42 files changed
+4071 -25

No files matched your search

+2 -1
View File
@@ -89,7 +89,8 @@ pub(super) fn route_of(nav: NavItem) -> Route {
pub(super) fn nav_item_of(route: &Route) -> NavItem {
match route {
Route::Instances | Route::Detail(_) => NavItem::Instances,
Route::Download => NavItem::Download,
// M109:内容库与版本管理共用「下载」那一个导航项(B1 裁定)
Route::Download | Route::Content => NavItem::Download,
Route::Logs => NavItem::Logs,
Route::Settings => NavItem::Settings,
Route::Agent => NavItem::Ai,
+8
View File
@@ -146,6 +146,12 @@ pub struct App {
/// 已经提示过的取消次数(M105):`AppState::install_cancelled` 是「发生过几次取消」,
/// 这里是「其中几次已经弹过提示」。两者不等 ⇒ 弹一次 i18n 提示。
pub(super) install_cancelled_seen: u64,
/// 内容库页的页面内选择(M109):内容类型 / 选中项目 / 目标实例 / 关键词草稿。
///
/// 为什么在 `App` 而不是 presenter:这四样是「用户在这一屏上点了什么」,重建快照不该冲掉;
/// 而检索条件与结果(会跨页存活、要被事件写)在 `AppState::content`。放进 `view` 模型也
/// 不行——`refresh_view` 每次都用快照重建模型,选择会被冲回默认值。
pub(super) content: pages::content::PageState,
}
impl App {
@@ -246,6 +252,7 @@ impl App {
// 下载页安装草稿(M105):出厂态 = 没选中、原版、两个空串
download_draft: pages::InstallDraft::default(),
install_cancelled_seen: 0,
content: pages::content::PageState::default(),
};
// 配色来源档可能已经指向一张背景图:那时 `Ui` 必须由解析出的方案构造,而不是 baseline。
app.rebuild_ui();
@@ -338,6 +345,7 @@ impl App {
category,
&draft,
self.lang(),
&self.content,
);
self.view_dirty = false;
self.refreshes += 1;
+6 -1
View File
@@ -43,6 +43,7 @@ use super::state::{App, IDLE_TICK, TICK, TICK_MS, WATCH_WINDOW};
use super::CAPTURE_AT_FRAME;
mod agent;
mod content;
mod download;
mod page_messages;
mod settings;
@@ -173,6 +174,8 @@ impl App {
self.state.navigate(route);
// 直达某一页时不要再被「自动选中第一条实例」拉回详情页。
self.auto_selected = true;
// M109:直达内容库时补一次检索(与页面内切换同一个入口)
self.ensure_content_loaded();
}
self.auto_select();
@@ -364,7 +367,7 @@ fn heartbeat() -> Task<Message> {
})
}
/// 取证开关 `ULL_START_ROUTE` 的取值解析(`settings` / `instances` / `download` / `logs`)。
/// 取证开关 `ULL_START_ROUTE` 的取值解析(`settings` / `instances` / `download` / `content` / `logs`)。
///
/// 为什么是环境变量:`crates/app-iced/src/cli.rs` 不在 M93 的 scope,而外观矩阵必须在**设置页**上
/// 出图。语义与 `ULL_APPEARANCE_*` / `ULL_TICK_TRACE` 一致:只影响这一次进程。
@@ -376,6 +379,8 @@ fn env_start_route() -> Option<Route> {
"settings" => Some(Route::Settings),
// M104:AI 独立页也要能直达(三态矩阵的 Detached 那张)
"agent" => Some(Route::Agent),
// M109:内容库(截图矩阵要能直达;页面内的四条侧栏不在路由里)
"content" => Some(Route::Content),
_ => None,
}
}
+163
View File
@@ -0,0 +1,163 @@
//! 内容库页的消息落点(M109):`pages::Message::Content(..)` 的穷尽分发。
//!
//! ## 两件事分开(与 `update/download.rs` 同一条纪律)
//!
//! | 意图 | 谁改状态 | 派指令吗 |
//! |---|---|---|
//! | 关键词草稿 / 选中项目 / 目标实例 | `App::content`(页面内选择) | 不派 |
//! | 检索条件(类型/来源/排序/版本/加载器/关键词提交/翻页/重试) | `AppState::content`(presenter) | 派 `SearchContent` |
//! | 装入 | 谁都不改 | 派 `InstallContent` |
//!
//! 「装入」不改本地状态是关键:装上的证据是 `InstallProgress` 事件回流到安装面板
//! (`pages/download::install_panel`),页面自己不做乐观回显。
//!
//! ## 为什么条件写进 presenter 再派
//!
//! `AppState::content.query` 是「当前结果对应哪份条件」的唯一真相:结果回来时
//! `apply_content` 用它判「迟到的事件要不要丢」。页面自己存一份就会有两份真相。
use unlucky_presenter::dto::{AppCommand, ContentInstallRequest, ContentQuery, ContentSource};
use crate::pages::content::{Blocker, Content};
use super::*;
impl App {
/// 进入内容库页时补一次检索(首个用户动作不该是「再点一下搜索」)。
///
/// 判据是 `AppState::content` 里还没有结果、也没有请求在飞:重复进出不重复打网络。
pub(super) fn ensure_content_loaded(&mut self) {
if self.state.content.results.is_some() || self.state.content.pending {
return;
}
let query = ContentQuery::first(self.content.kind);
self.search_content(query);
}
/// 页面消息(穷尽分发:漏一支报 E0004)。
pub(super) fn on_content(&mut self, message: pages::content::Message) -> Task<Message> {
match message {
pages::content::Message::SetKind(kind) => {
// 换类型 = 换一份条件(facet 全变):选中项作废,条件回到该类型的首页
self.content.kind = kind;
self.content.selected = None;
let mut query = ContentQuery::first(kind);
// 保留用户已经选好的来源/排序/版本/加载器:换成默认值会让筛选条自己跳回去
query.source = self.state.content.query.source;
query.sort = self.state.content.query.sort;
query.game_version = self.state.content.query.game_version.clone();
query.loader = self.state.content.query.loader;
self.search_content(query);
}
pages::content::Message::QueryChanged(text) => {
self.content.draft = text;
self.view_dirty = true;
}
pages::content::Message::Search => {
let mut query = self.state.content.query.clone();
query.text = self.content.draft.trim().to_string();
query.offset = 0;
self.search_content(query);
}
pages::content::Message::SetSource(source) => {
if source == ContentSource::Curseforge && !self.curseforge_available() {
// 筛选条那条本来就是禁用的;这里再挡一次(防绕过 UI 的消息)
return Task::none();
}
self.with_query(|query| query.source = source);
}
pages::content::Message::SetSort(sort) => self.with_query(|query| query.sort = sort),
pages::content::Message::SetVersion(version) => {
self.with_query(|query| query.game_version = version);
}
pages::content::Message::SetLoader(loader) => {
self.with_query(|query| query.loader = loader)
}
pages::content::Message::Select(id) => {
self.content.selected = Some(id);
self.view_dirty = true;
}
pages::content::Message::SetTarget(id) => {
self.content.target = Some(id);
self.view_dirty = true;
}
pages::content::Message::LoadMore => {
let next = self.state.content.query.next_page();
self.search_content(next);
}
pages::content::Message::Retry => {
let mut query = self.state.content.query.clone();
query.offset = 0;
self.search_content(query);
}
pages::content::Message::Install => {
if let Some(request) = self.content_install_request() {
self.dispatch(AppCommand::InstallContent(Box::new(request)));
}
}
}
Task::none()
}
/// 改一个筛选字段并**立刻重查**(第一页)。
fn with_query(&mut self, change: impl FnOnce(&mut ContentQuery)) {
let mut query = self.state.content.query.clone();
change(&mut query);
query.offset = 0;
self.search_content(query);
}
/// 提交一次检索:条件先落进 presenter(迟到事件的判据),再派指令。
fn search_content(&mut self, query: ContentQuery) {
self.state.set_content_query(query.clone());
self.view_dirty = true;
self.dispatch(AppCommand::SearchContent(query));
}
/// CF 源可用吗(结果里带回的那一位;还没拉过 ⇒ `false`)。
fn curseforge_available(&self) -> bool {
self.state
.content
.results
.as_ref()
.is_some_and(|results| results.curseforge)
}
/// 组一份内容库的「装入」请求;闸门与页面上的按钮**同一个判据**(`Content::can_install`)。
///
/// 名字带 `content_`:M105 的版本安装已经占了 `install_request`(版本 → `CreateRequest`),
/// 两者语义完全不同,撞名会被编译器拦下(本轮 rebase 撞过)。
///
/// 这里再判一次不是重复劳动:页面禁用的只是那一个按钮,消息仍然可能从别的路径进来,
/// 而 host 侧对整合包会直接回失败——三道闸门同一个结论,才不会出现「有的路能过」。
fn content_install_request(&self) -> Option<ContentInstallRequest> {
let catalog = unlucky_i18n::Lang::En.catalog();
let model = Content::from_snapshot(&self.state, &self.content, catalog);
if !model.can_install() {
// 有原因就打印出来(取证用);没有选中项时什么都不做
for reason in model.blocker_reasons(catalog) {
eprintln!("[content] 装入被挡下:{reason}");
}
if model.blockers.contains(&Blocker::NeedsPipeline) {
eprintln!("[content] 整合包装入未实现(M111)");
}
return None;
}
let hit = model.selected_hit()?;
let target = model
.targets
.iter()
.find(|target| Some(&target.id) == model.target.as_ref())?;
Some(ContentInstallRequest {
project: hit.id.clone(),
title: hit.title.clone(),
// 整合包走不到这里:`ContentKind::Modpack` 的 `dir()` 是 `None` ⇒ 必然带
// `Blocker::NeedsPipeline` ⇒ `can_install()` 为假。所以这里就是「单文件」那一类。
kind: model.kind,
source: hit.source,
instance: target.id.clone(),
game_version: target.mc_version.clone(),
loader: target.loader,
})
}
}
@@ -43,9 +43,15 @@ impl App {
match message {
pages::Message::Navigate(route) => {
// M109:进内容库时补一次检索(首个动作不该是「再点一下搜索」)。
// 判据在 `ensure_content_loaded` 里(已有结果/请求在飞就不重复打网络)。
let entering_content = route == Route::Content;
self.state.navigate(route);
self.view_dirty = true;
self.refresh_view();
if entering_content {
self.ensure_content_loaded();
}
}
pages::Message::Launch(id) => self.dispatch(AppCommand::LaunchInstance(id)),
pages::Message::Stop(id) => self.dispatch(AppCommand::StopInstance(id)),
@@ -57,6 +63,8 @@ impl App {
// 这是**唯一**一处绕开 M101 投递口的页面消息:语言档位不是「某一组的状态」,
// 它要落盘到 `ui.language` 并重建整棵视图模型(设置页的语言行只是它的第一个出口)。
pages::Message::Language(choice) => self.set_language(choice),
// M109:内容库那一套意图在自己的文件里穷尽分发(`update/content.rs`)
pages::Message::Content(message) => return self.on_content(message),
// M95:摘要落剪贴板(文本由页面造,这里只搬)
pages::Message::CopySummary(text) => return iced::clipboard::write(text),
// 下面四支已由 `on_download` 提前消化;显式列出而不是 `_ => {}`,见模块文档
+13 -1
View File
@@ -111,6 +111,7 @@ pub(super) fn model(
category: pages::Category,
draft: &pages::InstallDraft,
lang: Lang,
content: &pages::content::PageState,
) -> pages::Model {
let route = state.route().clone();
let screen = pages::Screen::of(&route).unwrap_or(pages::Screen::Instances);
@@ -141,6 +142,8 @@ pub(super) fn model(
active: route.nav(),
instances: pages::Instances { rows, detail },
download: pages::Download::from_snapshot(state, category, draft, lang.catalog()),
// M109:检索条件/结果来自快照,页面内选择(类型/选中/目标/草稿)来自 `App::content`
content: pages::content::Content::from_snapshot(state, content, lang.catalog()),
logs: logs_model(state).with_status(lang),
lang,
forced: forced.map(|state| match state {
@@ -280,7 +283,16 @@ mod tests {
/// 测试用的薄包装:默认宽度 / 默认分类 / 空草稿。
fn at(state: &AppState, forced: Option<crate::cli::ForcedState>) -> pages::Model {
let (draft, lang) = (pages::InstallDraft::default(), Lang::En);
model(state, 1180.0, forced, pages::Category::All, &draft, lang)
let page = pages::content::PageState::default();
model(
state,
1180.0,
forced,
pages::Category::All,
&draft,
lang,
&page,
)
}
#[test]
+94
View File
@@ -0,0 +1,94 @@
//! # 内容库页(M109 `CONTENTDL`):整合包 / Mod / 资源包 / 光影
//!
//! 用户令:「下载页只负责下载整合包和Mod/光影」。于是版本管理页(M107/M108 改名)只做版本,
//! **这一页**做内容:四条内容类型(页内一级侧栏)+ 关键词/来源/版本/加载器/排序筛选 +
//! 结果列表与详情面板 + **一键装入指定实例**。
//!
//! ## 入口(B1 裁定:不新增导航项)
//!
//! `Route::Content` 是新顶层路由,但 `Route::nav()` 返回 `NavSection::Download`:导航轨仍是
//! 五项,页头右上是「版本管理 | 内容库」一级切换([`parts::section_switcher`])。
//! 为什么不是第六个图标:轨宽 88 塞六项会挤,且用户要的是「同一入口下两块内容」。
//!
//! ## 分页 vs 无限滚动:**选分页**
//!
//! 三条理由,都是这一页的实际约束:
//!
//! 1. **快照只有一个槽**(`AppState::content.results`):无限滚动要求「滚到底就自动续」,而
//! 自动续在没有虚拟化列表的 iced 里会一次挂上几百个控件;
//! 2. **翻页要有明确出口**:站点限流 / 失败重试都发生在「下一批」上,用户得能看见自己正在
//! 等第 3 页,而不是画面莫名一卡;
//! 3. **可截图复现**:分页的每一次状态都是一份确定的快照,D30 的矩阵能钉住「还有更多 / 没有更多了」。
//!
//! 所以尾部是一个「加载更多」按钮 + 一句「没有更多了」([`parts::more`]),追加逻辑在
//! presenter 的 `AppState::apply_content`(同条件换页 ⇒ 追加;换条件 ⇒ 整批替换;迟到 ⇒ 丢掉)。
//!
//! ## 四态(都必须能画出来)
//!
//! | 态 | 判据 | 画什么 |
//! |---|---|---|
//! | 加载 | `pending` 或还没有结果 | 一句「正在加载」+ 说明 + 重试 |
//! | 空 | 拉到了、零命中 | 真 `EmptyState` + 重试 CTA |
//! | 失败 | `failed` 有原因 | `Error` 图标 + 原因原文 + 重试(实心) |
//! | 有数据 | 有命中项 | 计数头 + 列表 +(还有更多 ⇒ 加载更多 / 否则 ⇒ 没有更多了) |
//!
//! ## 不在这里做的事
//!
//! - **不发 HTTP**:检索与装入都在 presenter 的 `host/addon`(任务书第 4 条)。
//! - **不做乐观回显**:点「装入」不改本地状态,进度只由 `InstallProgress` 事件回流。
//! - **不自己画安装面板**:复用 `pages::download::install_panel`(同一个函数、同一套文案)。
//!
//! ## 偏离登记
//!
//! | # | 偏离 | 为什么 | 何时收口 |
//! |---|---|---|---|
//! | 1 | 四种内容类型借用现有字形(`Folder`/`Instances`/`Download`/`Play`) | 设计令牌的图标集还没有这四枚 | 图标集补「整合包 / Mod / 资源包 / 光影」时 |
//! | 2 | 本页自带一份栏几何(`content/layout.rs`) | M107 的 `pages/layout/rails.rs` 未并入本分支的 base | M107 合并后改为复用、删掉本文件 |
//! | 3 | 整合包的「装入」是禁用态 | core 没有远程整合包的安装管线(M111) | M111 落地后接同一入口 |
//! | 4 | 安装进度只有阶段级(无字节条) | presenter 依赖表里没有 tokio/futures,拿不到 `ProgressEmitter` 的接收端 | presenter 补依赖后换成字节级 |
mod demo;
mod layout;
mod message;
mod model;
mod parts;
mod view;
#[cfg(test)]
mod tests;
pub use message::Message;
pub use model::{loader_name, Blocker, Content, ContentQueryView, Hit, PageState, Status, Target};
use iced::Element;
use unlucky_i18n::Lang;
pub use unlucky_presenter::dto::ContentKind;
use crate::pages::layout::FrameMetrics;
use crate::pages::Message as PageMessage;
use crate::theme::Ui;
/// 整页入口(`pages/mod.rs` 的 `render` 调它;页面层不认识 presenter 的 DTO)。
pub fn view<'a>(
ui: &'a Ui,
metrics: FrameMetrics,
model: &'a Content,
lang: Lang,
) -> Element<'a, PageMessage> {
view::view(ui, metrics, model, lang)
}
/// 演示数据(`pages_demo` 出图与单测用;产品路径走 `Content::from_snapshot`)。
pub fn demo(kind: ContentKind, lang: Lang) -> Content {
demo::demo(kind, lang)
}
/// 页头那条一级切换(版本管理页也要用同一个函数:两页共用一个入口才不会各画一套)。
pub(crate) fn section_switcher<'a>(
ui: &'a Ui,
catalog: unlucky_i18n::Catalog,
) -> Element<'a, PageMessage> {
parts::section_switcher(ui, catalog, true)
}
+208
View File
@@ -0,0 +1,208 @@
//! 内容库的演示数据(`pages_demo` 出图与单测用;产品路径永远走 `Content::from_snapshot`)。
//!
//! ## 为什么要环境变量选「哪一屏」
//!
//! D30 的验收矩阵要求「四类内容 × 浅/深」加「搜索无结果」「加载器不兼容禁用」两种状态,
//! 共 10 张。`pages_demo` 的 `--screen=` 只够选到**哪一页**,选不到「同一页的哪一态」;
//! 而 `pages/harness.rs` 与 `cli.rs` 不在本 mission 的 scope。
//!
//! 所以沿用本仓既有的取证开关范式(`ULL_START_ROUTE` / `ULL_APPEARANCE_*` / `ULL_TICK_TRACE`):
//!
//! | 变量 | 取值 | 作用 |
//! |---|---|---|
//! | `ULL_CONTENT_KIND` | `modpack` / `mod` / `resourcepack` / `shader` | 选内容类型(默认 `mod`) |
//! | `ULL_CONTENT_STATE` | `ok` / `empty` / `incompatible` | 选状态(默认 `ok`) |
//!
//! 只影响本来就用演示数据的 harness 路径;产品二进制里 `demo()` 根本不会被调用。
use unlucky_i18n::{Catalog, Lang};
use unlucky_presenter::dto::{ContentHit, ContentKind, ContentSort, ContentSource, LoaderKind};
use super::model::{blockers_of, Content, ContentQueryView, Hit, Status, Target};
use crate::pages::download::Download;
/// 一个固定的演示环境:两个实例(一个 Fabric / 一个 Forge)。
fn targets() -> Vec<Target> {
vec![
Target {
id: "survival".to_string(),
name: "Survival".to_string(),
mc_version: "1.20.1".to_string(),
loader: LoaderKind::Fabric,
},
Target {
id: "modded".to_string(),
name: "Modded".to_string(),
mc_version: "1.21.1".to_string(),
loader: LoaderKind::Forge,
},
]
}
/// 四条命中项:**先造 `ContentHit` 再走 `Hit::of`**,保证演示数据与产品路径逐字段同源
/// (自己拼 `Hit` 的那一版会把「1,200,000 下载量」「Fabric 首字母大写」这类格式化漂开)。
fn hits(kind: ContentKind, catalog: Catalog, compatible_only: bool) -> Vec<Hit> {
let rows: &[(&str, &str, &str)] = match kind {
ContentKind::Modpack => &[
(
"Fabulously Optimized",
"robotkoer",
"Performance-focused vanilla+ pack",
),
("Adrenaline", "Someone", "Lightweight kitchen-sink pack"),
("Better MC", "Luna", "Adventure pack with quests"),
("All the Mods 9", "ATMTeam", "Kitchen sink, 400+ mods"),
],
ContentKind::Mod => &[
("Sodium", "jellysquid3", "Modern rendering engine"),
("Lithium", "jellysquid3", "Game logic optimizations"),
("Iris Shaders", "coderbot", "Shader support for Fabric"),
("JEI", "mezz", "Item and recipe viewer"),
],
ContentKind::Resourcepack => &[
(
"Faithful 32x",
"Faithful",
"Double resolution vanilla textures",
),
("Stay True", "Fred", "Warm, hand-drawn look"),
("Classic 3D", "Someone", "3D blocks and items"),
("Bare Bones", "Someone", "Flat, minimalist textures"),
],
ContentKind::Shader => &[
("Complementary Reimagined", "Emin", "Balanced, high quality"),
("BSL Shaders", "capttatsu", "Soft colors, strong weather"),
("Photon", "SixthSurge", "Path-traced look"),
("MakeUp Ultra Fast", "Someone", "Lightweight shader"),
],
};
rows.iter()
.enumerate()
.map(|(index, (title, author, summary))| {
// 前两条只支持 1.20.1 + Fabric(能装进 Survival);后两条是 1.21.1 + Forge
let compatible = compatible_only || index < 2;
Hit::of(
&ContentHit {
id: format!("{}-{index}", kind.slug()),
slug: format!("{}-{index}", kind.slug()),
title: (*title).to_string(),
summary: (*summary).to_string(),
author: (*author).to_string(),
downloads: 1_200_000u64.saturating_sub(index as u64 * 111_111),
updated: format!("2026-0{}-1{index}", index + 1),
source: if index % 2 == 0 {
ContentSource::Modrinth
} else {
ContentSource::Curseforge
},
game_versions: if compatible {
vec!["1.20.1".to_string()]
} else {
vec!["1.21.1".to_string()]
},
loaders: if compatible {
vec!["fabric".to_string()]
} else {
vec!["forge".to_string()]
},
icon: None,
},
catalog,
)
})
.collect()
}
/// 装入中的面板(**复用下载页那块**:`pages::download::install_panel_model` + `install_panel`)。
fn install_panel(catalog: Catalog) -> Download {
use unlucky_presenter::dto::{InstallProgressView, InstallStage};
let progress = InstallProgressView {
version: "Sodium".to_string(),
instance: "survival".to_string(),
loader: "Fabric".to_string(),
stage: InstallStage::Downloading,
files_done: 0,
files_total: 1,
message: "sodium-fabric-0.6.0.jar".to_string(),
..InstallProgressView::new()
};
crate::pages::download::install_panel_model(&progress, catalog)
.expect("演示数据的进度是「下载中」,面板必然存在")
}
/// 内建模型(默认 `ok` + 从环境变量读类型/状态)。
pub(super) fn demo(fallback_kind: ContentKind, lang: Lang) -> Content {
let catalog = lang.catalog();
let kind = std::env::var("ULL_CONTENT_KIND")
.ok()
.and_then(|value| {
ContentKind::ALL
.into_iter()
.find(|item| item.slug() == value.trim())
})
.unwrap_or(fallback_kind);
let state = std::env::var("ULL_CONTENT_STATE").unwrap_or_else(|_| "ok".to_string());
let targets = targets();
let target = Some("survival".to_string());
let empty = state == "empty";
// 「装入中」那一屏:面板就是下载页那块(同一个函数),用来证明它在内容库语义下也可读
let installing = state == "install";
let list = if empty {
Vec::new()
} else {
hits(kind, catalog, state != "incompatible")
};
// 「不兼容」那一屏:选中的那条是 1.21.1 + Forge 的项目,而目标是 1.20.1 + Fabric
let selected_index = if state == "incompatible" { 2 } else { 0 };
let selected = list.get(selected_index).map(|hit| hit.id.clone());
let blockers = blockers_of(
list.get(selected_index)
.map(|hit| (hit.versions.as_slice(), hit.loaders.as_slice())),
kind,
&targets,
target.as_deref(),
);
let target_label = targets
.iter()
.find(|item| Some(&item.id) == target.as_ref())
.map(|item| item.label());
Content {
kind,
query: ContentQueryView {
source: ContentSource::Modrinth,
sort: ContentSort::Relevance,
version: Some("1.20.1".to_string()),
loader: Some(LoaderKind::Fabric),
text: if empty {
"quarkonium".to_string()
} else {
String::new()
},
},
hits: list,
selected,
status: if empty { Status::Empty } else { Status::Ready },
total: if empty { 0 } else { 1_284 },
// 演示数据故意把 `has_more` 置真(真实判据是「本页装满」):出图要能看到「加载更多」
has_more: !empty,
curseforge: false,
targets,
target: target.clone(),
target_label,
blockers,
panel: if installing {
Some(install_panel(catalog))
} else {
None
},
versions: vec!["1.20.1".to_string(), "1.21.1".to_string()],
draft: if empty {
"quarkonium".to_string()
} else {
String::new()
},
}
}
+194
View File
@@ -0,0 +1,194 @@
//! 内容库页的栏几何(纯函数 + 数值表)。
//!
//! ## 为什么这一页自带一份几何
//!
//! 任务书要求「与 M107 同一套视觉语言」:类型栏 140 / 图标栏 64、间距 24、列表列下限 424
//! (行宽红线)、详情 320。M107 的那份几何在 `pages/layout/rails.rs`(M107 分支,尚未并入 main),
//! 本 mission 的分支基于 main,所以这里按**同一组数值**先落一份,并在报告里登记
//! 「M107 合并后应改为复用 `pages/layout/rails.rs`,删掉本文件」。
//!
//! ## 四档的判据是**算出来的**,不是拍的
//!
//! | 档 | 条件(可用宽,已扣导航轨与页边距) | 版面 |
//! |---|---|---|
//! | `Wide` | ≥ 140 + 24 + 424 + 24 + 320 = **932** | 标签栏 + 列表 + 详情 |
//! | `Narrow` | ≥ 64 + 24 + 424 + 24 + 320 = **856**(类型栏收成图标栏) | 图标栏 + 列表 + 详情 |
//! | `Compact` | ≥ 140 + 24 + 424 = **588** | 标签栏 + 单列(详情整页替换列表) |
//! | `Tight` | 其余 | 类型条变成顶部一行 chip + 单列 |
//!
//! 三个门槛都是「Σ栏宽 + 间距」算出来的,改动任何一档都会让数值表用例红。
use unlucky_design::spacing::Space;
use crate::pages::layout;
/// 类型栏宽(标签形态)。
///
/// M107 的版本类型栏是 140;这里取 200:四个**内容类型**的名字(`Resource packs`)比
/// 版本类型(`Release` / `Snapshot`)长,140 会把标签切掉。登记在 `pages/content.rs` 的偏离表。
pub const RAIL_LABEL: f32 = 200.0;
/// 类型栏宽(图标形态)。
pub const RAIL_ICON: f32 = 64.0;
/// 详情面板宽。
pub const DETAIL: f32 = 320.0;
/// 栏间距(`Space::X6` = 24,与 `pages::layout::PANES_GAP` 同源)。
pub const GAP: f32 = Space::X6.dp();
/// 一屏三栏各自多宽。
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Rails {
/// 档位
pub tier: Tier,
/// 类型栏宽(`Tight` 档是 0:类型条在顶部)
pub rail: f32,
/// 列表列宽
pub list: f32,
/// 详情面板宽(0 = 这一档没有独立详情列)
pub detail: f32,
/// 栏间距
pub gap: f32,
}
/// 版面档位。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Tier {
/// 标签栏 + 列表 + 详情
Wide,
/// 图标栏 + 列表 + 详情
Narrow,
/// 标签栏 + 单列
Compact,
/// 顶部 chip 条 + 单列
Tight,
}
impl Tier {
/// 这一档有独立详情列吗。
pub const fn has_detail(self) -> bool {
matches!(self, Self::Wide | Self::Narrow)
}
/// 这一档的类型栏是标签形态吗(`Tight` 没有栏)。
pub const fn rail_has_labels(self) -> bool {
matches!(self, Self::Wide | Self::Compact)
}
}
/// `Wide` 的门槛:标签栏 + 间距 + 列表下限 + 间距 + 详情。
pub fn wide_min() -> f32 {
RAIL_LABEL + GAP + layout::LINE_SAFE_WIDTH_PX + GAP + DETAIL
}
/// `Narrow` 的门槛:图标栏 + 间距 + 列表下限 + 间距 + 详情。
pub fn narrow_min() -> f32 {
RAIL_ICON + GAP + layout::LINE_SAFE_WIDTH_PX + GAP + DETAIL
}
/// `Compact` 的门槛:标签栏 + 间距 + 列表下限。
pub fn compact_min() -> f32 {
RAIL_LABEL + GAP + layout::LINE_SAFE_WIDTH_PX
}
/// 可用宽 → 三栏几何。
pub fn resolve(available: f32) -> Rails {
let cap = layout::measure_cap_px();
if available >= wide_min() {
let rail = RAIL_LABEL;
return Rails {
tier: Tier::Wide,
rail,
gap: GAP,
detail: DETAIL,
list: (available - rail - GAP - DETAIL - GAP).max(layout::LINE_SAFE_WIDTH_PX),
};
}
if available >= narrow_min() {
let rail = RAIL_ICON;
return Rails {
tier: Tier::Narrow,
rail,
gap: GAP,
detail: DETAIL,
list: (available - rail - GAP - DETAIL - GAP).max(layout::LINE_SAFE_WIDTH_PX),
};
}
if available >= compact_min() {
let rail = RAIL_LABEL;
return Rails {
tier: Tier::Compact,
rail,
gap: GAP,
detail: 0.0,
list: (available - rail - GAP).min(cap.max(layout::LINE_SAFE_WIDTH_PX)),
};
}
Rails {
tier: Tier::Tight,
rail: 0.0,
gap: GAP,
detail: 0.0,
list: available.min(cap.max(layout::LINE_SAFE_WIDTH_PX)),
}
}
#[cfg(test)]
mod tests {
use super::*;
/// 门槛必须由「Σ栏宽 + 间距」算出来(抓得住:谁把 932 抄成 900 这种拍值)。
#[test]
fn the_three_thresholds_are_sums_not_magic_numbers() {
assert_eq!(wide_min(), 200.0 + 24.0 + 424.0 + 24.0 + 320.0);
assert_eq!(narrow_min(), 64.0 + 24.0 + 424.0 + 24.0 + 320.0);
assert_eq!(compact_min(), 200.0 + 24.0 + 424.0);
assert_eq!(wide_min(), 992.0);
assert_eq!(narrow_min(), 856.0);
assert_eq!(compact_min(), 648.0);
}
#[test]
fn each_tier_takes_effect_at_its_own_threshold() {
assert_eq!(resolve(wide_min()).tier, Tier::Wide);
assert_eq!(resolve(wide_min() - 0.5).tier, Tier::Narrow);
assert_eq!(resolve(narrow_min()).tier, Tier::Narrow);
assert_eq!(resolve(narrow_min() - 0.5).tier, Tier::Compact);
assert_eq!(resolve(compact_min()).tier, Tier::Compact);
assert_eq!(resolve(compact_min() - 0.5).tier, Tier::Tight);
}
#[test]
fn a_wide_page_never_gives_the_list_less_than_the_row_width_red_line() {
// 1180 窗宽(右栗收起):1044 可用 → Wide,列表 536
let rails = resolve(1044.0);
assert_eq!(rails.tier, Tier::Wide);
assert!(rails.list >= layout::LINE_SAFE_WIDTH_PX, "{}", rails.list);
// 逐档扫一遍:只要还有独立列表列,就不许低于 424(行宽红线)
for available in 500..=1600 {
let rails = resolve(available as f32);
if rails.rail > 0.0 {
assert!(
rails.list >= layout::LINE_SAFE_WIDTH_PX,
"available={available} 时列表只剩 {}",
rails.list
);
}
}
}
#[test]
fn the_detail_column_only_exists_in_the_two_wide_tiers() {
assert!(Tier::Wide.has_detail());
assert!(Tier::Narrow.has_detail());
assert!(!Tier::Compact.has_detail());
assert!(!Tier::Tight.has_detail());
assert_eq!(resolve(1200.0).detail, DETAIL);
assert_eq!(resolve(700.0).detail, 0.0);
// 图标形态只在这两档出现,且窄档的标签栏不存在
assert_eq!(resolve(900.0).rail, RAIL_ICON);
assert_eq!(resolve(700.0).rail, RAIL_LABEL);
assert_eq!(resolve(400.0).rail, 0.0);
assert!(Tier::Wide.rail_has_labels());
assert!(!Tier::Narrow.rail_has_labels());
}
}
@@ -0,0 +1,40 @@
//! 内容库页的**自有**消息(M109)。
//!
//! 与设置页同一个范式(`pages/settings/message.rs` + `pages::Message::Settings`):页面自己的一套
//! 意图在这个文件里声明,`pages::Message` 只留一个 `Content(..)` 投递口,于是「加一条内容库消息」
//! 不碰共享枚举的正文,也不碰 `app/update/page_messages.rs` 那条不许 `_ =>` 的 match。
//!
//! **一条纪律**:这里的每个变体都是**意图**,不是状态变更。改了条件 ≠ 已经发请求
//! (`QueryChanged` 只改输入框里的字,`Search` 才发指令);点了「装入」≠ 已装上
//! (装上的证据是 `InstallProgress` 事件回流到面板)。
use unlucky_presenter::dto::{ContentKind, ContentSort, ContentSource, LoaderKind};
/// 内容库页的消息。
#[derive(Debug, Clone)]
pub enum Message {
/// 换内容类型(左侧四条一级侧栏)
SetKind(ContentKind),
/// 关键词输入框变了(**只改草稿**,不 dispatch)
QueryChanged(String),
/// 提交搜索(零偏移重来)
Search,
/// 换来源(Modrinth / CurseForge)
SetSource(ContentSource),
/// 换排序
SetSort(ContentSort),
/// 换游戏版本过滤(`None` = 不限)
SetVersion(Option<String>),
/// 换加载器过滤(`None` = 不限)
SetLoader(Option<LoaderKind>),
/// 选中一个项目(**页面内选中**,不 dispatch)
Select(String),
/// 选目标实例(一键装入的对象)
SetTarget(String),
/// 加载下一页
LoadMore,
/// 同条件重试(失败态的出口)
Retry,
/// 装进目标实例
Install,
}
+281
View File
@@ -0,0 +1,281 @@
//! 内容库页的视图模型:**presenter 快照 + 页面选中项 → 要画的东西**。
//!
//! ## 分层(与实例页 / 下载页同一条)
//!
//! | 数据源 | 谁转 | 用途 |
//! |---|---|---|
//! | `AppState::content`(检索条件 + 结果 + 请求态) | [`Content::from_snapshot`] | 产品运行时的真页面 |
//! | [`super::demo`] 的固定夹具 | 同上 | `pages_demo` 出图与单测 |
//!
//! ## 「点之前就知道装不装得上」(任务书第 3 条)
//!
//! 兼容性判定在 [`Content::blockers`]:**纯函数**,输入是「选中的项目 + 目标实例」,
//! 输出是 [`Blocker`] 列表。视图拿它决定按钮 `disabled` 与旁边那几句原因——于是「点完才报错」
//! 在结构上不可能发生(按钮根本发不出消息)。
//!
//! 两条判据各自独立、**都能同时成立**(版本对不上 + 加载器也不对):把两条合成一条会在
//! 「都不兼容」时只说一半原因。
//!
//! ## 为什么要 `PageState`(页面自留的那点状态)
//!
//! `kind` / `selected` / `target` / `draft` 都是**页面内**的选择:检索条件与结果在 presenter
//! (`AppState::content`),这四样是「用户在这一屏上点了什么」,重建快照不该把它们冲掉。
//! 它们的所有者是 `app/state.rs` 的 `App::content`,本文件只借用。
use unlucky_presenter::dto::{
loader_facet, ContentKind, ContentResults, ContentSort, ContentSource, ContentState, LoaderKind,
};
use unlucky_presenter::state::AppState;
use unlucky_i18n::Catalog;
use crate::pages::download::Download;
mod items;
pub(crate) use items::{capitalize, thousands};
pub use items::{loader_name, Blocker, Hit, PageState, Status, Target};
/// 内容库页要画的东西。
#[derive(Debug, Clone, PartialEq)]
pub struct Content {
/// 当前内容类型(左栏四条侧栏的选中项)
pub kind: ContentKind,
/// 当前检索条件(筛选条 + 分页游标)
pub query: ContentQueryView,
/// 命中项
pub hits: Vec<Hit>,
/// 选中项
pub selected: Option<String>,
/// 当前状态
pub status: Status,
/// 命中总数(`0` = 站点没给)
pub total: u32,
/// 还有下一页
pub has_more: bool,
/// CurseForge 源可用(缺 key 时筛选条里那条禁用并说明)
pub curseforge: bool,
/// 目标实例候选
pub targets: Vec<Target>,
/// 选中的目标实例
pub target: Option<String>,
/// 选中目标实例的可读标签(详情面板「装入到 …」)
pub target_label: Option<String>,
/// 装入不上的原因(空 = 可以装)
pub blockers: Vec<Blocker>,
/// 安装面板的视图模型(**复用下载页那块**:`pages::download::install_panel`)
///
/// 存整个 `Download` 而不是只存一帧视图:面板的入参就是 `&Download`,而 iced 的控件树
/// 借用它 ⇒ 被借的东西必须活得比这一帧长(局部临时值不行)。
pub panel: Option<Download>,
/// 游戏版本筛选的候选(来自实例;去重、保持实例顺序)
pub versions: Vec<String>,
/// 关键词草稿
pub draft: String,
}
/// 筛选条的展示态(数字/枚举都在这里,页面不再从 `ContentQuery` 里挖字段)。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ContentQueryView {
/// 来源
pub source: ContentSource,
/// 排序
pub sort: ContentSort,
/// 游戏版本过滤
pub version: Option<String>,
/// 加载器过滤
pub loader: Option<LoaderKind>,
/// 已提交的关键词(输入框显示的是 `draft`)
pub text: String,
}
impl Content {
/// presenter 快照 + 页面选中项 → 视图模型(**唯一的真数据入口**)。
pub fn from_snapshot(state: &AppState, page: &PageState, catalog: Catalog) -> Self {
let content: &ContentState = &state.content;
let hits: Vec<Hit> = content
.results
.as_ref()
.map(|results| {
results
.hits
.iter()
.map(|hit| Hit::of(hit, catalog))
.collect()
})
.unwrap_or_default();
let status = status_of(content, &hits);
let targets: Vec<Target> = state.instances.iter().map(Target::of).collect();
let target = page
.target
.clone()
.filter(|id| targets.iter().any(|target| &target.id == id))
.or_else(|| targets.first().map(|target| target.id.clone()));
let target_label = target
.as_deref()
.and_then(|id| targets.iter().find(|item| item.id == id))
.map(Target::label);
let mut versions: Vec<String> = Vec::new();
for instance in &targets {
if !versions.iter().any(|item| item == &instance.mc_version) {
versions.push(instance.mc_version.clone());
}
}
let selected_hit = page.selected.as_deref().and_then(|id| {
content
.results
.as_ref()?
.hits
.iter()
.find(|hit| hit.id == id)
});
let blockers = blockers_of(
selected_hit.map(|hit| (hit.game_versions.as_slice(), hit.loaders.as_slice())),
page.kind,
&targets,
target.as_deref(),
);
Self {
kind: page.kind,
query: ContentQueryView {
source: content.query.source,
sort: content.query.sort,
version: content.query.game_version.clone(),
loader: content.query.loader,
text: content.query.text.clone(),
},
hits,
selected: page.selected.clone(),
status,
total: content.results.as_ref().map_or(0, |results| results.total),
has_more: content
.results
.as_ref()
.is_some_and(ContentResults::has_more)
&& !content.pending,
curseforge: content
.results
.as_ref()
.is_some_and(|results| results.curseforge),
targets,
target: target.clone(),
target_label,
blockers,
panel: content_install(state, catalog),
versions,
draft: page.draft.clone(),
}
}
/// 选中项在命中列表里的那一行(详情面板读它)。
pub fn selected_hit(&self) -> Option<&Hit> {
let id = self.selected.as_deref()?;
self.hits.iter().find(|hit| hit.id == id)
}
/// 「装入」按钮此刻能不能按(`false` 时按钮禁用,`blockers()` 给原因)。
pub fn can_install(&self) -> bool {
self.selected_hit().is_some() && self.blockers.is_empty()
}
/// 原因的人读文案(顺序 = 判定顺序;空 = 没有原因)。
pub fn blocker_reasons(&self, catalog: Catalog) -> Vec<String> {
self.blockers
.iter()
.map(|blocker| blocker.reason(catalog))
.collect()
}
/// 加载中(首屏 / 换条件)。
pub const fn is_loading(&self) -> bool {
matches!(self.status, Status::Loading)
}
}
/// 快照 → 四态。判据只有三个字段(请求在飞 / 失败原因 / 命中项),没有第四份真相。
fn status_of(content: &ContentState, hits: &[Hit]) -> Status {
if let Some(reason) = &content.failed {
return Status::Failed(reason.clone());
}
if content.pending || content.results.is_none() {
return Status::Loading;
}
if hits.is_empty() {
Status::Empty
} else {
Status::Ready
}
}
/// 安装面板(**只借用下载页那块面板**,不另写一套画法)。
///
/// 转换与「成功/取消这类终态不占面板」的判断都在 `pages::download::install_panel_model` 里
/// (M105 起 `InstallView` 不再从那个模块导出):内容库只拿 `Option<Download>`。
fn content_install(state: &AppState, catalog: Catalog) -> Option<Download> {
state
.install
.as_ref()
.and_then(|progress| crate::pages::download::install_panel_model(progress, catalog))
}
/// 兼容性判定(**纯函数**,单测直接钉它;见模块文档第 2 节)。
///
/// `hit` 是「选中项的两个支持列表」:`None` = 没选中,`Some((版本, 加载器))` = 选中了。
/// **空列表 ≠ 不兼容**:站点没给数据时按「未知」处理,不许把「不知道」画成「不支持」。
pub(super) fn blockers_of(
hit: Option<(&[String], &[String])>,
kind: ContentKind,
targets: &[Target],
target: Option<&str>,
) -> Vec<Blocker> {
if targets.is_empty() {
return vec![Blocker::NoTarget];
}
let Some(target) = target.and_then(|id| targets.iter().find(|item| item.id == id)) else {
return vec![Blocker::NoTarget];
};
let mut blockers = Vec::new();
// 整合包:不是「兼容不兼容」的问题,是这条路根本没有(M111)
if kind.dir().is_none() {
blockers.push(Blocker::NeedsPipeline);
}
let Some((versions, loaders)) = hit else {
return blockers;
};
if !versions.is_empty() && !versions.iter().any(|item| item == &target.mc_version) {
blockers.push(Blocker::Version {
needs: join_or_unknown(versions),
actual: target.mc_version.clone(),
});
}
let facet = loader_facet(target.loader);
if let Some(facet) = facet {
if !loaders.is_empty() && !loaders.iter().any(|item| item == facet) {
blockers.push(Blocker::Loader {
loader: target.loader,
});
}
}
blockers
}
/// 版本列表 → 一行人读串(最多三个,其余用 `…`;空 = 「未知」)。
fn join_or_unknown(versions: &[String]) -> String {
if versions.is_empty() {
return "?".to_string();
}
let shown = versions.iter().take(3).cloned().collect::<Vec<_>>();
if versions.len() > shown.len() {
format!("{}…", shown.join(", "))
} else {
shown.join(", ")
}
}
#[cfg(test)]
#[path = "model/tests.rs"]
mod tests;
@@ -0,0 +1,218 @@
//! 内容库页的类型:页面自留状态 / 四态 / 展示行 / 目标实例 / 装不上的原因。
//!
//! 从 `pages/content/model.rs` 拆出(那个文件已贴 400 行硬上限)。纯数据类型与纯函数,
//! 不认识 iced:`model/tests.rs` 直接钉它们的判定。
use unlucky_presenter::dto::{ContentHit, ContentKind, ContentSource, InstanceSummary, LoaderKind};
use unlucky_i18n::{Catalog, Key};
/// 页面自留的选择(检索条件与结果在 presenter 的 `AppState::content`)。
#[derive(Debug, Clone, PartialEq)]
pub struct PageState {
/// 当前内容类型
pub kind: ContentKind,
/// 选中的项目 id(`None` = 没选 → 详情面板空态)
pub selected: Option<String>,
/// 目标实例 id(`None` = 没选 → 装入按钮禁用并说明)
pub target: Option<String>,
/// 关键词输入框里的草稿(**未提交**;提交后才进 `AppState::content.query`)
pub draft: String,
}
impl Default for PageState {
fn default() -> Self {
Self {
kind: ContentKind::Mod,
selected: None,
target: None,
draft: String::new(),
}
}
}
/// 页面此刻画哪一态(四态 + 分页尾)。
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Status {
/// 加载中(首屏 / 换条件 / 换页)
Loading,
/// 有命中项
Ready,
/// 拉到了但一条都没有
Empty,
/// 失败(带一句人读原因)
Failed(String),
}
impl Status {
/// 失败了吗(视图用它决定画错误面板还是列表)。
pub const fn failed(&self) -> bool {
matches!(self, Self::Failed(_))
}
}
/// 一条命中项的展示态(文案在这一层求好:iced 的文本控件借用内容)。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Hit {
/// 项目 id(选中 / 装入指令的载荷)
pub id: String,
/// 标题
pub title: String,
/// 一行说明
pub summary: String,
/// 来源 + 作者(`Modrinth · jelly`)
pub supporting: String,
/// 下载量(已加千分位)
pub downloads: String,
/// 更新日期的人读串
pub updated: String,
/// 支持的游戏版本(详情面板用;空 = 站点没给)
pub versions: Vec<String>,
/// 支持的加载器(**站点原样的小写 facet**:兼容性判定要用它跟 `loader_facet` 比)
pub loaders: Vec<String>,
/// 来源(行上标来源徽标)
pub source: ContentSource,
}
impl Hit {
/// 命中项 DTO → 展示态(**演示数据也走这条**,于是 fixture 与产品路径逐字段同源)。
pub(crate) fn of(hit: &ContentHit, catalog: Catalog) -> Self {
let source = match hit.source {
ContentSource::Modrinth => catalog.text(Key::ContentSourceModrinth),
ContentSource::Curseforge => catalog.text(Key::ContentSourceCurseforge),
};
let supporting = if hit.author.is_empty() {
source.to_string()
} else {
format!("{source} · {}", hit.author)
};
Self {
id: hit.id.clone(),
title: hit.title.clone(),
summary: hit.summary.clone(),
supporting,
downloads: catalog.render(
Key::ContentDetailDownloads,
&[("count", &thousands(hit.downloads))],
),
updated: catalog.render(Key::ContentDetailUpdated, &[("date", &hit.updated)]),
versions: hit.game_versions.clone(),
loaders: hit.loaders.clone(),
source: hit.source,
}
}
}
/// 目标实例(一键装入的对象)的展示态。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Target {
/// 实例 id
pub id: String,
/// 显示名
pub name: String,
/// 游戏版本
pub mc_version: String,
/// 加载器
pub loader: LoaderKind,
}
impl Target {
pub(super) fn of(instance: &InstanceSummary) -> Self {
Self {
id: instance.id.clone(),
name: instance.name.clone(),
mc_version: instance.mc_version.clone(),
loader: instance.loader.kind,
}
}
/// 详情里那一行(`Survival · 1.20.4 · Fabric`)。
pub(crate) fn label(&self) -> String {
format!(
"{} · {} · {}",
self.name,
self.mc_version,
loader_name(self.loader)
)
}
}
/// 下载量加千分位(`1200000` → `1,200,000`):raw 数字在详情面板上读不出量级。
pub(crate) fn thousands(value: u64) -> String {
let digits = value.to_string();
let mut out = String::with_capacity(digits.len() + digits.len() / 3);
for (index, ch) in digits.chars().enumerate() {
if index > 0 && (digits.len() - index).is_multiple_of(3) {
out.push(',');
}
out.push(ch);
}
out
}
/// 首字母大写(`fabric` → `Fabric`;空串原样)。
///
/// **只在画的时候用**:`Hit::loaders` 与兼容性判定都保持站点原样的小写 facet,
/// 否则「Fabric」与 facet「fabric」一比就成了不兼容(本轮踩过)。
pub(crate) fn capitalize(raw: &str) -> String {
let mut chars = raw.chars();
match chars.next() {
Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
None => String::new(),
}
}
/// 加载器的**界面名**(ASCII)。
///
/// 不复用 `LoaderKind::label()`:它对原版返回「原版」——中文业务串,而本仓的界面文案在 i18n
/// 之前是英文基线(干净 CI 上没有中文字体,见 `pages/instances/data.rs` 的同一条理由)。
pub const fn loader_name(loader: LoaderKind) -> &'static str {
match loader {
LoaderKind::Vanilla => "Vanilla",
LoaderKind::Fabric => "Fabric",
LoaderKind::Quilt => "Quilt",
LoaderKind::Forge => "Forge",
LoaderKind::NeoForge => "NeoForge",
LoaderKind::OptiFine => "OptiFine",
LoaderKind::LiteLoader => "LiteLoader",
}
}
/// 装不上的原因(**一条一个原因**,可以有多条同时成立)。
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Blocker {
/// 整包类内容不走「单文件进目录」这条路(M111 才有导入管线)
NeedsPipeline,
/// 项目不支持目标实例的游戏版本
Version {
/// 项目支持的游戏版本(人读,最多取三个)
needs: String,
/// 目标实例的版本
actual: String,
},
/// 项目不支持目标实例的加载器
Loader {
/// 目标实例的加载器
loader: LoaderKind,
},
/// 没有目标实例
NoTarget,
}
impl Blocker {
/// 一句话原因(i18n)。
pub(super) fn reason(&self, catalog: Catalog) -> String {
match self {
Self::NeedsPipeline => catalog.text(Key::ContentModpackNotReady).to_string(),
Self::Version { needs, actual } => catalog.render(
Key::ContentIncompatibleVersion,
&[("version", needs), ("actual", actual)],
),
Self::Loader { loader } => catalog.render(
Key::ContentIncompatibleLoader,
&[("loader", loader_name(*loader))],
),
Self::NoTarget => catalog.text(Key::ContentNoInstances).to_string(),
}
}
}
@@ -0,0 +1,274 @@
//! 内容库视图模型的守门测试(M109):四态判定、兼容性判定、装入闸门。
//!
//! 全部是纯函数级断言(不开图形环境):`status_of` / `blockers_of` / `Content::can_install`
//! 是页面「该画什么、按钮能不能按」的唯一判据,钉住它们比截图更早期。
use super::*;
use unlucky_i18n::Lang;
use unlucky_presenter::dto::{
ContentHit, ContentKind, ContentQuery, ContentResults, ContentSource, InstanceRuntime,
LoaderKind,
};
fn hit(versions: &[&str], loaders: &[&str]) -> ContentHit {
ContentHit {
id: "p1".to_string(),
slug: "p".to_string(),
title: "Sodium".to_string(),
summary: "fast".to_string(),
author: "jelly".to_string(),
downloads: 10,
updated: "2026-01-01".to_string(),
source: ContentSource::Modrinth,
game_versions: versions.iter().map(|item| (*item).to_string()).collect(),
loaders: loaders.iter().map(|item| (*item).to_string()).collect(),
icon: None,
}
}
fn targets() -> Vec<Target> {
vec![
Target {
id: "fabric-1.20.1".to_string(),
name: "Survival".to_string(),
mc_version: "1.20.1".to_string(),
loader: LoaderKind::Fabric,
},
Target {
id: "forge-1.21.1".to_string(),
name: "Modded".to_string(),
mc_version: "1.21.1".to_string(),
loader: LoaderKind::Forge,
},
]
}
fn blockers_of_hit(hit: &ContentHit, kind: ContentKind, target: Option<&str>) -> Vec<Blocker> {
blockers_of(
Some((hit.game_versions.as_slice(), hit.loaders.as_slice())),
kind,
&targets(),
target,
)
}
#[test]
fn a_matching_project_has_no_blockers() {
let hit = hit(&["1.20.1"], &["fabric"]);
assert!(blockers_of_hit(&hit, ContentKind::Mod, Some("fabric-1.20.1")).is_empty());
}
#[test]
fn a_version_mismatch_is_reported_before_the_user_clicks() {
// 抓得住:把「版本对不上」留到点了才报错(任务书第 3 条禁止的正是这个)。
let hit = hit(&["1.20.1"], &["fabric"]);
let blockers = blockers_of_hit(&hit, ContentKind::Mod, Some("forge-1.21.1"));
assert!(blockers.contains(&Blocker::Version {
needs: "1.20.1".to_string(),
actual: "1.21.1".to_string()
}));
assert!(blockers.contains(&Blocker::Loader {
loader: LoaderKind::Forge
}));
}
#[test]
fn both_mismatches_are_reported_separately() {
// 合成一条会在「都不兼容」时只说一半原因
let hit = hit(&["1.20.1"], &["fabric"]);
assert_eq!(
blockers_of_hit(&hit, ContentKind::Mod, Some("forge-1.21.1")).len(),
2
);
}
#[test]
fn site_data_that_is_missing_never_blocks() {
// 站点没给版本/加载器 = 未知,不是「不支持」
let hit = hit(&[], &[]);
assert!(blockers_of_hit(&hit, ContentKind::Mod, Some("forge-1.21.1")).is_empty());
}
#[test]
fn modpacks_are_blocked_by_the_missing_pipeline_not_by_compatibility() {
let hit = hit(&["1.20.1"], &["fabric"]);
assert_eq!(
blockers_of_hit(&hit, ContentKind::Modpack, Some("fabric-1.20.1")),
vec![Blocker::NeedsPipeline]
);
// 资源包 / 光影 / Mod 都没有这一条
for kind in [
ContentKind::Mod,
ContentKind::Resourcepack,
ContentKind::Shader,
] {
assert!(blockers_of_hit(&hit, kind, Some("fabric-1.20.1")).is_empty());
}
}
#[test]
fn no_target_instance_disables_the_button_with_a_reason() {
assert_eq!(
blockers_of(None, ContentKind::Mod, &[], None),
vec![Blocker::NoTarget]
);
// 有实例但选中项不在名单里(被删了):同样不许静默通过
assert_eq!(
blockers_of(None, ContentKind::Mod, &targets(), Some("gone")),
vec![Blocker::NoTarget]
);
// 没选中项目:只剩「目标」与「整包」这两类先天原因
assert!(blockers_of(None, ContentKind::Mod, &targets(), Some("fabric-1.20.1")).is_empty());
}
fn state_with(results: Option<ContentResults>, pending: bool) -> AppState {
let mut state = AppState::new();
state.instances = Vec::new();
state.content.pending = pending;
state.content.results = results;
state
}
fn results(hits: Vec<ContentHit>, offset: u32) -> ContentResults {
let mut query = ContentQuery::first(ContentKind::Mod);
query.offset = offset;
ContentResults {
query,
hits,
total: 100,
curseforge: false,
}
}
#[test]
fn the_four_states_are_derived_from_three_fields() {
let page = PageState::default();
let catalog = Lang::En.catalog();
// 还没有结果 ⇒ 加载
let loading = Content::from_snapshot(&state_with(None, false), &page, catalog);
assert!(loading.is_loading());
assert_eq!(loading.status, Status::Loading);
// 请求在飞 ⇒ 加载(即使手里还有上一批)
let pending = Content::from_snapshot(
&state_with(Some(results(vec![hit(&["1.20.1"], &["fabric"])], 0)), true),
&page,
catalog,
);
assert_eq!(pending.status, Status::Loading);
// 拉到了但零命中 ⇒ 空
let empty = Content::from_snapshot(
&state_with(Some(results(Vec::new(), 0)), false),
&page,
catalog,
);
assert_eq!(empty.status, Status::Empty);
// 有命中 ⇒ 有数据
let ready = Content::from_snapshot(
&state_with(Some(results(vec![hit(&["1.20.1"], &["fabric"])], 0)), false),
&page,
catalog,
);
assert_eq!(ready.status, Status::Ready);
assert_eq!(ready.hits.len(), 1);
assert_eq!(ready.hits[0].title, "Sodium");
assert!(!ready.has_more, "只有 1 条、不够一页 ⇒ 没有下一页");
}
#[test]
fn a_failure_outranks_the_loading_state() {
let mut state = state_with(None, true);
state.content.failed = Some("网络不通".to_string());
let model = Content::from_snapshot(&state, &PageState::default(), Lang::En.catalog());
assert_eq!(model.status, Status::Failed("网络不通".to_string()));
assert!(model.status.failed());
}
#[test]
fn can_install_needs_a_selection_and_no_blockers() {
let mut state = state_with(Some(results(vec![hit(&["1.20.1"], &["fabric"])], 0)), false);
state.instances = vec![unlucky_presenter::dto::InstanceSummary {
id: "fabric-1.20.1".to_string(),
name: "Survival".to_string(),
mc_version: "1.20.1".to_string(),
loader: unlucky_presenter::dto::LoaderInfo {
kind: LoaderKind::Fabric,
version: "0.16.5".to_string(),
},
last_played: None,
runtime: InstanceRuntime::Idle,
icon: None,
isolation: "isolated".to_string(),
version_id: "1.20.1".to_string(),
version_present: true,
}];
let catalog = Lang::En.catalog();
let page = PageState {
kind: ContentKind::Mod,
selected: None,
target: None,
draft: String::new(),
};
// 没选中项目 ⇒ 不能装(也不该说「成功」)
let no_selection = Content::from_snapshot(&state, &page, catalog);
assert!(!no_selection.can_install());
assert_eq!(
no_selection.target.as_deref(),
Some("fabric-1.20.1"),
"只有一个实例就默认选它"
);
// 选中兼容项 ⇒ 能装
let page = PageState {
selected: Some("p1".to_string()),
..page
};
let compatible = Content::from_snapshot(&state, &page, catalog);
assert!(compatible.can_install());
assert!(compatible.blocker_reasons(catalog).is_empty());
assert!(compatible.target_label.is_some());
}
#[test]
fn the_panel_only_shows_while_an_install_is_unfinished() {
use unlucky_presenter::dto::{InstallProgressView, InstallStage};
let mut state = state_with(None, false);
state.install = Some(InstallProgressView {
version: "Sodium".to_string(),
stage: InstallStage::Downloading,
files_total: 1,
..InstallProgressView::new()
});
let running = Content::from_snapshot(&state, &PageState::default(), Lang::En.catalog());
assert!(running.panel.is_some(), "下载中要有面板");
state.install = Some(InstallProgressView {
stage: InstallStage::Done,
..InstallProgressView::new()
});
let done = Content::from_snapshot(&state, &PageState::default(), Lang::En.catalog());
assert!(done.panel.is_none(), "装完的面板不该钉在页面上");
}
#[test]
fn loader_names_are_ascii_so_the_english_baseline_stays_english() {
for loader in [
LoaderKind::Vanilla,
LoaderKind::Fabric,
LoaderKind::Forge,
LoaderKind::NeoForge,
LoaderKind::Quilt,
] {
assert!(loader_name(loader).is_ascii(), "{loader:?}");
}
assert_eq!(
loader_name(LoaderKind::Vanilla),
"Vanilla",
"不能画「原版」"
);
}
+333
View File
@@ -0,0 +1,333 @@
//! 内容库页的零件:筛选条 / 结果列表 / 详情面板 / 四态(加载 / 空 / 失败 / 无更多)/ 一级切换。
//!
//! 全是既有真组件(`chip` / `list_item` / `buttons` / `empty_state`),页面不自己画状态层。
//!
//! ## 消息口径
//!
//! 本文件的每个控件都直接发 [`crate::pages::Message`]:内容库自己的意图包一层
//! `PageMessage::Content(..)`([`intent`]),而「取消安装 / 打开日志」用的是**下载页就有的**
//! 那两个变体——因为对用户来说它们是同一件事(同一个 `AppCommand::CancelInstall`)。
use iced::widget::{column, container, row, scrollable};
use iced::{Alignment, Element, Length, Padding};
use unlucky_design::color::Role;
use unlucky_design::icons::IconKind;
use unlucky_design::spacing::Space;
use unlucky_design::typography::TypeRole;
use unlucky_i18n::{Catalog, Key};
use unlucky_presenter::dto::ContentSort;
use unlucky_presenter::router::Route;
use crate::app::components::buttons::{self, Button};
use crate::app::components::feedback::{empty_state, EmptyState};
use crate::app::components::navigation::list_item::{list_item, ListItem};
use crate::app::components::selection::{chip, Chip};
use crate::theme::Ui;
use super::layout::Rails;
use super::model::{Content, Hit, Status};
mod detail;
mod filters;
pub(crate) use detail::detail;
pub(crate) use filters::{filters, labelled};
use super::Message as ContentMessage;
use crate::pages::frame;
use crate::pages::Message as PageMessage;
/// 内容库意图 → 页面消息(唯一的一层包装)。
pub(super) fn intent(message: ContentMessage) -> PageMessage {
PageMessage::Content(message)
}
/// 结果行与行之间的间距(与下载页同值)。
const ROW_GAP: Space = Space::X1;
/// 一级切换:版本管理 | 内容库(M109 按 B1 裁定:共用同一条导航轨项,页内切换)。
pub(super) fn section_switcher<'a>(
ui: &'a Ui,
catalog: Catalog,
on_content: bool,
) -> Element<'a, PageMessage> {
let versions = Chip::filter(
catalog.text(Key::ContentSectionVersions),
!on_content,
PageMessage::Navigate(Route::Download),
);
let content = Chip::filter(
catalog.text(Key::ContentSectionContent),
on_content,
PageMessage::Navigate(Route::Content),
);
row![chip(ui, versions), chip(ui, content)]
.spacing(ui.space(Space::X2))
.align_y(Alignment::Center)
.into()
}
/// 关键词搜索条(输入只改草稿;回车/按钮才发检索指令)。
pub(super) fn search<'a>(
ui: &'a Ui,
catalog: Catalog,
model: &'a Content,
) -> Element<'a, PageMessage> {
// 输入只改草稿,**提交**才发检索(每敲一个字就打一次网络既没必要也会被限流)。
// 搜索条自身没有提交回调,所以旁边给一个显式的「搜索」按钮——这也是键盘外的唯一出口。
let bar = crate::app::components::inputs::search_bar(
ui,
crate::app::components::inputs::SearchBar::new(model.draft.as_str())
.placeholder(catalog.text(Key::ContentSearchPlaceholder)),
move |text| intent(ContentMessage::QueryChanged(text)),
);
let submit = crate::app::components::buttons::text(
ui,
crate::app::components::buttons::Button::new(
catalog.text(Key::ContentSearch),
intent(ContentMessage::Search),
)
.leading(IconKind::Search),
);
row![bar, submit]
.spacing(ui.space(Space::X2))
.align_y(Alignment::Center)
.width(Length::Fill)
.into()
}
/// 结果列表(独立滚动;底部「加载更多」钉在列表之外,见 [`more`])。
pub(super) fn list<'a>(
ui: &'a Ui,
catalog: Catalog,
model: &'a Content,
rails: Rails,
) -> Element<'a, PageMessage> {
let rows = column(
model
.hits
.iter()
.map(|hit| hit_row(ui, model, hit))
.collect::<Vec<_>>(),
)
.spacing(ui.space(ROW_GAP))
.padding(Padding::from([0.0, ui.space(Space::X2)]))
.width(Length::Fill);
let _ = catalog;
container(
scrollable(rows)
.direction(scrollable::Direction::Vertical(
scrollable::Scrollbar::new()
.width(frame::SCROLLBAR_WIDTH)
.scroller_width(frame::SCROLLBAR_WIDTH)
.margin(frame::SCROLLBAR_MARGIN),
))
.height(Length::Fill)
.style(frame::scroll_style(ui)),
)
.width(Length::Fixed(rails.list))
.height(Length::Fill)
.into()
}
fn hit_row<'a>(ui: &'a Ui, model: &Content, hit: &'a Hit) -> Element<'a, PageMessage> {
let mut item = ListItem::new(
hit.title.as_str(),
intent(ContentMessage::Select(hit.id.clone())),
)
.overline(hit.supporting.as_str())
.supporting(hit.summary.as_str())
.leading(kind_icon(model.kind));
if model.selected.as_deref() == Some(hit.id.as_str()) {
item = item.selected();
}
list_item(ui, item)
}
/// 计数头 + 翻页尾(分页而非无限滚动:见 `pages/content.rs` 的模块文档)。
pub(super) fn count_head<'a>(
ui: &'a Ui,
catalog: Catalog,
model: &Content,
) -> Element<'a, PageMessage> {
let count = if model.total > 0 {
model.total
} else {
u32::try_from(model.hits.len()).unwrap_or(u32::MAX)
};
frame::text_role_owned(
ui,
TypeRole::LabelMedium,
false,
catalog.render(
Key::ContentCount,
&[("count", &super::model::thousands(u64::from(count)))],
),
ui.color(Role::OnSurfaceVariant),
)
}
/// 列表尾部:还有更多 ⇒「加载更多」;没有了 ⇒ 一句「没有更多了」。
pub(super) fn more<'a>(ui: &'a Ui, catalog: Catalog, model: &Content) -> Element<'a, PageMessage> {
if model.has_more {
return buttons::text(
ui,
Button::new(
catalog.text(Key::ContentLoadMore),
intent(ContentMessage::LoadMore),
)
.leading(IconKind::ChevronDown),
);
}
frame::text_role(
ui,
TypeRole::BodySmall,
false,
catalog.text(Key::ContentNoMore),
ui.color(Role::OnSurfaceVariant),
)
}
pub(super) fn loading<'a>(ui: &'a Ui, catalog: Catalog) -> Element<'a, PageMessage> {
centered(
column![
frame::text_role_centered(
ui,
TypeRole::TitleMedium,
true,
catalog.text(Key::ContentLoading),
ui.color(Role::OnSurface)
),
frame::text_role_centered(
ui,
TypeRole::BodySmall,
false,
catalog.text(Key::ContentLoadingHint),
ui.color(Role::OnSurfaceVariant)
),
buttons::text(
ui,
Button::new(
catalog.text(Key::ContentRetry),
intent(ContentMessage::Retry)
)
),
]
.spacing(ui.space(Space::X3))
.width(Length::Fill)
.align_x(Alignment::Center),
)
}
/// 空态(真 `EmptyState`:CTA 是标准 Filled 按钮)。
pub(super) fn empty<'a>(ui: &'a Ui, catalog: Catalog) -> Element<'a, PageMessage> {
centered(empty_state(
ui,
EmptyState::new(
IconKind::Search,
catalog.text(Key::ContentEmptyTitle),
catalog.text(Key::ContentEmptyBody),
)
.action(
catalog.text(Key::ContentRetry),
intent(ContentMessage::Retry),
),
))
}
/// 失败态(带原因与重试出口;原因来自 host,不打码不打折)。
pub(super) fn failed<'a>(
ui: &'a Ui,
catalog: Catalog,
reason: &'a str,
) -> Element<'a, PageMessage> {
centered(
column![
row![
frame::glyph(IconKind::Error, ui.color(Role::Error), frame::ICON_PX),
frame::text_role(
ui,
TypeRole::TitleSmall,
true,
catalog.text(Key::ContentFailedTitle),
ui.color(Role::Error)
),
]
.spacing(ui.space(Space::X2))
.align_y(Alignment::Center),
frame::text_role(
ui,
TypeRole::BodySmall,
false,
reason,
ui.color(Role::OnSurfaceVariant)
),
frame::text_role(
ui,
TypeRole::BodySmall,
false,
catalog.text(Key::ContentFailedBody),
ui.color(Role::OnSurfaceVariant)
),
buttons::filled(
ui,
Button::new(
catalog.text(Key::ContentRetry),
intent(ContentMessage::Retry)
)
.leading(IconKind::Refresh)
),
]
.spacing(ui.space(Space::X3))
.width(Length::Fill)
.align_x(Alignment::Center),
)
}
fn centered<'a>(content: impl Into<Element<'a, PageMessage>>) -> Element<'a, PageMessage> {
container(content)
.width(Length::Fill)
.height(Length::Fill)
.align_x(Alignment::Center)
.align_y(Alignment::Center)
.into()
}
/// 内容类型 → 图标(**借用**现有字形:图标集里还没有「整合包/Mod/资源包/光影」四枚专用图标,
/// 已在报告里登记为偏离)。
pub(super) const fn kind_icon(kind: unlucky_presenter::dto::ContentKind) -> IconKind {
match kind {
unlucky_presenter::dto::ContentKind::Modpack => IconKind::Folder,
unlucky_presenter::dto::ContentKind::Mod => IconKind::Instances,
unlucky_presenter::dto::ContentKind::Resourcepack => IconKind::Download,
// `Play`/`Stop`/`More` 是**填充**字形,pages 里禁用(tests/no_hardcoded_tokens):
// 光影借用 `Expand`(描边的四散光线),语义最近又不撞语汇
unlucky_presenter::dto::ContentKind::Shader => IconKind::Expand,
}
}
pub(super) fn sort_label(sort: ContentSort, catalog: Catalog) -> &'static str {
match sort {
ContentSort::Relevance => catalog.text(Key::ContentSortRelevance),
ContentSort::Downloads => catalog.text(Key::ContentSortDownloads),
ContentSort::Newest => catalog.text(Key::ContentSortNewest),
ContentSort::Updated => catalog.text(Key::ContentSortUpdated),
}
}
/// 状态分派(四态各一支;`Ready` 由调用方画列表 + 详情)。
pub(super) fn status_body<'a>(
ui: &'a Ui,
catalog: Catalog,
model: &'a Content,
) -> Option<Element<'a, PageMessage>> {
match &model.status {
Status::Loading => Some(loading(ui, catalog)),
Status::Empty => Some(empty(ui, catalog)),
Status::Failed(reason) => Some(failed(ui, catalog, reason.as_str())),
Status::Ready => None,
}
}
@@ -0,0 +1,179 @@
//! 详情面板:选中项的支持范围 + 目标实例 + 装入(不兼容时**禁用并逐条给原因**)。
//!
//! 从 `pages/content/parts.rs` 拆出(那个文件已贴 400 行硬上限)。
//!
//! 这块是「一键装入指定实例」的**唯一**入口:按钮能不能按由 [`Content::can_install`] 决定,
//! 按不动时旁边逐条列出原因——判定与画法分开,`model/tests.rs` 才能不开图形环境就钉住它。
use iced::widget::{column, container};
use iced::{Element, Length};
use unlucky_design::color::Role;
use unlucky_design::icons::IconKind;
use unlucky_design::spacing::Space;
use unlucky_design::typography::TypeRole;
use unlucky_i18n::{Catalog, Key};
use crate::app::components::buttons::{self, Button};
use crate::app::components::divider;
use crate::app::components::selection::{chip, Chip};
use crate::theme::Ui;
use super::super::model::{capitalize, Content};
use super::super::Message as ContentMessage;
use super::{intent, labelled};
use crate::pages::frame;
use crate::pages::Message as PageMessage;
/// 详情面板:选中项的支持范围 + 目标实例 + 装入(不兼容时**禁用并给原因**)。
pub(crate) fn detail<'a>(
ui: &'a Ui,
catalog: Catalog,
model: &'a Content,
) -> Element<'a, PageMessage> {
let Some(hit) = model.selected_hit() else {
return container(frame::text_role(
ui,
TypeRole::BodySmall,
false,
catalog.text(Key::ContentDetailEmpty),
ui.color(Role::OnSurfaceVariant),
))
.width(Length::Fill)
.into();
};
let head = column![
frame::text_role(
ui,
TypeRole::TitleMedium,
true,
hit.title.as_str(),
ui.color(Role::OnSurface)
),
frame::text_role(
ui,
TypeRole::BodySmall,
false,
hit.supporting.as_str(),
ui.color(Role::OnSurfaceVariant),
),
frame::text_role(
ui,
TypeRole::BodySmall,
false,
hit.summary.as_str(),
ui.color(Role::OnSurfaceVariant),
),
]
.spacing(ui.space(Space::X1))
.width(Length::Fill);
let meta = column![
frame::text_role(
ui,
TypeRole::LabelMedium,
false,
hit.downloads.as_str(),
ui.color(Role::OnSurfaceVariant)
),
frame::text_role(
ui,
TypeRole::LabelMedium,
false,
hit.updated.as_str(),
ui.color(Role::OnSurfaceVariant)
),
support_line(ui, catalog, Key::ContentDetailVersions, &hit.versions),
support_line(ui, catalog, Key::ContentDetailLoaders, &hit.loaders),
]
.spacing(ui.space(Space::X1))
.width(Length::Fill);
let mut body = column![head, divider::full(ui), meta].spacing(ui.space(Space::X3));
// 目标实例:一行 chip(选中的那个高亮)
if model.targets.is_empty() {
body = body.push(frame::text_role(
ui,
TypeRole::BodySmall,
false,
catalog.text(Key::ContentNoInstances),
ui.color(Role::OnSurfaceVariant),
));
} else {
let mut targets = Vec::new();
for target in &model.targets {
targets.push(chip(
ui,
Chip::filter(
target.name.as_str(),
model.target.as_deref() == Some(target.id.as_str()),
intent(ContentMessage::SetTarget(target.id.clone())),
),
));
}
body = body.push(labelled(
ui,
catalog.text(Key::ContentTargetInstance),
targets,
));
}
// 装入:能装 ⇒ 实心按钮;不能 ⇒ 禁用 + 逐条原因(**点之前**就说清楚)
if model.can_install() {
body = body.push(buttons::filled(
ui,
Button::new(
catalog.text(Key::ContentInstall),
intent(ContentMessage::Install),
)
.leading(IconKind::Download),
));
} else {
body = body.push(buttons::outlined(
ui,
Button::disabled(catalog.text(Key::ContentInstall)),
));
for reason in model.blocker_reasons(catalog) {
body = body.push(frame::text_role_owned(
ui,
TypeRole::BodySmall,
false,
reason,
ui.color(Role::OnSurfaceVariant),
));
}
}
container(body.spacing(ui.space(Space::X3)))
.width(Length::Fill)
.into()
}
/// 「游戏版本:1.20.1, 1.20.4」一行(空列表给 `—`,不编造)。
fn support_line<'a>(
ui: &'a Ui,
catalog: Catalog,
key: Key,
values: &[String],
) -> Element<'a, PageMessage> {
let list = if values.is_empty() {
"—".to_string()
} else {
// 加载器是站点给的小写 facet(`fabric`):这一层只管怎么显示
values
.iter()
.map(|value| capitalize(value))
.collect::<Vec<_>>()
.join(", ")
};
frame::text_role_owned(
ui,
TypeRole::BodySmall,
false,
catalog.render(key, &[("list", &list)]),
ui.color(Role::OnSurfaceVariant),
)
}
@@ -0,0 +1,163 @@
//! 筛选条:来源 / 排序 / 游戏版本 / 加载器四组 chip,外加分组标签与换行容器。
//!
//! 从 `pages/content/parts.rs` 拆出(那个文件已贴 400 行硬上限)。
//!
//! 缺 key 的源(CurseForge)画成**禁用**而不是消失:筛选条要能被发现,也要能解释自己。
use iced::widget::{column, container, row};
use iced::{Alignment, Element, Length};
use unlucky_design::color::Role;
use unlucky_design::spacing::Space;
use unlucky_design::typography::TypeRole;
use unlucky_i18n::{Catalog, Key};
use unlucky_presenter::dto::{loader_facet, ContentSort, ContentSource, LoaderKind};
use crate::app::components::selection::{chip, Chip};
use crate::theme::Ui;
use super::super::model::{loader_name, Content};
use super::super::Message as ContentMessage;
use super::{intent, sort_label};
use crate::pages::frame;
use crate::pages::Message as PageMessage;
/// 来源 + 排序 + 版本 + 加载器四组 chip(一行放不下就换行,`row` 变 `column`)。
pub(crate) fn filters<'a>(
ui: &'a Ui,
catalog: Catalog,
model: &'a Content,
) -> Element<'a, PageMessage> {
let mut source = Vec::new();
for item in ContentSource::ALL {
let available = match item {
ContentSource::Modrinth => true,
ContentSource::Curseforge => model.curseforge,
};
let label = match item {
ContentSource::Modrinth => catalog.text(Key::ContentSourceModrinth),
ContentSource::Curseforge => catalog.text(Key::ContentSourceCurseforge),
};
let mut spec = Chip::filter(
label,
model.query.source == item,
intent(ContentMessage::SetSource(item)),
);
if !available {
// 缺 key 的源:**禁用并说明**,不是静默消失(否则用户找不到它)
spec = spec.disabled();
}
source.push(chip(ui, spec));
}
let mut sort = Vec::new();
for item in ContentSort::ALL {
sort.push(chip(
ui,
Chip::filter(
sort_label(item, catalog),
model.query.sort == item,
intent(ContentMessage::SetSort(item)),
),
));
}
let mut versions = vec![chip(
ui,
Chip::filter(
catalog.text(Key::ContentFilterAny),
model.query.version.is_none(),
intent(ContentMessage::SetVersion(None)),
),
)];
for version in &model.versions {
versions.push(chip(
ui,
Chip::filter(
version.as_str(),
model.query.version.as_deref() == Some(version.as_str()),
intent(ContentMessage::SetVersion(Some(version.clone()))),
),
));
}
let mut loaders = vec![chip(
ui,
Chip::filter(
catalog.text(Key::ContentFilterAny),
model.query.loader.is_none(),
intent(ContentMessage::SetLoader(None)),
),
)];
for loader in [
LoaderKind::Fabric,
LoaderKind::Forge,
LoaderKind::NeoForge,
LoaderKind::Quilt,
] {
let usable = loader_facet(loader).is_some();
let mut spec = Chip::filter(
loader_name(loader),
model.query.loader == Some(loader),
intent(ContentMessage::SetLoader(Some(loader))),
);
if !usable {
spec = spec.disabled();
}
loaders.push(chip(ui, spec));
}
let mut column = column![
labelled(ui, catalog.text(Key::ContentFilterVersion), versions),
labelled(ui, catalog.text(Key::ContentFilterLoader), loaders),
wrapped_row(ui, source),
wrapped_row(ui, sort),
]
.spacing(ui.space(Space::X2))
.width(Length::Fill);
if !model.curseforge {
column = column.push(frame::text_role(
ui,
TypeRole::BodySmall,
false,
catalog.text(Key::ContentSourceUnavailable),
ui.color(Role::OnSurfaceVariant),
));
}
column.into()
}
/// 分组标签 + 一行 chip(标签左、chip 右,纵向居中对齐)。
pub(crate) fn labelled<'a>(
ui: &'a Ui,
label: &'a str,
chips: Vec<Element<'a, PageMessage>>,
) -> Element<'a, PageMessage> {
row![
container(frame::text_role(
ui,
TypeRole::LabelMedium,
false,
label,
ui.color(Role::OnSurfaceVariant),
)),
wrapped_row(ui, chips),
]
.spacing(ui.space(Space::X2))
.align_y(Alignment::Center)
.width(Length::Fill)
.into()
}
/// `Vec<Element>` → 一行(顺序就是声明顺序)。
pub(crate) fn wrapped_row<'a>(
ui: &'a Ui,
chips: Vec<Element<'a, PageMessage>>,
) -> Element<'a, PageMessage> {
row(chips)
.spacing(ui.space(Space::X1))
.align_y(Alignment::Center)
.into()
}
@@ -0,0 +1,87 @@
//! 内容库页的守门(M109):演示数据、四类内容、页面自留状态的默认值。
//!
//! 视图模型的判定在 `model/tests.rs`,栏几何在 `layout.rs`,这里只管「演示数据真的装得满」——
//! 出图靠它,装不满就说明 `demo()` 与 `Content` 的字段对不上。
use unlucky_i18n::Lang;
use unlucky_presenter::dto::ContentKind;
use super::model::{PageState, Status};
use super::{demo, Content};
#[test]
fn the_page_state_starts_on_mods_with_nothing_selected() {
let page = PageState::default();
assert_eq!(page.kind, ContentKind::Mod);
assert!(page.selected.is_none());
assert!(page.target.is_none());
assert!(page.draft.is_empty());
}
#[test]
fn every_kind_gets_a_full_demo_screen() {
// 四种内容各出一屏(状态矩阵的前 8 张靠这一条)
for kind in ContentKind::ALL {
let model: Content = demo(kind, Lang::En);
assert_eq!(model.kind, kind, "{} 的演示数据换了类型", kind.slug());
assert_eq!(model.status, Status::Ready, "{}", kind.slug());
assert_eq!(model.hits.len(), 4, "{} 的命中项不足", kind.slug());
assert!(
model.selected.is_some(),
"演示数据要选中一条(详情面板才有内容)"
);
assert!(model.total > 0);
assert!(model.has_more, "演示数据要能画「加载更多」");
// 整合包**不**在这一条里断(它天生不可装:`ContentKind::Modpack.dir()` 是 `None`),
// 由下一条用例专钉。
if kind != ContentKind::Modpack {
assert!(
model.can_install(),
"默认那一屏应当是「能装」的({})",
kind.slug()
);
}
}
}
#[test]
fn the_modpack_screen_is_browsable_but_never_installable() {
// 整合包:检索能看,装入一律禁用(M111 未落地)——演示图上必须看得到原因
let model = demo(ContentKind::Modpack, Lang::En);
assert!(model.selected.is_some());
assert!(!model.can_install());
assert_eq!(
model.blocker_reasons(Lang::En.catalog()).len(),
1,
"整合包只有一条原因(导入管线未落地)"
);
}
#[test]
fn the_installable_kinds_block_only_when_the_target_disagrees() {
for kind in [
ContentKind::Mod,
ContentKind::Resourcepack,
ContentKind::Shader,
] {
let model = demo(kind, Lang::En);
assert!(
model.blocker_reasons(Lang::En.catalog()).is_empty(),
"{} 的默认演示项应当可装",
kind.slug()
);
}
}
#[test]
fn the_demo_selection_is_always_one_of_the_hits() {
for kind in ContentKind::ALL {
let model = demo(kind, Lang::En);
let selected = model.selected.clone().expect("有选中项");
assert!(
model.hits.iter().any(|hit| hit.id == selected),
"{} 的选中项不在命中列表里(详情面板会是空的)",
kind.slug()
);
}
}
+222
View File
@@ -0,0 +1,222 @@
//! 内容库页的整页装配:页头(含一级切换)+ 左侧类型栏 + 主列(筛选/结果)+ 详情 + 安装面板。
//!
//! ## 版面(几何在 [`super::layout`],这里只消费结果)
//!
//! ```text
//! ┌─ 页头:Content [版本管理 | 内容库] ─┐
//! ├──────┬────────────────────────┬──────────────┤
//! │ 类型 │ 搜索 + 四组 chip │ 详情 / 装入 │
//! │ 四条 │ 计数头 │ 原因逐条列出 │
//! │ 侧栏 │ 结果列表(独立滚动) │ │
//! │ │ 加载更多 / 没有更多了 │ │
//! ├──────┴────────────────────────┴──────────────┤
//! │ 安装面板(**下载页那块**,只在有安装时出现) │
//! └──────────────────────────────────────────────┘
//! ```
//!
//! ## 为什么类型栏用 `list_item`(与 M107 的版本类型栏同一套)
//!
//! 四条内容类型是**一级侧栏**(页面内导航),几何与状态层必须与版本管理页一致:`list_item`
//! 自带 60 高两行档与选中态,页面不自己写 hover/pressed。窄档(`Tight`)退成一行 chip——
//! 栏宽没了就不该硬塞一条 140px 的栏。
use iced::widget::{column, container, row};
use iced::{Element, Length};
use unlucky_design::spacing::Space;
use unlucky_i18n::{Catalog, Key};
use unlucky_presenter::dto::ContentKind;
use crate::app::components::divider;
use crate::app::components::navigation::list_item::{list_item, ListItem};
use crate::app::components::selection::{chip, Chip};
use crate::theme::Ui;
use crate::pages::download;
use crate::pages::frame::{self, Header};
use crate::pages::layout::FrameMetrics;
use crate::pages::Message as PageMessage;
use super::layout::{self as content_layout, Rails, Tier};
use super::model::{Content, Status};
use super::parts;
use super::Message as ContentMessage;
/// 整页入口(由 `pages/mod.rs` 的 `render` 调;页面层不认识 presenter 类型)。
pub(super) fn view<'a>(
ui: &'a Ui,
metrics: FrameMetrics,
model: &'a Content,
lang: unlucky_i18n::Lang,
) -> Element<'a, PageMessage> {
let catalog = lang.catalog();
let header = Header::new(catalog.text(Key::ContentTitle))
.subtitle(catalog.text(Key::ContentSubtitle))
.action(parts::section_switcher(ui, catalog, true));
frame::page(ui, metrics, header, body(ui, metrics, catalog, model))
}
fn body<'a>(
ui: &'a Ui,
metrics: FrameMetrics,
catalog: Catalog,
model: &'a Content,
) -> Element<'a, PageMessage> {
let rails = content_layout::resolve(metrics.available);
let mut columns = row![kind_rail(ui, catalog, model, rails)]
.spacing(rails.gap)
.width(Length::Fill)
.height(Length::Fill);
if rails.tier == Tier::Tight {
// 窄到放不下侧栏:类型条跑到主列上方(同一个 `kind_rail`,chip 形态)
let main = column![
kind_rail(ui, catalog, model, rails),
divider::full(ui),
main_column(ui, catalog, model, rails)
]
.spacing(ui.space(Space::X2))
.width(Length::Fill)
.height(Length::Fill);
columns = row![main].width(Length::Fill).height(Length::Fill);
} else {
columns = columns.push(main_column(ui, catalog, model, rails));
if rails.tier.has_detail() {
columns = columns.push(detail_column(ui, catalog, model, rails));
}
}
let mut stack = column![columns]
.spacing(ui.space(Space::X3))
.width(Length::Fill)
.height(Length::Fill);
if let Some(panel) = &model.panel {
// **同一块安装面板**(下载页那个函数):内容库不另画一套进度/失败/取消。
stack = stack
.push(divider::full(ui))
.push(download::install_panel(ui, catalog, panel));
}
stack.into()
}
/// 左侧四条内容类型(`Wide`/`Compact` 是列表项,`Narrow` 是图标项,`Tight` 是一行 chip)。
fn kind_rail<'a>(
ui: &'a Ui,
catalog: Catalog,
model: &'a Content,
rails: Rails,
) -> Element<'a, PageMessage> {
if rails.tier == Tier::Tight {
let mut chips = Vec::new();
for kind in ContentKind::ALL {
chips.push(chip(
ui,
Chip::filter(
kind_label(kind, catalog),
model.kind == kind,
PageMessage::Content(ContentMessage::SetKind(kind)),
),
));
}
return row(chips)
.spacing(ui.space(Space::X2))
.width(Length::Fill)
.into();
}
let labels = rails.tier.rail_has_labels();
let mut items = column(Vec::new())
.spacing(ui.space(Space::X1))
.width(Length::Fill);
for kind in ContentKind::ALL {
let label = kind_label(kind, catalog);
// 图标档没有文字位:标签仍传(无障碍/日志用),宽度由几何裁掉
let mut item = ListItem::new(
if labels { label } else { " " },
PageMessage::Content(ContentMessage::SetKind(kind)),
)
.leading(parts::kind_icon(kind));
if model.kind == kind {
item = item.selected();
}
items = items.push(list_item(ui, item));
}
container(items)
.width(Length::Fixed(rails.rail))
.height(Length::Fill)
.into()
}
/// 主列:搜索条 + 筛选 + 计数 + 结果(或四态之一)。
fn main_column<'a>(
ui: &'a Ui,
catalog: Catalog,
model: &'a Content,
rails: Rails,
) -> Element<'a, PageMessage> {
// 主列的横向内边距不在这里加:`rails.list` 已经把可用宽算干了,再加 2×X2 会让整排
// 溢出 32px(详情列被切掉一半,复审截图上正是这条)
let mut head = column![parts::search(ui, catalog, model)]
.spacing(ui.space(Space::X2))
.width(Length::Fill);
// 筛选条**永远在**:它是控制面,不是数据。加载中把筛选条藏起来,用户就没法在慢网络下
// 换条件或换类型(只能干等),这是「加载态顺手把入口收走」的经典坑。
head = head.push(parts::filters(ui, catalog, model));
head = head.push(divider::full(ui));
if model.status == Status::Ready {
// 计数头只在有数据时出现:0 条的「0 results」和空态说的是同一件事,画两遍是噪音
head = head.push(parts::count_head(ui, catalog, model));
}
let body: Element<'a, PageMessage> = if let Some(state) = parts::status_body(ui, catalog, model)
{
state
} else {
{
let mut list = column![parts::list(ui, catalog, model, rails)]
.width(Length::Fill)
.height(Length::Fill);
// 紧凑档没有独立详情列:详情压在列表下面(整页替换的成本比「多一块」高)
if !rails.tier.has_detail() {
list = list
.push(divider::full(ui))
.push(parts::detail(ui, catalog, model));
}
list.push(parts::more(ui, catalog, model)).into()
}
};
column![head, body]
.spacing(ui.space(Space::X2))
.width(Length::Fixed(rails.list))
.height(Length::Fill)
.into()
}
/// 详情列(只在 `Wide` / `Narrow` 存在)。
fn detail_column<'a>(
ui: &'a Ui,
catalog: Catalog,
model: &'a Content,
rails: Rails,
) -> Element<'a, PageMessage> {
container(parts::detail(ui, catalog, model))
.width(Length::Fixed(rails.detail))
.height(Length::Fill)
.style(move |_theme| container::Style::default())
.into()
}
/// 四种内容类型的界面名。
fn kind_label(kind: ContentKind, catalog: Catalog) -> &'static str {
match kind {
ContentKind::Modpack => catalog.text(Key::ContentKindModpack),
ContentKind::Mod => catalog.text(Key::ContentKindMod),
ContentKind::Resourcepack => catalog.text(Key::ContentKindResourcepack),
ContentKind::Shader => catalog.text(Key::ContentKindShader),
}
}
+36
View File
@@ -109,6 +109,38 @@ pub use demo::demo;
pub use model::{cancelled_toast, Category, Download};
pub use options::{InstallDraft, Loader};
// M109:内容库页复用**同一块**安装面板(同一个函数、同一套文案与几何),不另写一套。
//
// 两个薄包装为什么必须在**这个文件**里:①`install::panel` 是 `pub(super)`(只在 `pages::download`
// 可见),再导出到 crate 会撞 `E0364`;②面板的入参是 `&Download`,而 `InstallView` 是页面私有
// 类型(M105 起不再从本模块导出),所以「快照 → 面板形状」的转换也留在这里——内容库只拿到
// `Option<Download>`,它不需要也不该认识 `InstallView`。
pub(crate) fn install_panel<'a>(
ui: &'a Ui,
catalog: Catalog,
model: &'a Download,
) -> Element<'a, Message> {
install::panel(ui, catalog, model)
}
/// 快照 → 面板要的 `Download` 形状(**只填 `install`**)。
///
/// 「装完 / 已取消」这类没有动作可给的终态返回 `None`(与下载页同一个判断
/// `InstallView::finished`):留着只会让面板停在一个点不动的状态上。
pub(crate) fn install_panel_model(
progress: &unlucky_presenter::dto::InstallProgressView,
catalog: Catalog,
) -> Option<Download> {
let view = model::InstallView::of(progress, catalog);
if view.finished() {
return None;
}
Some(Download {
install: Some(view),
..Download::loading()
})
}
use iced::widget::column;
use iced::{Element, Length};
@@ -120,6 +152,7 @@ use crate::app::components::buttons::{self, Button};
use crate::app::components::divider;
use crate::theme::Ui;
use super::content;
use super::frame::{self, Header};
use super::layout::{FrameMetrics, VersionRails};
use super::{Action, Message};
@@ -138,6 +171,9 @@ pub fn view<'a>(
let header = Header::new(catalog.text(Key::DownloadTitle))
.subtitle(catalog.text(Key::DownloadSubtitle))
// M109:一级切换「版本管理 | 内容库」。两页共用同一条导航轨项(`NavSection::Download`),
// 所以这个切换是进内容库的唯一产品入口——少了它,内容库只能靠 `ULL_START_ROUTE` 到达。
.action(content::section_switcher(ui, catalog))
.action(buttons::text(
ui,
Button::new(
+6
View File
@@ -11,6 +11,7 @@ use unlucky_i18n::LangChoice;
use unlucky_presenter::router::Route;
use super::appearance;
use super::content;
use super::download::{Category, Loader};
use super::settings;
@@ -85,6 +86,11 @@ pub enum Message {
/// (`app/update/settings/language_appearance.rs`)是 language+appearance 线的文件。
/// 代价是本文件多一个变体;收口方向写在 `settings/general.rs` 的文档里。
Language(LangChoice),
/// 内容库页的一组消息(**M109**):与设置页同一个范式(页面自己的枚举 + 一个投递口)。
///
/// 加一条内容库消息只动 `pages/content/message.rs`,不动本枚举的正文,也不动
/// `app/update/page_messages.rs` 那条不许 `_ =>` 的 match。
Content(content::Message),
}
/// 页面动作清单(骨架期够用就行;接线时按业务语义重命名/扩充)。
+4
View File
@@ -109,6 +109,7 @@ pub mod harness;
pub mod layout;
pub mod model;
pub mod content;
mod download;
mod instances;
mod logs;
@@ -213,6 +214,8 @@ fn render<'a>(
Screen::Instances => instances::view(ui, metrics, model),
// M106:下载页此前写死成 `Lang::En`,现在跟 `model.lang`(页面文案跟着界面语言走)。
Screen::Download => download::view(ui, metrics, &model.download, model.lang),
// M109:内容库页同样跟 `model.lang`
Screen::Content => content::view(ui, metrics, &model.content, model.lang),
Screen::Settings => settings::view(ui, metrics, states, language),
Screen::Logs => logs::view(ui, metrics, &model.logs, model.lang),
}
@@ -272,6 +275,7 @@ pub fn demo_model(screen: Screen, window_width: f32) -> Model {
match screen {
Screen::Instances => model.instances = instances::demo(),
Screen::Download => model.download = download::demo(unlucky_i18n::Lang::En.catalog()),
Screen::Content => model.content = content::demo(content::ContentKind::Mod, model.lang),
Screen::Logs => model.logs = logs::demo(model.lang),
Screen::Settings => {}
}
+5
View File
@@ -19,6 +19,7 @@ use unlucky_presenter::router::NavSection;
use crate::app::components::interaction::Interaction;
use super::content::Content;
use super::logs::Logs;
use super::{Download, Screen};
@@ -89,6 +90,8 @@ pub struct Model {
pub download: Download,
/// 日志页的数据(M95:presenter 快照 → 日志行 + 诊断结论,见 [`super::logs`])。
pub logs: Logs,
/// 内容库页的数据(M109:检索条件 / 命中项 / 装入闸门,见 [`super::content`])。
pub content: Content,
/// 界面语言(M106):页面文案走 i18n key,语言由 app 层决定后灌进来。
///
/// 谁填:`app/state.rs` 的 `App::refresh_view`(`LangState::lang()` → `view::model(..)`),
@@ -112,6 +115,8 @@ impl Model {
instances: Instances::default(),
download: Download::loading(),
logs: Logs::default(),
// 演示模型也从这个空壳起步(`demo_model` 再按页面填数据)
content: super::content::demo(unlucky_presenter::dto::ContentKind::Mod, Lang::En),
lang: Lang::En,
forced: None,
}
+11 -3
View File
@@ -14,6 +14,8 @@ pub enum Screen {
Instances,
/// **版本管理**(原下载页;单列页,但正文自己切三栏:类型 / 主版本 / 列表)。
Download,
/// 内容库(M109):整合包 / Mod / 资源包 / 光影。
Content,
/// 设置(supporting-pane)。
Settings,
/// 日志(单列,两行页头)。
@@ -22,9 +24,10 @@ pub enum Screen {
impl Screen {
/// 全部页面(截图矩阵按这个顺序铺)。
pub const ALL: [Screen; 4] = [
pub const ALL: [Screen; 5] = [
Screen::Instances,
Screen::Download,
Screen::Content,
Screen::Settings,
Screen::Logs,
];
@@ -34,6 +37,7 @@ impl Screen {
match self {
Screen::Instances => "instances",
Screen::Download => "download",
Screen::Content => "content",
Screen::Settings => "settings",
Screen::Logs => "logs",
}
@@ -49,7 +53,9 @@ impl Screen {
match self {
Screen::Instances => PaneKit::ListDetail,
Screen::Settings => PaneKit::Supporting,
Screen::Download | Screen::Logs => PaneKit::Single,
// 内容库自带三栏(`pages/content/layout.rs`),版面基座仍是单列:
// 它的三栏是「页内栏」而不是 layout 模块那三种整页版面。
Screen::Download | Screen::Content | Screen::Logs => PaneKit::Single,
}
}
@@ -60,6 +66,7 @@ impl Screen {
Some(match route {
Route::Instances | Route::Detail(_) => Screen::Instances,
Route::Download => Screen::Download,
Route::Content => Screen::Content,
Route::Logs => Screen::Logs,
Route::Settings => Screen::Settings,
Route::Agent => return None,
@@ -70,7 +77,8 @@ impl Screen {
pub const fn nav(self) -> NavSection {
match self {
Screen::Instances => NavSection::Instances,
Screen::Download => NavSection::Download,
// M109(B1 裁定):内容库与版本管理共用同一条导航轨项
Screen::Download | Screen::Content => NavSection::Download,
Screen::Logs => NavSection::Logs,
Screen::Settings => NavSection::Settings,
}
+9 -3
View File
@@ -23,10 +23,14 @@ fn ui() -> Ui {
}
#[test]
fn four_screens_answer_the_mission_list() {
assert_eq!(Screen::ALL.len(), 4);
fn five_screens_answer_the_mission_list() {
// M109:内容库是第五页(`download` 与 `content` 共用导航轨的「下载」项)
assert_eq!(Screen::ALL.len(), 5);
let slugs: Vec<&str> = Screen::ALL.iter().map(|screen| screen.slug()).collect();
assert_eq!(slugs, vec!["instances", "download", "settings", "logs"]);
assert_eq!(
slugs,
vec!["instances", "download", "content", "settings", "logs"]
);
for screen in Screen::ALL {
assert_eq!(Screen::from_slug(screen.slug()), Some(screen));
}
@@ -38,6 +42,8 @@ fn screens_declare_the_layout_their_page_needs() {
assert_eq!(Screen::Instances.kit(), PaneKit::ListDetail);
assert_eq!(Screen::Settings.kit(), PaneKit::Supporting);
assert_eq!(Screen::Download.kit(), PaneKit::Single);
// 内容库自带页内三栏,版面基座仍是单列(见 `pages/content/layout.rs`)
assert_eq!(Screen::Content.kit(), PaneKit::Single);
assert_eq!(Screen::Logs.kit(), PaneKit::Single);
}