Files
Unlucky-Launcher/crates/app-iced/src/pages/content/parts.rs
T
AskaEth 6b161227d8 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 行。
2026-11-05 03:20:21 +08:00

334 lines
11 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 内容库页的零件:筛选条 / 结果列表 / 详情面板 / 四态(加载 / 空 / 失败 / 无更多)/ 一级切换。
//!
//! 全是既有真组件(`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,
}
}