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

+319
View File
@@ -0,0 +1,319 @@
//! 下载页视图模型:**presenter 快照 → 页面要画的东西**(与 [`super::super::model`] 同一套分层)。
//!
//! ## 为什么这一页也要中间层
//!
//! 页面层不认识 presenter 的 DTO(同实例页的理由):真页面吃 `AppState` 的快照,
//! 演示数据与单测吃 [`super::demo`],两边都转成这里的纯展示字段。
//!
//! 文案在这一层就按 [`Catalog`] 求好值(`supporting` / `detail` / `rate` 都是 `String`):
//! iced 的文字控件**借用**内容,行内文案又是 `format!` 出来的,不先落成字段就没法进控件树
//! (`pages/frame` 的 `text_role_owned` 是同一个理由的单行版)。
//!
//! ## 快照里**没有**的东西(都登记在 `pages/mod.rs` 的偏离表里)
//!
//! | 缺什么 | 后果 | 兜法 |
//! |---|---|---|
//! | 「版本列表已加载完成」标志 | 空列表既可能是「还没拉到」也可能是「真的没有」 | [`Download::loaded`] 按「快照里有没有版本」判:没有 = 加载中 |
//! | 版本列表**失败**事件 | 拉取失败会停在加载态(不静默:页面上有说明与刷新入口) | 错误态借安装阶段的 `Failed`(结构化、可翻译) |
//! | 「当前选中」字段 | 选中态存在模型里,任何快照重建都会回到「正在安装的那个版本」 | 见 `pages/mod.rs` 偏离登记;长期要靠 presenter 加字段 |
//!
//! ## 中文串一个都不许画
//!
//! `InstallProgressView::message` / `InstallStage::label()` 都是**中文业务串**(`i18n::bridge`
//! 的规矩 2:core 的中文串只给日志和 CLI)。所以这里只搬**结构化字段**(阶段枚举、计数、字节),
//! 文案按 `Key` 现求——中文环境自然出中文,英文环境不需要任何中文字体。
use unlucky_presenter::dto::{
human_bytes, AncientKind, InstallProgressView, InstallStage, VersionCategory, VersionKind,
VersionSummary,
};
use unlucky_presenter::state::AppState;
use unlucky_i18n::{Catalog, Key};
/// 分类筛选(页面上的四个 filter chip)。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Category {
/// 不过滤:画快照里有什么就是什么(首帧与 `RefreshAll` 的落点)
#[default]
All,
/// 正式版
Release,
/// 预览版
Snapshot,
/// 远古版(快照里按 `AncientKind` 分四类,页面先只认一个 chip)
Ancient,
}
impl Category {
/// 四个 chip 的声明顺序(= 画出来的顺序)。
pub const ALL: [Category; 4] = [
Category::All,
Category::Release,
Category::Snapshot,
Category::Ancient,
];
/// chip 文案的 key。
pub const fn label_key(self) -> Key {
match self {
Category::All => Key::DownloadCategoryAll,
Category::Release => Key::DownloadCategoryRelease,
Category::Snapshot => Key::DownloadCategorySnapshot,
Category::Ancient => Key::DownloadCategoryAncient,
}
}
/// 点这个 chip 要派的指令(`None` = 不派:`All` 就是「把当前快照全画出来」)。
///
/// 远古版在 `VersionCategory` 里是**带子类的**(`Ancient(AncientKind)`),一个 chip 拉不全四类;
/// 这里先拉 `Classic`,四子类同屏要靠 presenter 侧支持(已登记 finding)。
pub const fn command(self) -> Option<VersionCategory> {
match self {
Category::All => None,
Category::Release => Some(VersionCategory::Release),
Category::Snapshot => Some(VersionCategory::Snapshot),
Category::Ancient => Some(VersionCategory::Ancient(AncientKind::Classic)),
}
}
/// 这个版本属于这个分类吗。
pub fn matches(self, kind: VersionKind) -> bool {
match self {
Category::All => true,
Category::Release => kind == VersionKind::Release,
Category::Snapshot => kind == VersionKind::Snapshot,
Category::Ancient => kind.is_ancient(),
}
}
/// 从快照里**反推**当前分类:整份列表只有一类 → 那一类;混合或为空 → [`Category::All`]。
///
/// 快照没有「我上次按哪个分类拉的」这种字段,所以分类靠列表内容反推
/// (`ListVersions` 的结果是单一分类,`RefreshAll` 的结果是混合)。
pub fn of(versions: &[VersionSummary]) -> Self {
let mut kinds = versions.iter().map(|version| version.kind);
let Some(first) = kinds.next() else {
return Category::All;
};
if kinds.all(|kind| kind == first) {
match first {
VersionKind::Release => Category::Release,
VersionKind::Snapshot => Category::Snapshot,
VersionKind::Ancient(_) => Category::Ancient,
}
} else {
Category::All
}
}
}
/// 版本列表的一行(`ListItem` 的两行档:headline = 版本 id,supporting = `正式版 · 发布于 …`)。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct VersionRow {
/// 版本 id(`1.21.9` / `rd-132211`)——也是「安装」指令的载荷
pub id: String,
/// 发布日期(ISO-8601 日期部分,快照原样)
pub released: String,
/// 版本类型
pub kind: VersionKind,
/// 本地是否已安装(行上给勾 / overline `Installed`)
pub installed: bool,
/// 副文案(`正式版 · 发布于 2026-09-20`;一行 = 一个 key,不拼片段)
pub supporting: String,
}
impl VersionRow {
pub(super) fn of(version: &VersionSummary, catalog: Catalog) -> Self {
Self {
id: version.id.clone(),
released: version.released.clone(),
kind: version.kind,
installed: version.installed,
supporting: catalog.render(
Key::DownloadRowSupporting,
&[
("kind", catalog.text(kind_key(version.kind))),
("date", version.released.as_str()),
],
),
}
}
}
/// 版本类型 → 文案 key(远古版不分子类:chip 只有一档,行上再分子类等于两套语言)。
const fn kind_key(kind: VersionKind) -> Key {
match kind {
VersionKind::Release => Key::DownloadCategoryRelease,
VersionKind::Snapshot => Key::DownloadCategorySnapshot,
VersionKind::Ancient(_) => Key::DownloadCategoryAncient,
}
}
/// 一次安装的展示态(结构化字段 + 已求好的文案)。
#[derive(Debug, Clone, PartialEq)]
pub struct InstallView {
/// 目标版本(= 选中的那一行)
pub version: String,
/// 阶段
pub stage: InstallStage,
/// 阶段词(`下载文件`)
pub stage_label: String,
/// 计数行(`128/512 文件`;未知时是「等第一个文件…」)
pub detail: String,
/// 速率 + 剩余时间(`1.2 MB/s · 剩 1m15s`;未知时 `None`)
pub rate: Option<String>,
/// 整体进度 0.0~1.0
pub ratio: f32,
/// 进度是否已知(`false` = 进度条画不确定态)
pub known: bool,
}
impl InstallView {
/// 快照 → 展示态。
pub(super) fn of(progress: &InstallProgressView, catalog: Catalog) -> Self {
let known = progress.is_known();
let detail = if known {
catalog.render(
Key::DownloadProgress,
&[
("done", &progress.files_done.to_string()),
("total", &progress.files_total.to_string()),
],
)
} else {
catalog.text(Key::DownloadProgressUnknown).to_owned()
};
let rate = if progress.speed_bps == 0 || progress.eta_secs == 0 {
None
} else {
let eta = if progress.eta_secs >= 60 {
catalog.render(
Key::DownloadEtaMins,
&[
("mins", &(progress.eta_secs / 60).to_string()),
("secs", &format!("{:02}", progress.eta_secs % 60)),
],
)
} else {
catalog.render(
Key::DownloadEtaSecs,
&[("secs", &progress.eta_secs.to_string())],
)
};
Some(catalog.render(
Key::DownloadRate,
&[
("speed", &format!("{}/s", human_bytes(progress.speed_bps))),
("eta", eta.as_str()),
],
))
};
Self {
version: progress.version.clone(),
stage: progress.stage,
stage_label: catalog.text(stage_key(progress.stage)).to_owned(),
detail,
rate,
ratio: progress.ratio(),
known,
}
}
/// 失败了(错误态要出可读文案 + 出口,不许静默)。
pub const fn failed(&self) -> bool {
matches!(self.stage, InstallStage::Failed)
}
/// 收尾了(`Done` / `Failed` / `Cancelled`)。
pub fn terminal(&self) -> bool {
self.stage.is_terminal()
}
/// 还在跑(「取消安装」只在这时给)。
pub fn running(&self) -> bool {
!self.stage.is_terminal()
}
}
/// 阶段 → 文案 key(**不**用 `InstallStage::label()`:那是中文业务串)。
const fn stage_key(stage: InstallStage) -> Key {
match stage {
InstallStage::Planning => Key::DownloadStagePlanning,
InstallStage::Downloading => Key::DownloadStageDownloading,
InstallStage::Installing => Key::DownloadStageInstalling,
InstallStage::Registering => Key::DownloadStageRegistering,
InstallStage::Done => Key::DownloadStageDone,
InstallStage::Failed => Key::DownloadStageFailed,
InstallStage::Cancelled => Key::DownloadStageCancelled,
}
}
/// 下载页要画的东西(**全部字段都能从快照推出来**,页面自己不留状态)。
#[derive(Debug, Clone, PartialEq, Default)]
pub struct Download {
/// 版本列表(顺序 = 快照顺序)
pub rows: Vec<VersionRow>,
/// 当前分类(从快照反推)
pub category: Category,
/// 选中的版本 id(`None` = 没选中 → 安装按钮禁用)
pub selected: Option<String>,
/// 正在跑(或刚跑完)的安装
pub install: Option<InstallView>,
/// 快照里有没有版本列表(`false` = 还没拉到 → 加载态)
pub loaded: bool,
}
impl Download {
/// 空模型 = 加载态(首帧,以及 `pages::Model::new` 的默认值)。
pub fn loading() -> Self {
Self::default()
}
/// presenter 快照 → 下载页模型(**唯一的真数据入口**)。
///
/// 快照里的两个过滤条件(`installed_only` / `download_query`)原样照用:它们是 presenter 的
/// 状态,页面不该另存一份;过滤后为空就是「空态」(`loaded` 仍为 `true`)。
pub fn from_snapshot(state: &AppState, catalog: Catalog) -> Self {
let query = state.download_query.trim().to_ascii_lowercase();
let rows = state
.versions
.iter()
.filter(|version| !state.installed_only || version.installed)
.filter(|version| query.is_empty() || version.id.to_ascii_lowercase().contains(&query))
.map(|version| VersionRow::of(version, catalog))
.collect::<Vec<_>>();
let install = state
.install
.as_ref()
.map(|view| InstallView::of(view, catalog));
// 快照没有「选中」字段:正在安装的那个版本就是「选中」(见模块文档的缺口表)。
let selected = install.as_ref().map(|view| view.version.clone());
Self {
rows,
category: Category::of(&state.versions),
selected,
install,
loaded: !state.versions.is_empty(),
}
}
/// 选中的那一行(安装面板上的版本名)。
pub fn selected_row(&self) -> Option<&VersionRow> {
let id = self.selected.as_deref()?;
self.rows.iter().find(|row| row.id == id)
}
/// 加载态:快照里还没有版本列表。
pub const fn is_loading(&self) -> bool {
!self.loaded
}
/// 空态:列表拉到了,但过滤之后一行都不剩。
pub fn is_empty(&self) -> bool {
self.loaded && self.rows.is_empty()
}
}