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,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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user