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
@@ -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;
|
||||
Reference in new issue
Block a user