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