feat(download): M94 下载页换成真组件 + 真快照 + 真指令(PAGEDL)

页面(crates/app-iced/src/pages/download*):
- Tabs(分类)/ ListItem(版本行)/ filled 安装按钮 / progress / EmptyState / divider
  全部取自 components/ 的真组件;页头动作换成 buttons::text
- 数据只吃 pages::download::Download(app/view.rs 由 AppState 快照转,installed_only 与
  download_query 照用);页面自己不留业务状态,交互只发意图
- 状态:加载(不确定进度 + 文案)/ 空(EmptyState + 刷新 CTA)/ 有数据 / 安装中 / 安装失败
  (失败给可读文案 + 「打开日志」出口)

接线(追加式):
- pages::Message 加 SelectVersion / InstallVersion / CancelInstall / SetCategory
- app/update.rs 翻成 AppCommand::{InstallVersion, CancelInstall, ListVersions}
  (SelectVersion 只改视图模型;「刷新」= 当前分类重新拉,全部走 RefreshAll)
- pages::Model 加 download 字段

文案:28 个 Download* key 进中英两套 catalog(一行一个 key 的全语言);页面零中文字面量,
  core 的中文阶段串(InstallStage::label / message)一律不进界面

i18n 结构:catalog.rs 的既有测试模块原样外置到 catalog/tests.rs(392 行 → 282 行,
  逐字未改),新 key 才装得下 400 行硬上限

测试:app-iced 276 + i18n 23 全绿;clippy -D warnings 与 fmt --check 干净
This commit is contained in:
tower authored and AskaEth committed 2026-11-02 21:36:22 +08:00
1 parent b3f99652bb
commit 57e8380b24
14 files changed
+1633 -247

No files matched your search

