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

+222
View File
@@ -0,0 +1,222 @@
//! 内容库页的整页装配:页头(含一级切换)+ 左侧类型栏 + 主列(筛选/结果)+ 详情 + 安装面板。
//!
//! ## 版面(几何在 [`super::layout`],这里只消费结果)
//!
//! ```text
//! ┌─ 页头:Content [版本管理 | 内容库] ─┐
//! ├──────┬────────────────────────┬──────────────┤
//! │ 类型 │ 搜索 + 四组 chip │ 详情 / 装入 │
//! │ 四条 │ 计数头 │ 原因逐条列出 │
//! │ 侧栏 │ 结果列表(独立滚动) │ │
//! │ │ 加载更多 / 没有更多了 │ │
//! ├──────┴────────────────────────┴──────────────┤
//! │ 安装面板(**下载页那块**,只在有安装时出现) │
//! └──────────────────────────────────────────────┘
//! ```
//!
//! ## 为什么类型栏用 `list_item`(与 M107 的版本类型栏同一套)
//!
//! 四条内容类型是**一级侧栏**(页面内导航),几何与状态层必须与版本管理页一致:`list_item`
//! 自带 60 高两行档与选中态,页面不自己写 hover/pressed。窄档(`Tight`)退成一行 chip——
//! 栏宽没了就不该硬塞一条 140px 的栏。
use iced::widget::{column, container, row};
use iced::{Element, Length};
use unlucky_design::spacing::Space;
use unlucky_i18n::{Catalog, Key};
use unlucky_presenter::dto::ContentKind;
use crate::app::components::divider;
use crate::app::components::navigation::list_item::{list_item, ListItem};
use crate::app::components::selection::{chip, Chip};
use crate::theme::Ui;
use crate::pages::download;
use crate::pages::frame::{self, Header};
use crate::pages::layout::FrameMetrics;
use crate::pages::Message as PageMessage;
use super::layout::{self as content_layout, Rails, Tier};
use super::model::{Content, Status};
use super::parts;
use super::Message as ContentMessage;
/// 整页入口(由 `pages/mod.rs` 的 `render` 调;页面层不认识 presenter 类型)。
pub(super) fn view<'a>(
ui: &'a Ui,
metrics: FrameMetrics,
model: &'a Content,
lang: unlucky_i18n::Lang,
) -> Element<'a, PageMessage> {
let catalog = lang.catalog();
let header = Header::new(catalog.text(Key::ContentTitle))
.subtitle(catalog.text(Key::ContentSubtitle))
.action(parts::section_switcher(ui, catalog, true));
frame::page(ui, metrics, header, body(ui, metrics, catalog, model))
}
fn body<'a>(
ui: &'a Ui,
metrics: FrameMetrics,
catalog: Catalog,
model: &'a Content,
) -> Element<'a, PageMessage> {
let rails = content_layout::resolve(metrics.available);
let mut columns = row![kind_rail(ui, catalog, model, rails)]
.spacing(rails.gap)
.width(Length::Fill)
.height(Length::Fill);
if rails.tier == Tier::Tight {
// 窄到放不下侧栏:类型条跑到主列上方(同一个 `kind_rail`,chip 形态)
let main = column![
kind_rail(ui, catalog, model, rails),
divider::full(ui),
main_column(ui, catalog, model, rails)
]
.spacing(ui.space(Space::X2))
.width(Length::Fill)
.height(Length::Fill);
columns = row![main].width(Length::Fill).height(Length::Fill);
} else {
columns = columns.push(main_column(ui, catalog, model, rails));
if rails.tier.has_detail() {
columns = columns.push(detail_column(ui, catalog, model, rails));
}
}
let mut stack = column![columns]
.spacing(ui.space(Space::X3))
.width(Length::Fill)
.height(Length::Fill);
if let Some(panel) = &model.panel {
// **同一块安装面板**(下载页那个函数):内容库不另画一套进度/失败/取消。
stack = stack
.push(divider::full(ui))
.push(download::install_panel(ui, catalog, panel));
}
stack.into()
}
/// 左侧四条内容类型(`Wide`/`Compact` 是列表项,`Narrow` 是图标项,`Tight` 是一行 chip)。
fn kind_rail<'a>(
ui: &'a Ui,
catalog: Catalog,
model: &'a Content,
rails: Rails,
) -> Element<'a, PageMessage> {
if rails.tier == Tier::Tight {
let mut chips = Vec::new();
for kind in ContentKind::ALL {
chips.push(chip(
ui,
Chip::filter(
kind_label(kind, catalog),
model.kind == kind,
PageMessage::Content(ContentMessage::SetKind(kind)),
),
));
}
return row(chips)
.spacing(ui.space(Space::X2))
.width(Length::Fill)
.into();
}
let labels = rails.tier.rail_has_labels();
let mut items = column(Vec::new())
.spacing(ui.space(Space::X1))
.width(Length::Fill);
for kind in ContentKind::ALL {
let label = kind_label(kind, catalog);
// 图标档没有文字位:标签仍传(无障碍/日志用),宽度由几何裁掉
let mut item = ListItem::new(
if labels { label } else { " " },
PageMessage::Content(ContentMessage::SetKind(kind)),
)
.leading(parts::kind_icon(kind));
if model.kind == kind {
item = item.selected();
}
items = items.push(list_item(ui, item));
}
container(items)
.width(Length::Fixed(rails.rail))
.height(Length::Fill)
.into()
}
/// 主列:搜索条 + 筛选 + 计数 + 结果(或四态之一)。
fn main_column<'a>(
ui: &'a Ui,
catalog: Catalog,
model: &'a Content,
rails: Rails,
) -> Element<'a, PageMessage> {
// 主列的横向内边距不在这里加:`rails.list` 已经把可用宽算干了,再加 2×X2 会让整排
// 溢出 32px(详情列被切掉一半,复审截图上正是这条)
let mut head = column![parts::search(ui, catalog, model)]
.spacing(ui.space(Space::X2))
.width(Length::Fill);
// 筛选条**永远在**:它是控制面,不是数据。加载中把筛选条藏起来,用户就没法在慢网络下
// 换条件或换类型(只能干等),这是「加载态顺手把入口收走」的经典坑。
head = head.push(parts::filters(ui, catalog, model));
head = head.push(divider::full(ui));
if model.status == Status::Ready {
// 计数头只在有数据时出现:0 条的「0 results」和空态说的是同一件事,画两遍是噪音
head = head.push(parts::count_head(ui, catalog, model));
}
let body: Element<'a, PageMessage> = if let Some(state) = parts::status_body(ui, catalog, model)
{
state
} else {
{
let mut list = column![parts::list(ui, catalog, model, rails)]
.width(Length::Fill)
.height(Length::Fill);
// 紧凑档没有独立详情列:详情压在列表下面(整页替换的成本比「多一块」高)
if !rails.tier.has_detail() {
list = list
.push(divider::full(ui))
.push(parts::detail(ui, catalog, model));
}
list.push(parts::more(ui, catalog, model)).into()
}
};
column![head, body]
.spacing(ui.space(Space::X2))
.width(Length::Fixed(rails.list))
.height(Length::Fill)
.into()
}
/// 详情列(只在 `Wide` / `Narrow` 存在)。
fn detail_column<'a>(
ui: &'a Ui,
catalog: Catalog,
model: &'a Content,
rails: Rails,
) -> Element<'a, PageMessage> {
container(parts::detail(ui, catalog, model))
.width(Length::Fixed(rails.detail))
.height(Length::Fill)
.style(move |_theme| container::Style::default())
.into()
}
/// 四种内容类型的界面名。
fn kind_label(kind: ContentKind, catalog: Catalog) -> &'static str {
match kind {
ContentKind::Modpack => catalog.text(Key::ContentKindModpack),
ContentKind::Mod => catalog.text(Key::ContentKindMod),
ContentKind::Resourcepack => catalog.text(Key::ContentKindResourcepack),
ContentKind::Shader => catalog.text(Key::ContentKindShader),
}
}