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

+56 -37
View File
@@ -1,19 +1,30 @@
//! 加载态与空态(**没有可用版本数据时页面不能是白屏**)。
//! 占位状态(加载 / 空):**同一套占位规则——内容在可用区居中**(M94 r1 / P2-1)。
//!
//! | 状态 | 触发条件(都能从快照推出来) | 画什么 |
//! | 状态 | 条件(都能从快照推出来) | 画什么 |
//! |---|---|---|
//! | 加载 | `model.is_loading()`:快照里还没有版本列表 | 不确定态进度条 + 「正在加载版本列表…」+ 一句说明 |
//! | 空 | `model.is_empty()`:列表拉到了,但过滤后一行不剩 | 真 [`empty_state`](图标块 96 + 24 标题 + 说明 + 一个刷新 CTA) |
//! | 加载 | `model.is_loading()`:快照里还没有版本列表 | 标题 + 一句说明(**没有任何进度形状**) |
//! | 空 | `model.is_empty()`:列表拉到了,但过滤后一行不剩 | 真 [`empty_state`](图标块 96 + 24 标题 + 说明 + 刷新 CTA) |
//!
//! 两者都**不留白**:加载态给得出「还在等」的视觉证据(进度条不是装饰,是唯一的非文字信号),
//! 空态给得出「下一步做什么」(刷新)。
//! ## 为什么是「都居中」(P2-1)
//!
//! 拉取**失败**在这条通道上没有结构化事件(presenter 只有 `VersionListChanged`),所以列表拉不到
//! 会停在加载态而不是谎报「没有版本」——加载态的说明文案里就写着这件事。安装失败的错误态在
//! [`super::install`] 里(那里的 `Failed` 阶段是结构化字段,可以放心翻译)。
//! `components::feedback::empty_state` 的既有口径(`12b §15.4`)就是「内容在可用区居中」;
//! 加载态原先贴顶,等于同一个页面里两套占位规则,两态互切时内容会纵向跳一次。所以本轮把加载态
//! 也放进同一条规则里。**有数据时列表仍然贴顶**——那是有内容的状态,不属于占位规则。
//!
//! ## 为什么加载态没有进度条(P2-3)
//!
//! 页面**没有帧订阅**(`app/subscription.rs` 的 `needs_frames` 归 app 层),所以
//! `Progress::indeterminate(phase)` 拿到的是一个**静止的**相位——用户看到的是「17% 卡住」。
//! 与其留一个「看起来有进度但不动」的控件,不如给一句明确的「正在加载」文字;等 app 层真有了
//! 帧订阅再来带动画(那时这里换回 indeterminate 才有意义)。
//!
//! ## 文案口径(P2-2)
//!
//! 只说页面真能做到的动作:说明里那句「点右上角的刷新」对应页头那个真的 Refresh 按钮
//! (`Message::Action(Action::RefreshSources)`);没有写「稍后会自动重试」这种 UI 做不到的承诺。
use iced::widget::column;
use iced::{Element, Length};
use iced::widget::{column, container};
use iced::{Alignment, Element, Length};
use unlucky_design::color::Role;
use unlucky_design::icons::IconKind;
@@ -22,42 +33,50 @@ use unlucky_design::typography::TypeRole;
use unlucky_i18n::{Catalog, Key};
use crate::app::components::feedback::{empty_state, linear, EmptyState, Progress};
use crate::app::components::feedback::{empty_state, EmptyState};
use crate::theme::Ui;
use super::super::frame;
use super::super::{Action, Message};
/// 不确定态进度条的相位(没有帧订阅,取一帧静态相位;见 `install.rs` 同名常量)。
const INDETERMINATE_PHASE: f32 = 0.35;
/// 两个占位状态共用的外壳:水平 + 垂直都居中(见模块文档的两条理由)。
fn centered<'a>(content: impl Into<Element<'a, Message>>) -> Element<'a, Message> {
container(content)
.width(Length::Fill)
.height(Length::Fill)
.align_x(Alignment::Center)
.align_y(Alignment::Center)
.into()
}
/// 加载态。
/// 加载态:一句「正在加载」+ 一句这个页面真能做的事(点刷新)。
pub(super) fn loading<'a>(ui: &'a Ui, catalog: Catalog) -> Element<'a, Message> {
column![
frame::text_role(
ui,
TypeRole::BodyLarge,
true,
catalog.text(Key::DownloadLoading),
ui.color(Role::OnSurface),
),
linear(ui, Progress::indeterminate(INDETERMINATE_PHASE)),
frame::text_role(
ui,
TypeRole::BodySmall,
false,
catalog.text(Key::DownloadLoadingHint),
ui.color(Role::OnSurfaceVariant),
),
]
.spacing(ui.space(Space::X3))
.width(Length::Fill)
.into()
centered(
column![
frame::text_role_centered(
ui,
TypeRole::TitleMedium,
true,
catalog.text(Key::DownloadLoading),
ui.color(Role::OnSurface),
),
frame::text_role_centered(
ui,
TypeRole::BodySmall,
false,
catalog.text(Key::DownloadLoadingHint),
ui.color(Role::OnSurfaceVariant),
),
]
.spacing(ui.space(Space::X3))
.width(Length::Fill)
.align_x(Alignment::Center),
)
}
/// 空态(真 `EmptyState`:CTA 就是标准 Filled 按钮,走按钮家族)。
pub(super) fn empty<'a>(ui: &'a Ui, catalog: Catalog) -> Element<'a, Message> {
empty_state(
centered(empty_state(
ui,
EmptyState::new(
IconKind::Download,
@@ -68,5 +87,5 @@ pub(super) fn empty<'a>(ui: &'a Ui, catalog: Catalog) -> Element<'a, Message> {
catalog.text(Key::DownloadRefresh),
Message::Action(Action::RefreshSources),
),
)
))
}