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