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

+102
View File
@@ -0,0 +1,102 @@
//! 内容库的宿主实现(M109 `CONTENTDL`):**检索** + **装进指定实例**。
//!
//! ## 为什么这些代码在 presenter 而不是 core(临时落点,必须写清)
//!
//! `crates/core` 的 `addon` 模块今天只有两件事:加载器安装(`addon::loader`)与整合包**格式**
//! 解析(`addon::pack`,M110)。它**没有** CurseForge / Modrinth 的 API 客户端,也没有
//! 「远程整合包一键装进实例」的管线(M111/M112 才做)。而任务书的硬约束是「UI 层不许自己发
//! HTTP,新增能力一律经 presenter 的 host 转发」——所以本轮把客户端放在这里。
//!
//! **长期应下沉到 `core::addon`**(已报 finding):那时本模块只剩「DTO ↔ 领域类型」的翻译,
//! 与 `host/versions.rs` 现在的角色一样。
//!
//! ## 三条纪律
//!
//! | 纪律 | 落点 |
//! |---|---|
//! | 网络只在后台线程 | [`spawn_search`] / [`spawn_install`] 走 `job::spawn`,UI 线程只收事件 |
//! | Modrinth 走 core 的下载引擎 | `addon/http.rs` 的 `download_bytes` 路径带**信号量限流 + 源健康度 + 指数退避**,不另写重试循环 |
//! | 密钥不进仓库 / 日志 | CF 的 `X-API-KEY` 只从 `ULL_CF_API_KEY` 读,日志只打 `set`/`unset`,DTO 里只有一个 `bool` |
//!
//! ## 模块
//!
//! | 文件 | 职责 |
//! |---|---|
//! | [`sources`] | 两个站点的 JSON 方言 → [`crate::dto::ContentHit`] |
//! | `http` | 取文本:无 header 走引擎、带 header 走 client + 退避、百分号编码 |
//! | `install` | 一键装入:解析文件 → 下载到实例目录 → 进度回流 |
//!
//! ## 整合包与 CF 的边界(都写进报告)
//!
//! - 整合包**不做**「一键装入」:`ContentKind::Modpack.dir()` 是 `None`,UI 侧按钮禁用带原因,
//! host 侧再挡一次(就算绕过 UI 发指令也只回一条 `Failed`)。真流程是 M111 的导入管线。
//! - CurseForge 本轮只做**检索**(需要用户配 `ULL_CF_API_KEY`),文件解析未接通。
mod http;
mod install;
mod sources;
#[cfg(test)]
mod tests;
use std::sync::atomic::AtomicBool;
use std::sync::Arc;
use crate::dto::{ContentQuery, ContentResults};
use super::env::HostEnv;
use super::job::{spawn, HostTask, JobSender};
/// 日志里只出现这两种状态,**永远不打密钥本身**。
const KEY_UNSET: &str = "ULL_CF_API_KEY unset(CurseForge 源不可用)";
const KEY_SET: &str = "ULL_CF_API_KEY set(CurseForge 源可用)";
/// CurseForge 的密钥(环境变量;空串 / 空白按「没配」处理)。
///
/// 为什么不放设置项:core 的 `setting` schema 不在本 mission 的 scope(已登记 finding)。
/// 环境变量这条路至少满足「不进仓库、不进日志」两条硬要求。
fn curseforge_key() -> Option<String> {
std::env::var("ULL_CF_API_KEY")
.ok()
.map(|key| key.trim().to_string())
.filter(|key| !key.is_empty())
}
/// CF 源此刻可用吗(页面据此禁用那条来源)。
#[must_use]
pub fn curseforge_ready() -> bool {
curseforge_key().is_some()
}
/// 打一行「配没配」的日志(**不含**密钥内容;host 装配时调一次)。
pub fn log_curseforge_state() {
eprintln!(
"[content] {}",
if curseforge_ready() {
KEY_SET
} else {
KEY_UNSET
}
);
}
/// 后台检索(网络在后台线程,UI 不卡)。
pub fn spawn_search(env: HostEnv, tx: JobSender, query: ContentQuery) {
spawn("content", move || {
let result: Result<ContentResults, String> = sources::search(&env, &query);
let _ = tx.send(HostTask::Content(Box::new(result)));
});
}
/// 后台「装进指定实例」。
pub fn spawn_install(
env: HostEnv,
tx: JobSender,
request: crate::dto::ContentInstallRequest,
cancel: Arc<AtomicBool>,
epoch: u64,
) {
spawn("content-install", move || {
install::install(&env, &tx, &request, &cancel, epoch);
});
}