fix(download): M94 r1 —— 五条复审(P2-1/2/3/4/6)

P2-1 占位规则统一:加载态原先贴顶、空态居中 → 两态都走同一套「内容在可用区居中」
  (依据:components::feedback::empty_state 的既有口径 12b §15.4;两态互切不再纵向跳)。
  实测两个状态的内容中心线 y=449 / y=445(差 4px)。

P2-2 文案只说真能做到的事:
  - 删「The list appears after the first successful fetch; this page never goes blank.」
    (工程话术 + 承诺了 UI 做不到的「永空白」)→「如果一直停在这里,点右上角的刷新。」
    (对应页头那个真的 Refresh 按钮)
  - 空态「Switch the category or clear the filter」删掉「清筛选」(页面没有筛选控件)
    →「换一个分类,或刷新一次列表。」
  - 副标题「Version list from the official manifest」去掉 manifest 这种工程词 →「可以安装的版本」

P2-3 去掉静止的 indeterminate 进度条:页面没有帧订阅,indeterminate 只会拿到一个固定相位
  (看起来像 17% 卡住)。加载态改成纯文字;安装面板只在进度已知时画 determinate。
  两帧证据:215/512 → 条填 177px(42%),409/512 → 338px(80%),条随事件走而不是动画。

P2-4 失败态与安装中彻底不同构:失败不再画进度条,改成整块 ErrorContainer 面板
  (ErrorContainer 底 + Error 图标 + Error 色标题 + 停在哪个文件 + 说明 + 实心「打开日志」)。
  实测底色:浅色 (255,218,214) / 深色 (147,0,10),安装中面板底色仍是页面底色。

P2-6 死 key 与假守卫:
  - 删掉没用的 DownloadSelected;
  - 用真的守卫替换原来那条「前缀遍历 + 条数下限」:every_download_key_is_referenced_by_the_page
    用 include_str! 把页面源码编进来,目录里每个 Download* key 必须在源码里出现 Key::Xxx。
    实证:把 DownloadSelected 放回去 → 该测试红并点名 key;删掉 → 绿。
    注释里也写清抓不住的三种情况(反向是编译错误 / 译文完整性归 i18n 自己的测试 / 宏拼 key 能骗过它)。

门禁:fmt --check 干净、clippy -D warnings 干净、app-iced 276 + i18n 23 全绿;
tests.rs 384→383 行(不再贴顶),其余文件均 ≤400。
This commit is contained in:
tower authored and AskaEth committed 2026-11-02 21:36:26 +08:00
1 parent 57e8380b24
commit 9d6ee70301
6 files changed
+229 -145

No files matched your search

