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