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

+333
View File
@@ -0,0 +1,333 @@
//! 内容库页的零件:筛选条 / 结果列表 / 详情面板 / 四态(加载 / 空 / 失败 / 无更多)/ 一级切换。
//!
//! 全是既有真组件(`chip` / `list_item` / `buttons` / `empty_state`),页面不自己画状态层。
//!
//! ## 消息口径
//!
//! 本文件的每个控件都直接发 [`crate::pages::Message`]:内容库自己的意图包一层
//! `PageMessage::Content(..)`([`intent`]),而「取消安装 / 打开日志」用的是**下载页就有的**
//! 那两个变体——因为对用户来说它们是同一件事(同一个 `AppCommand::CancelInstall`)。
use iced::widget::{column, container, row, scrollable};
use iced::{Alignment, Element, Length, Padding};
use unlucky_design::color::Role;
use unlucky_design::icons::IconKind;
use unlucky_design::spacing::Space;
use unlucky_design::typography::TypeRole;
use unlucky_i18n::{Catalog, Key};
use unlucky_presenter::dto::ContentSort;
use unlucky_presenter::router::Route;
use crate::app::components::buttons::{self, Button};
use crate::app::components::feedback::{empty_state, EmptyState};
use crate::app::components::navigation::list_item::{list_item, ListItem};
use crate::app::components::selection::{chip, Chip};
use crate::theme::Ui;
use super::layout::Rails;
use super::model::{Content, Hit, Status};
mod detail;
mod filters;
pub(crate) use detail::detail;
pub(crate) use filters::{filters, labelled};
use super::Message as ContentMessage;
use crate::pages::frame;
use crate::pages::Message as PageMessage;
/// 内容库意图 → 页面消息(唯一的一层包装)。
pub(super) fn intent(message: ContentMessage) -> PageMessage {
PageMessage::Content(message)
}
/// 结果行与行之间的间距(与下载页同值)。
const ROW_GAP: Space = Space::X1;
/// 一级切换:版本管理 | 内容库(M109 按 B1 裁定:共用同一条导航轨项,页内切换)。
pub(super) fn section_switcher<'a>(
ui: &'a Ui,
catalog: Catalog,
on_content: bool,
) -> Element<'a, PageMessage> {
let versions = Chip::filter(
catalog.text(Key::ContentSectionVersions),
!on_content,
PageMessage::Navigate(Route::Download),
);
let content = Chip::filter(
catalog.text(Key::ContentSectionContent),
on_content,
PageMessage::Navigate(Route::Content),
);
row![chip(ui, versions), chip(ui, content)]
.spacing(ui.space(Space::X2))
.align_y(Alignment::Center)
.into()
}
/// 关键词搜索条(输入只改草稿;回车/按钮才发检索指令)。
pub(super) fn search<'a>(
ui: &'a Ui,
catalog: Catalog,
model: &'a Content,
) -> Element<'a, PageMessage> {
// 输入只改草稿,**提交**才发检索(每敲一个字就打一次网络既没必要也会被限流)。
// 搜索条自身没有提交回调,所以旁边给一个显式的「搜索」按钮——这也是键盘外的唯一出口。
let bar = crate::app::components::inputs::search_bar(
ui,
crate::app::components::inputs::SearchBar::new(model.draft.as_str())
.placeholder(catalog.text(Key::ContentSearchPlaceholder)),
move |text| intent(ContentMessage::QueryChanged(text)),
);
let submit = crate::app::components::buttons::text(
ui,
crate::app::components::buttons::Button::new(
catalog.text(Key::ContentSearch),
intent(ContentMessage::Search),
)
.leading(IconKind::Search),
);
row![bar, submit]
.spacing(ui.space(Space::X2))
.align_y(Alignment::Center)
.width(Length::Fill)
.into()
}
/// 结果列表(独立滚动;底部「加载更多」钉在列表之外,见 [`more`])。
pub(super) fn list<'a>(
ui: &'a Ui,
catalog: Catalog,
model: &'a Content,
rails: Rails,
) -> Element<'a, PageMessage> {
let rows = column(
model
.hits
.iter()
.map(|hit| hit_row(ui, model, hit))
.collect::<Vec<_>>(),
)
.spacing(ui.space(ROW_GAP))
.padding(Padding::from([0.0, ui.space(Space::X2)]))
.width(Length::Fill);
let _ = catalog;
container(
scrollable(rows)
.direction(scrollable::Direction::Vertical(
scrollable::Scrollbar::new()
.width(frame::SCROLLBAR_WIDTH)
.scroller_width(frame::SCROLLBAR_WIDTH)
.margin(frame::SCROLLBAR_MARGIN),
))
.height(Length::Fill)
.style(frame::scroll_style(ui)),
)
.width(Length::Fixed(rails.list))
.height(Length::Fill)
.into()
}
fn hit_row<'a>(ui: &'a Ui, model: &Content, hit: &'a Hit) -> Element<'a, PageMessage> {
let mut item = ListItem::new(
hit.title.as_str(),
intent(ContentMessage::Select(hit.id.clone())),
)
.overline(hit.supporting.as_str())
.supporting(hit.summary.as_str())
.leading(kind_icon(model.kind));
if model.selected.as_deref() == Some(hit.id.as_str()) {
item = item.selected();
}
list_item(ui, item)
}
/// 计数头 + 翻页尾(分页而非无限滚动:见 `pages/content.rs` 的模块文档)。
pub(super) fn count_head<'a>(
ui: &'a Ui,
catalog: Catalog,
model: &Content,
) -> Element<'a, PageMessage> {
let count = if model.total > 0 {
model.total
} else {
u32::try_from(model.hits.len()).unwrap_or(u32::MAX)
};
frame::text_role_owned(
ui,
TypeRole::LabelMedium,
false,
catalog.render(
Key::ContentCount,
&[("count", &super::model::thousands(u64::from(count)))],
),
ui.color(Role::OnSurfaceVariant),
)
}
/// 列表尾部:还有更多 ⇒「加载更多」;没有了 ⇒ 一句「没有更多了」。
pub(super) fn more<'a>(ui: &'a Ui, catalog: Catalog, model: &Content) -> Element<'a, PageMessage> {
if model.has_more {
return buttons::text(
ui,
Button::new(
catalog.text(Key::ContentLoadMore),
intent(ContentMessage::LoadMore),
)
.leading(IconKind::ChevronDown),
);
}
frame::text_role(
ui,
TypeRole::BodySmall,
false,
catalog.text(Key::ContentNoMore),
ui.color(Role::OnSurfaceVariant),
)
}
pub(super) fn loading<'a>(ui: &'a Ui, catalog: Catalog) -> Element<'a, PageMessage> {
centered(
column![
frame::text_role_centered(
ui,
TypeRole::TitleMedium,
true,
catalog.text(Key::ContentLoading),
ui.color(Role::OnSurface)
),
frame::text_role_centered(
ui,
TypeRole::BodySmall,
false,
catalog.text(Key::ContentLoadingHint),
ui.color(Role::OnSurfaceVariant)
),
buttons::text(
ui,
Button::new(
catalog.text(Key::ContentRetry),
intent(ContentMessage::Retry)
)
),
]
.spacing(ui.space(Space::X3))
.width(Length::Fill)
.align_x(Alignment::Center),
)
}
/// 空态(真 `EmptyState`:CTA 是标准 Filled 按钮)。
pub(super) fn empty<'a>(ui: &'a Ui, catalog: Catalog) -> Element<'a, PageMessage> {
centered(empty_state(
ui,
EmptyState::new(
IconKind::Search,
catalog.text(Key::ContentEmptyTitle),
catalog.text(Key::ContentEmptyBody),
)
.action(
catalog.text(Key::ContentRetry),
intent(ContentMessage::Retry),
),
))
}
/// 失败态(带原因与重试出口;原因来自 host,不打码不打折)。
pub(super) fn failed<'a>(
ui: &'a Ui,
catalog: Catalog,
reason: &'a str,
) -> Element<'a, PageMessage> {
centered(
column![
row![
frame::glyph(IconKind::Error, ui.color(Role::Error), frame::ICON_PX),
frame::text_role(
ui,
TypeRole::TitleSmall,
true,
catalog.text(Key::ContentFailedTitle),
ui.color(Role::Error)
),
]
.spacing(ui.space(Space::X2))
.align_y(Alignment::Center),
frame::text_role(
ui,
TypeRole::BodySmall,
false,
reason,
ui.color(Role::OnSurfaceVariant)
),
frame::text_role(
ui,
TypeRole::BodySmall,
false,
catalog.text(Key::ContentFailedBody),
ui.color(Role::OnSurfaceVariant)
),
buttons::filled(
ui,
Button::new(
catalog.text(Key::ContentRetry),
intent(ContentMessage::Retry)
)
.leading(IconKind::Refresh)
),
]
.spacing(ui.space(Space::X3))
.width(Length::Fill)
.align_x(Alignment::Center),
)
}
fn centered<'a>(content: impl Into<Element<'a, PageMessage>>) -> Element<'a, PageMessage> {
container(content)
.width(Length::Fill)
.height(Length::Fill)
.align_x(Alignment::Center)
.align_y(Alignment::Center)
.into()
}
/// 内容类型 → 图标(**借用**现有字形:图标集里还没有「整合包/Mod/资源包/光影」四枚专用图标,
/// 已在报告里登记为偏离)。
pub(super) const fn kind_icon(kind: unlucky_presenter::dto::ContentKind) -> IconKind {
match kind {
unlucky_presenter::dto::ContentKind::Modpack => IconKind::Folder,
unlucky_presenter::dto::ContentKind::Mod => IconKind::Instances,
unlucky_presenter::dto::ContentKind::Resourcepack => IconKind::Download,
// `Play`/`Stop`/`More` 是**填充**字形,pages 里禁用(tests/no_hardcoded_tokens):
// 光影借用 `Expand`(描边的四散光线),语义最近又不撞语汇
unlucky_presenter::dto::ContentKind::Shader => IconKind::Expand,
}
}
pub(super) fn sort_label(sort: ContentSort, catalog: Catalog) -> &'static str {
match sort {
ContentSort::Relevance => catalog.text(Key::ContentSortRelevance),
ContentSort::Downloads => catalog.text(Key::ContentSortDownloads),
ContentSort::Newest => catalog.text(Key::ContentSortNewest),
ContentSort::Updated => catalog.text(Key::ContentSortUpdated),
}
}
/// 状态分派(四态各一支;`Ready` 由调用方画列表 + 详情)。
pub(super) fn status_body<'a>(
ui: &'a Ui,
catalog: Catalog,
model: &'a Content,
) -> Option<Element<'a, PageMessage>> {
match &model.status {
Status::Loading => Some(loading(ui, catalog)),
Status::Empty => Some(empty(ui, catalog)),
Status::Failed(reason) => Some(failed(ui, catalog, reason.as_str())),
Status::Ready => None,
}
}