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

@@ -0,0 +1,185 @@
//! 安装面板(页面底部):选中版本 → 「安装」/ 进度 → 「取消」/ 失败 → 「打开日志」。
//!
//! ## 这一块为什么钉在底部而不是跟着列表滚
//!
//! 列表是**线性进度项 + 版本集**,装的是「几十行」;底部按钮如果跟着滚,用户在列表中间永远
//! 看不到「安装」在哪。所以列表独立滚动(`rows::list`),面板固定占尾部一行高度。
//!
//! ## 只有 intent,没有乐观回显
//!
//! | 控件 | 消息 | app 层落点 |
//! |---|---|---|
//! | 「安装」 | [`Message::InstallVersion`] | `AppCommand::InstallVersion(id)` |
//! | 「取消安装」 | [`Message::CancelInstall`] | `AppCommand::CancelInstall` |
//! | 「打开日志」 | [`Message::Navigate(Route::Logs)]` | `AppState::navigate`(不派指令) |
//!
//! 按钮按下**不改任何本地状态**:进度条读的是 `model.install`,它只由 presenter 的
//! `InstallProgress` 事件灌进来(`apply` → 视图模型重建)。于是「点了没变」= 后端还没回事件,
//! 正是要的行为。
use iced::widget::{column, container, row};
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_presenter::router::Route;
use unlucky_i18n::{Catalog, Key};
use crate::app::components::buttons::{self, Button};
use crate::app::components::feedback::{linear, Progress};
use crate::theme::Ui;
use super::super::frame;
use super::super::Message;
use super::model::{Download, InstallView, VersionRow};
/// 不确定态进度条的相位(页面没有帧订阅 ⇒ 不做动画,取一帧好看的静态相位)。
const INDETERMINATE_PHASE: f32 = 0.35;
/// 底部的安装面板。
pub(super) fn panel<'a>(ui: &'a Ui, catalog: Catalog, model: &'a Download) -> Element<'a, Message> {
let content = match &model.install {
Some(install) => installing(ui, catalog, install),
None => match model.selected_row() {
Some(entry) => ready(ui, catalog, entry),
None => hint(ui, catalog),
},
};
container(content)
.width(Length::Fill)
.padding(Padding::from([ui.space(Space::X3), 0.0]))
.into()
}
/// 没选中任何版本:给一句「选一个版本再安装」(不静默禁用,也不画一个点不动的按钮)。
fn hint<'a>(ui: &'a Ui, catalog: Catalog) -> Element<'a, Message> {
frame::text_role(
ui,
TypeRole::BodySmall,
false,
catalog.text(Key::DownloadSelectHint),
ui.color(Role::OnSurfaceVariant),
)
}
/// 选好了:版本 id + 「安装」(全屏唯一实心 accent,rubric K2)。
///
/// 已装的版本给**禁用**的 Outlined 按钮(`Button::disabled` 只清消息、保留几何):
/// 「已装」是列表行上的事实(overline + 勾),按钮再点一次不会更清楚。
fn ready<'a>(ui: &'a Ui, catalog: Catalog, entry: &'a VersionRow) -> Element<'a, Message> {
let label = container(frame::text_role(
ui,
TypeRole::BodyMedium,
false,
entry.id.as_str(),
ui.color(Role::OnSurface),
))
.width(Length::Fill);
let action = if entry.installed {
buttons::outlined(ui, Button::disabled(catalog.text(Key::DownloadInstall)))
} else {
buttons::filled(
ui,
Button::new(
catalog.text(Key::DownloadInstall),
Message::InstallVersion(entry.id.clone()),
)
.leading(IconKind::Download),
)
};
row![label.width(Length::Fill), action]
.width(Length::Fill)
.align_y(Alignment::Center)
.into()
}
/// 安装中 / 已收尾:阶段 + 进度条 + 计数 + 速率,失败时给可读文案与出口。
fn installing<'a>(ui: &'a Ui, catalog: Catalog, install: &'a InstallView) -> Element<'a, Message> {
let bar = if install.known {
linear(ui, Progress::determinate(install.ratio))
} else {
linear(ui, Progress::indeterminate(INDETERMINATE_PHASE))
};
let mut body = column![
row![
container(frame::text_role(
ui,
TypeRole::BodyMedium,
false,
install.version.as_str(),
ui.color(Role::OnSurface),
))
.width(Length::Fill),
frame::text_role(
ui,
TypeRole::LabelMedium,
false,
install.stage_label.as_str(),
ui.color(Role::OnSurfaceVariant),
),
]
.width(Length::Fill)
.align_y(Alignment::Center),
bar,
frame::text_role(
ui,
TypeRole::BodySmall,
false,
install.detail.as_str(),
ui.color(Role::OnSurfaceVariant),
),
]
.spacing(ui.space(Space::X2))
.width(Length::Fill);
if let Some(rate) = &install.rate {
body = body.push(frame::text_role(
ui,
TypeRole::BodySmall,
false,
rate.as_str(),
ui.color(Role::OnSurfaceVariant),
));
}
// 失败态:**可读的原因出口**(结构化文案 + 去日志页),不静默、不搬 core 的中文串。
if install.failed() {
body = body.push(frame::text_role(
ui,
TypeRole::BodySmall,
false,
catalog.text(Key::DownloadFailedHint),
ui.color(Role::Error),
));
}
let action = if install.running() {
buttons::text(
ui,
Button::new(catalog.text(Key::DownloadCancel), Message::CancelInstall),
)
} else if install.failed() {
buttons::outlined(
ui,
Button::new(
catalog.text(Key::DownloadOpenLogs),
Message::Navigate(Route::Logs),
),
)
} else {
frame::gap(0.0)
};
column![body, action]
.spacing(ui.space(Space::X2))
.width(Length::Fill)
.into()
}