+103 -82
View File
@@ -4,103 +4,124 @@
//! 内容是一串线性进度项,分栏没有第二个阅读对象。窗口变宽时**正文列不变宽**(撞行宽上限),
//! 多出来的宽度留白——这就是 M68「全宽单卡」的反面。
//!
//! 页头 1 个操作 + 空态 3 个快捷入口(12b §15.5:≤3)。
//! ## M94:占位控件 → 真组件 + 真数据 + 真指令(逐项对照)
//!
//! | 位置 | M84(占位) | M94(真组件) |
//! |---|---|---|
//! | 页头动作 | `frame::text_action`(`text` + 手搓 hover) | `buttons::text`(五式 + 状态层) |
//! | 分类 | 无 | `navigation::tabs`(Primary,48 高 / 3px `Primary` 指示条) |
//! | 列表行 | 无 | `navigation::list_item`(两行档 **60**:`row_height` 44 + 每多一行 16) |
//! | 分隔线 | `frame::divider` | `components::divider::full` / `list` |
//! | 安装按钮 | 无 | `buttons::filled`(**本页唯一实心 accent**,rubric K2) |
//! | 进度 | 无 | `feedback::progress::linear`(已知 = determinate,未知 = indeterminate) |
//! | 空态 | `frame::empty_state` | `feedback::empty_state`(`EmptyState` 真组件) |
//! | 数据 | `"3 sources healthy · …"` 硬编码假状态行 | [`Download`](`app/view.rs` 从 presenter 快照转) |
//! | 文案 | 英文字面量 | `unlucky_i18n` 的 `Key`(拼错编译失败;中英两套目录) |
//!
//! ## 状态矩阵(四个状态都能从快照推出来,都出图)
//!
//! | 状态 | 条件 | 组件 |
//! |---|---|---|
//! | 加载 | 快照里没有版本列表([`Download::is_loading`]) | 不确定态进度条 + 文案([`states::loading`]) |
//! | 空 | 列表拉到了、过滤后为空([`Download::is_empty`]) | `EmptyState` + 刷新 CTA([`states::empty`]) |
//! | 有数据 | 有版本行 | Tabs + 计数头 + `ListItem` 列表([`rows`]) |
//! | 安装中 / 失败 | `AppState::install` 不为空 | 进度条 + 阶段 + 「取消」/「打开日志」([`install`]) |
//!
//! ## 意图 → 消息 → 指令(页面只发意图,不做乐观回显)
//!
//! | 用户动作 | 页面消息 | app 层落点 |
//! |---|---|---|
//! | 点分类 tab | [`Message::SetCategory`] | `AppCommand::ListVersions(category)`(`All` 不派:画现有快照) |
//! | 点一行 | [`Message::SelectVersion`] | **只改视图模型**(选中不是业务命令) |
//! | 点「安装」 | [`Message::InstallVersion`] | `AppCommand::InstallVersion(id)` |
//! | 点「取消安装」 | [`Message::CancelInstall`] | `AppCommand::CancelInstall` |
//! | 点「刷新」/「打开日志」 | `Message::Action(RefreshSources)` / `Message::Navigate(Logs)` | `ListVersions` / `AppState::navigate` |
//!
//! 进度条只读 `model.install`:它由 presenter 的 `InstallProgress` 事件经 `apply` 灌进来。
//! 于是「点了没变」只有一种解释——后端还没回事件,这正是要的(P3-2 的纪律)。
//!
//! ## 版面判据(一条没动)
//!
//! 正文列 424 硬上限、断点 600 / 840、rail 88 都由 [`super::layout`] 与 `shell` 无条件施加,
//! 本页拿不到「更宽」的选项;页头仍按 M3 口径铺满页宽(偏离 #1,未变)。
use iced::widget::{column, container, row};
use iced::{Alignment, Element, Length};
mod demo;
mod install;
mod model;
mod rows;
mod states;
#[cfg(test)]
mod tests;
pub use demo::demo;
pub use model::{Category, Download};
use iced::widget::column;
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, Lang};
use crate::app::components::buttons::{self, Button};
use crate::app::components::divider;
use crate::theme::Ui;
use super::frame::{self, Header};
use super::layout::FrameMetrics;
use super::{Action, Message};
/// 整页。
pub fn view<'a>(ui: &'a Ui, metrics: FrameMetrics) -> Element<'a, Message> {
let header = Header::new("Download")
.subtitle("Versions, mod loaders and modpacks")
.action(frame::text_action(
/// 整页(**唯一**的真数据入口:`model` 由 `app/view.rs` 从 presenter 快照建)。
///
/// `lang` 是 i18n 的入参:`theme::Ui` 还没有 language 字段(那条在 M93 手里),产品路径现传
/// [`Lang::En`](D28 的英文基线零中文负担);接上探测结果时只改调用点,页面不用动。
pub fn view<'a>(
ui: &'a Ui,
metrics: FrameMetrics,
model: &'a Download,
lang: Lang,
) -> Element<'a, Message> {
let catalog = lang.catalog();
let header = Header::new(catalog.text(Key::DownloadTitle))
.subtitle(catalog.text(Key::DownloadSubtitle))
.action(buttons::text(
ui,
"Refresh sources",
Action::RefreshSources,
Button::new(
catalog.text(Key::DownloadRefresh),
Message::Action(Action::RefreshSources),
),
));
let empty = frame::empty_state(
ui,
IconKind::Download,
"No downloads in progress",
"Pick a version or drop a modpack file here. Sources are probed per region and can be pinned \
in Settings → Downloads.",
vec![
frame::text_action(ui, "Browse versions", Action::BrowseVersions),
frame::text_action(ui, "Import modpack", Action::ImportModpack),
frame::text_action(ui, "Check sources", Action::CheckSources),
],
);
let body = frame::single_column(
metrics,
column![source_status(ui), empty]
.spacing(ui.space(Space::X4))
.width(Length::Fill)
.height(Length::Fill),
);
frame::page(ui, metrics, header, body)
frame::page(ui, metrics, header, body(ui, metrics, catalog, model))
}
/// 来源状态行(12pt,`OnSurfaceVariant`):说明这一页的数据从哪来,不是业务数据。
fn source_status<'a>(ui: &'a Ui) -> Element<'a, Message> {
container(
row![
frame::glyph(
IconKind::Check,
ui.color(Role::Secondary),
frame::ICON_SMALL_PX
),
frame::text_role(
ui,
TypeRole::BodySmall,
false,
"3 sources healthy · BMCLAPI pinned by region · 2 min ago",
ui.color(Role::OnSurfaceVariant),
),
/// 正文:加载 / 空 / 列表 + 底部安装面板(列宽由 [`super::layout`] 决议,页面只吃结果)。
fn body<'a>(
ui: &'a Ui,
metrics: FrameMetrics,
catalog: Catalog,
model: &'a Download,
) -> Element<'a, Message> {
let content = if model.is_loading() {
states::loading(ui, catalog)
} else if model.is_empty() {
states::empty(ui, catalog)
} else {
column![
rows::categories(ui, catalog, model.category),
rows::head(ui, catalog, model),
divider::full(ui),
rows::list(ui, catalog, model),
divider::full(ui),
install::panel(ui, catalog, model),
]
.spacing(ui.space(Space::X2))
.align_y(Alignment::Center),
)
.width(Length::Fill)
.into()
}
.spacing(ui.space(Space::X3))
.width(Length::Fill)
.height(Length::Fill)
.into()
};
#[cfg(test)]
mod tests {
use super::super::layout::{content_column_width, frame_metrics, measure_cap_px, PaneKit};
/// 单列页:任何窗口宽度都不许出现第二列,正文列不许超过行宽上限。
#[test]
fn download_is_single_column_at_every_width() {
for width in [600.0, 840.0, 900.0, 1180.0, 2400.0] {
let metrics = frame_metrics(PaneKit::Single, width);
assert!(metrics.secondary.is_none(), "{width} 宽下载页出现了第二列");
assert!(metrics.column <= measure_cap_px() + f32::EPSILON);
assert_eq!(metrics.column, content_column_width(width));
}
}
/// 宽窗下多出来的宽度必须留白(不是把卡片拉宽)——1180 的正文列 < 可用宽度。
#[test]
fn extra_width_goes_to_margin_not_to_the_column() {
let metrics = frame_metrics(PaneKit::Single, 1180.0);
assert_eq!(metrics.column, measure_cap_px());
assert!(
metrics.available - metrics.column >= 400.0,
"1180 宽下应留出大片留白(M68 的全宽卡就是没有这段留白)"
);
}
frame::single_column(metrics, content)
}