+118 -64
View File
@@ -2,26 +2,37 @@
//!
//! ## 这一块为什么钉在底部而不是跟着列表滚
//!
//! 列表是**线性进度项 + 版本集**,装的是「几十行」;底部按钮如果跟着滚,用户在列表中间永远
//! 看不到「安装」在哪。所以列表独立滚动(`rows::list`),面板固定占尾部一行高度。
//! 列表装的是几十行版本;底部按钮如果跟着滚,用户在列表中间永远看不到「安装」在哪。
//! 所以列表独立滚动(`rows::list`),面板固定占尾部一块高度。
//!
//! ## 只有 intent,没有乐观回显
//! ## 只有意图,没有乐观回显
//!
//! | 控件 | 消息 | app 层落点 |
//! |---|---|---|
//! | 「安装」 | [`Message::InstallVersion`] | `AppCommand::InstallVersion(id)` |
//! | 「取消安装」 | [`Message::CancelInstall`] | `AppCommand::CancelInstall` |
//! | 「打开日志」 | [`Message::Navigate(Route::Logs)]` | `AppState::navigate`(不派指令) |
//! | 「打开日志」 | [`Message::Navigate(Route::Logs)`] | `AppState::navigate`(不派指令) |
//!
//! 按钮按下**不改任何本地状态**:进度条读的是 `model.install`,它只由 presenter 的
//! `InstallProgress` 事件灌进来(`apply` → 视图模型重建)。于是「点了没变」= 后端还没回事件,
//! 正是要的行为。
//! `InstallProgress` 事件灌进来(`apply` → 视图模型重建)。于是「点了没变」= 后端还没回事件。
//!
//! ## 三种面板形态刻意不同构(M94 r1 / P2-3 + P2-4)
//!
//! | 状态 | 形态 | 为什么 |
//! |---|---|---|
//! | 选好了 | 版本 id + 实心「安装」 | 本页唯一的实心按钮(rubric K2) |
//! | 安装中 | 版本 id + 阶段 + **进度条**(仅当进度已知)+ 计数 + 文本「取消安装」 | 有进度才有条 |
//! | 失败 | **整块 `ErrorContainer` 面板**:`Error` 图标 + `Error` 色标题 + 停在哪 + 实心「打开日志」 | **不画进度条**——进度条正是「看起来还在跑」的来源;颜色/图标/措辞/按钮四样一起变,才一眼可辨 |
//!
//! 进度未知(刚派发、后端还没给文件计数)时**不画条**:页面没有帧订阅,`Progress::indeterminate`
//! 只会得到一个静止的假进度(复审 P2-3 的「17% 卡住」),不如只写「等第一个文件…」。
use iced::widget::{column, container, row};
use iced::{Alignment, Element, Length, Padding};
use iced::{Alignment, Background, Border, Element, Length, Padding};
use unlucky_design::color::Role;
use unlucky_design::icons::IconKind;
use unlucky_design::shape::ShapeScale;
use unlucky_design::spacing::Space;
use unlucky_design::typography::TypeRole;
@@ -37,9 +48,6 @@ 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 {
@@ -94,52 +102,50 @@ fn ready<'a>(ui: &'a Ui, catalog: Catalog, entry: &'a VersionRow) -> Element<'a,
)
};
row![label.width(Length::Fill), action]
row![label, action]
.width(Length::Fill)
.align_y(Alignment::Center)
.into()
}
/// 安装中 / 已收尾:阶段 + 进度条 + 计数 + 速率,失败时给可读文案与出口。
/// 安装中 / 已收尾(失败另走 [`failed`])。
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))
};
if install.failed() {
return failed(ui, catalog, install);
}
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,
let head = 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::BodySmall,
TypeRole::LabelMedium,
false,
install.detail.as_str(),
install.stage_label.as_str(),
ui.color(Role::OnSurfaceVariant),
),
]
.spacing(ui.space(Space::X2))
.width(Length::Fill);
.width(Length::Fill)
.align_y(Alignment::Center);
let mut body = column![head];
// 进度已知才画条:未知时画 indeterminate 会得到一个静止的假进度(见模块文档)。
if install.known {
body = body.push(linear(ui, Progress::determinate(install.ratio)));
}
body = body.push(frame::text_role(
ui,
TypeRole::BodySmall,
false,
install.detail.as_str(),
ui.color(Role::OnSurfaceVariant),
));
if let Some(rate) = &install.rate {
body = body.push(frame::text_role(
ui,
@@ -150,36 +156,84 @@ fn installing<'a>(ui: &'a Ui, catalog: Catalog, install: &'a InstallView) -> Ele
));
}
// 失败态:**可读的原因出口**(结构化文案 + 去日志页),不静默、不搬 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]
column![
body.spacing(ui.space(Space::X2)).width(Length::Fill),
action
]
.spacing(ui.space(Space::X2))
.width(Length::Fill)
.into()
}
/// 失败态:一整块 `ErrorContainer` 面板(**没有进度条**)。
///
/// 四样一起变,保证与「正在下载」一眼可辨:容器色(`ErrorContainer`)/ 图标(`Error`)/
/// 措辞(`Install failed` 用 `Error` 色标题)/ 主按钮(实心「打开日志」,不是文本按钮)。
fn failed<'a>(ui: &'a Ui, catalog: Catalog, install: &'a InstallView) -> Element<'a, Message> {
let head = row![
frame::glyph(IconKind::Error, ui.color(Role::Error), frame::ICON_PX),
frame::text_role(
ui,
TypeRole::TitleSmall,
true,
catalog.text(Key::DownloadStageFailed),
ui.color(Role::Error),
),
]
.spacing(ui.space(Space::X2))
.align_y(Alignment::Center);
let stopped = frame::text_role_owned(
ui,
TypeRole::BodySmall,
false,
format!("{} · {}", install.version, install.detail),
ui.color(Role::OnErrorContainer),
);
let action = buttons::filled(
ui,
Button::new(
catalog.text(Key::DownloadOpenLogs),
Message::Navigate(Route::Logs),
)
.leading(IconKind::Logs),
);
container(
column![
head,
stopped,
frame::text_role(
ui,
TypeRole::BodySmall,
false,
catalog.text(Key::DownloadFailedHint),
ui.color(Role::OnErrorContainer),
),
action,
]
.spacing(ui.space(Space::X2))
.width(Length::Fill)
.into()
.width(Length::Fill),
)
.width(Length::Fill)
.padding(ui.space(Space::X4))
.style(move |_theme| container::Style {
background: Some(Background::Color(ui.color(Role::ErrorContainer))),
border: Border {
radius: ui.radius_dp(ShapeScale::Large),
..Border::default()
},
..container::Style::default()
})
.into()
}