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,179 @@
|
||||
//! 详情面板:选中项的支持范围 + 目标实例 + 装入(不兼容时**禁用并逐条给原因**)。
|
||||
//!
|
||||
//! 从 `pages/content/parts.rs` 拆出(那个文件已贴 400 行硬上限)。
|
||||
//!
|
||||
//! 这块是「一键装入指定实例」的**唯一**入口:按钮能不能按由 [`Content::can_install`] 决定,
|
||||
//! 按不动时旁边逐条列出原因——判定与画法分开,`model/tests.rs` 才能不开图形环境就钉住它。
|
||||
|
||||
use iced::widget::{column, container};
|
||||
use iced::{Element, Length};
|
||||
|
||||
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 crate::app::components::buttons::{self, Button};
|
||||
use crate::app::components::divider;
|
||||
use crate::app::components::selection::{chip, Chip};
|
||||
use crate::theme::Ui;
|
||||
|
||||
use super::super::model::{capitalize, Content};
|
||||
use super::super::Message as ContentMessage;
|
||||
use super::{intent, labelled};
|
||||
use crate::pages::frame;
|
||||
use crate::pages::Message as PageMessage;
|
||||
|
||||
/// 详情面板:选中项的支持范围 + 目标实例 + 装入(不兼容时**禁用并给原因**)。
|
||||
pub(crate) fn detail<'a>(
|
||||
ui: &'a Ui,
|
||||
catalog: Catalog,
|
||||
model: &'a Content,
|
||||
) -> Element<'a, PageMessage> {
|
||||
let Some(hit) = model.selected_hit() else {
|
||||
return container(frame::text_role(
|
||||
ui,
|
||||
TypeRole::BodySmall,
|
||||
false,
|
||||
catalog.text(Key::ContentDetailEmpty),
|
||||
ui.color(Role::OnSurfaceVariant),
|
||||
))
|
||||
.width(Length::Fill)
|
||||
.into();
|
||||
};
|
||||
|
||||
let head = column![
|
||||
frame::text_role(
|
||||
ui,
|
||||
TypeRole::TitleMedium,
|
||||
true,
|
||||
hit.title.as_str(),
|
||||
ui.color(Role::OnSurface)
|
||||
),
|
||||
frame::text_role(
|
||||
ui,
|
||||
TypeRole::BodySmall,
|
||||
false,
|
||||
hit.supporting.as_str(),
|
||||
ui.color(Role::OnSurfaceVariant),
|
||||
),
|
||||
frame::text_role(
|
||||
ui,
|
||||
TypeRole::BodySmall,
|
||||
false,
|
||||
hit.summary.as_str(),
|
||||
ui.color(Role::OnSurfaceVariant),
|
||||
),
|
||||
]
|
||||
.spacing(ui.space(Space::X1))
|
||||
.width(Length::Fill);
|
||||
|
||||
let meta = column![
|
||||
frame::text_role(
|
||||
ui,
|
||||
TypeRole::LabelMedium,
|
||||
false,
|
||||
hit.downloads.as_str(),
|
||||
ui.color(Role::OnSurfaceVariant)
|
||||
),
|
||||
frame::text_role(
|
||||
ui,
|
||||
TypeRole::LabelMedium,
|
||||
false,
|
||||
hit.updated.as_str(),
|
||||
ui.color(Role::OnSurfaceVariant)
|
||||
),
|
||||
support_line(ui, catalog, Key::ContentDetailVersions, &hit.versions),
|
||||
support_line(ui, catalog, Key::ContentDetailLoaders, &hit.loaders),
|
||||
]
|
||||
.spacing(ui.space(Space::X1))
|
||||
.width(Length::Fill);
|
||||
|
||||
let mut body = column![head, divider::full(ui), meta].spacing(ui.space(Space::X3));
|
||||
|
||||
// 目标实例:一行 chip(选中的那个高亮)
|
||||
if model.targets.is_empty() {
|
||||
body = body.push(frame::text_role(
|
||||
ui,
|
||||
TypeRole::BodySmall,
|
||||
false,
|
||||
catalog.text(Key::ContentNoInstances),
|
||||
ui.color(Role::OnSurfaceVariant),
|
||||
));
|
||||
} else {
|
||||
let mut targets = Vec::new();
|
||||
for target in &model.targets {
|
||||
targets.push(chip(
|
||||
ui,
|
||||
Chip::filter(
|
||||
target.name.as_str(),
|
||||
model.target.as_deref() == Some(target.id.as_str()),
|
||||
intent(ContentMessage::SetTarget(target.id.clone())),
|
||||
),
|
||||
));
|
||||
}
|
||||
body = body.push(labelled(
|
||||
ui,
|
||||
catalog.text(Key::ContentTargetInstance),
|
||||
targets,
|
||||
));
|
||||
}
|
||||
|
||||
// 装入:能装 ⇒ 实心按钮;不能 ⇒ 禁用 + 逐条原因(**点之前**就说清楚)
|
||||
if model.can_install() {
|
||||
body = body.push(buttons::filled(
|
||||
ui,
|
||||
Button::new(
|
||||
catalog.text(Key::ContentInstall),
|
||||
intent(ContentMessage::Install),
|
||||
)
|
||||
.leading(IconKind::Download),
|
||||
));
|
||||
} else {
|
||||
body = body.push(buttons::outlined(
|
||||
ui,
|
||||
Button::disabled(catalog.text(Key::ContentInstall)),
|
||||
));
|
||||
for reason in model.blocker_reasons(catalog) {
|
||||
body = body.push(frame::text_role_owned(
|
||||
ui,
|
||||
TypeRole::BodySmall,
|
||||
false,
|
||||
reason,
|
||||
ui.color(Role::OnSurfaceVariant),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
container(body.spacing(ui.space(Space::X3)))
|
||||
.width(Length::Fill)
|
||||
.into()
|
||||
}
|
||||
|
||||
/// 「游戏版本:1.20.1, 1.20.4」一行(空列表给 `—`,不编造)。
|
||||
fn support_line<'a>(
|
||||
ui: &'a Ui,
|
||||
catalog: Catalog,
|
||||
key: Key,
|
||||
values: &[String],
|
||||
) -> Element<'a, PageMessage> {
|
||||
let list = if values.is_empty() {
|
||||
"—".to_string()
|
||||
} else {
|
||||
// 加载器是站点给的小写 facet(`fabric`):这一层只管怎么显示
|
||||
values
|
||||
.iter()
|
||||
.map(|value| capitalize(value))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
};
|
||||
frame::text_role_owned(
|
||||
ui,
|
||||
TypeRole::BodySmall,
|
||||
false,
|
||||
catalog.render(key, &[("list", &list)]),
|
||||
ui.color(Role::OnSurfaceVariant),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
//! 筛选条:来源 / 排序 / 游戏版本 / 加载器四组 chip,外加分组标签与换行容器。
|
||||
//!
|
||||
//! 从 `pages/content/parts.rs` 拆出(那个文件已贴 400 行硬上限)。
|
||||
//!
|
||||
//! 缺 key 的源(CurseForge)画成**禁用**而不是消失:筛选条要能被发现,也要能解释自己。
|
||||
|
||||
use iced::widget::{column, container, row};
|
||||
use iced::{Alignment, Element, Length};
|
||||
|
||||
use unlucky_design::color::Role;
|
||||
use unlucky_design::spacing::Space;
|
||||
use unlucky_design::typography::TypeRole;
|
||||
|
||||
use unlucky_i18n::{Catalog, Key};
|
||||
use unlucky_presenter::dto::{loader_facet, ContentSort, ContentSource, LoaderKind};
|
||||
|
||||
use crate::app::components::selection::{chip, Chip};
|
||||
use crate::theme::Ui;
|
||||
|
||||
use super::super::model::{loader_name, Content};
|
||||
use super::super::Message as ContentMessage;
|
||||
use super::{intent, sort_label};
|
||||
use crate::pages::frame;
|
||||
use crate::pages::Message as PageMessage;
|
||||
|
||||
/// 来源 + 排序 + 版本 + 加载器四组 chip(一行放不下就换行,`row` 变 `column`)。
|
||||
pub(crate) fn filters<'a>(
|
||||
ui: &'a Ui,
|
||||
catalog: Catalog,
|
||||
model: &'a Content,
|
||||
) -> Element<'a, PageMessage> {
|
||||
let mut source = Vec::new();
|
||||
for item in ContentSource::ALL {
|
||||
let available = match item {
|
||||
ContentSource::Modrinth => true,
|
||||
ContentSource::Curseforge => model.curseforge,
|
||||
};
|
||||
let label = match item {
|
||||
ContentSource::Modrinth => catalog.text(Key::ContentSourceModrinth),
|
||||
ContentSource::Curseforge => catalog.text(Key::ContentSourceCurseforge),
|
||||
};
|
||||
let mut spec = Chip::filter(
|
||||
label,
|
||||
model.query.source == item,
|
||||
intent(ContentMessage::SetSource(item)),
|
||||
);
|
||||
if !available {
|
||||
// 缺 key 的源:**禁用并说明**,不是静默消失(否则用户找不到它)
|
||||
spec = spec.disabled();
|
||||
}
|
||||
source.push(chip(ui, spec));
|
||||
}
|
||||
|
||||
let mut sort = Vec::new();
|
||||
for item in ContentSort::ALL {
|
||||
sort.push(chip(
|
||||
ui,
|
||||
Chip::filter(
|
||||
sort_label(item, catalog),
|
||||
model.query.sort == item,
|
||||
intent(ContentMessage::SetSort(item)),
|
||||
),
|
||||
));
|
||||
}
|
||||
|
||||
let mut versions = vec![chip(
|
||||
ui,
|
||||
Chip::filter(
|
||||
catalog.text(Key::ContentFilterAny),
|
||||
model.query.version.is_none(),
|
||||
intent(ContentMessage::SetVersion(None)),
|
||||
),
|
||||
)];
|
||||
for version in &model.versions {
|
||||
versions.push(chip(
|
||||
ui,
|
||||
Chip::filter(
|
||||
version.as_str(),
|
||||
model.query.version.as_deref() == Some(version.as_str()),
|
||||
intent(ContentMessage::SetVersion(Some(version.clone()))),
|
||||
),
|
||||
));
|
||||
}
|
||||
|
||||
let mut loaders = vec![chip(
|
||||
ui,
|
||||
Chip::filter(
|
||||
catalog.text(Key::ContentFilterAny),
|
||||
model.query.loader.is_none(),
|
||||
intent(ContentMessage::SetLoader(None)),
|
||||
),
|
||||
)];
|
||||
for loader in [
|
||||
LoaderKind::Fabric,
|
||||
LoaderKind::Forge,
|
||||
LoaderKind::NeoForge,
|
||||
LoaderKind::Quilt,
|
||||
] {
|
||||
let usable = loader_facet(loader).is_some();
|
||||
let mut spec = Chip::filter(
|
||||
loader_name(loader),
|
||||
model.query.loader == Some(loader),
|
||||
intent(ContentMessage::SetLoader(Some(loader))),
|
||||
);
|
||||
if !usable {
|
||||
spec = spec.disabled();
|
||||
}
|
||||
loaders.push(chip(ui, spec));
|
||||
}
|
||||
|
||||
let mut column = column![
|
||||
labelled(ui, catalog.text(Key::ContentFilterVersion), versions),
|
||||
labelled(ui, catalog.text(Key::ContentFilterLoader), loaders),
|
||||
wrapped_row(ui, source),
|
||||
wrapped_row(ui, sort),
|
||||
]
|
||||
.spacing(ui.space(Space::X2))
|
||||
.width(Length::Fill);
|
||||
|
||||
if !model.curseforge {
|
||||
column = column.push(frame::text_role(
|
||||
ui,
|
||||
TypeRole::BodySmall,
|
||||
false,
|
||||
catalog.text(Key::ContentSourceUnavailable),
|
||||
ui.color(Role::OnSurfaceVariant),
|
||||
));
|
||||
}
|
||||
column.into()
|
||||
}
|
||||
|
||||
/// 分组标签 + 一行 chip(标签左、chip 右,纵向居中对齐)。
|
||||
pub(crate) fn labelled<'a>(
|
||||
ui: &'a Ui,
|
||||
label: &'a str,
|
||||
chips: Vec<Element<'a, PageMessage>>,
|
||||
) -> Element<'a, PageMessage> {
|
||||
row![
|
||||
container(frame::text_role(
|
||||
ui,
|
||||
TypeRole::LabelMedium,
|
||||
false,
|
||||
label,
|
||||
ui.color(Role::OnSurfaceVariant),
|
||||
)),
|
||||
wrapped_row(ui, chips),
|
||||
]
|
||||
.spacing(ui.space(Space::X2))
|
||||
.align_y(Alignment::Center)
|
||||
.width(Length::Fill)
|
||||
.into()
|
||||
}
|
||||
|
||||
/// `Vec<Element>` → 一行(顺序就是声明顺序)。
|
||||
pub(crate) fn wrapped_row<'a>(
|
||||
ui: &'a Ui,
|
||||
chips: Vec<Element<'a, PageMessage>>,
|
||||
) -> Element<'a, PageMessage> {
|
||||
row(chips)
|
||||
.spacing(ui.space(Space::X1))
|
||||
.align_y(Alignment::Center)
|
||||
.into()
|
||||
}
|
||||
Reference in new issue
Block a user