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

+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()
},
}
}