feat(app-iced): M107 DLVRAIL —— 版本管理页三栏侧栏 + 跨类型搜索

用户令(信息架构):这一页从「下载」改称「版本管理」,版面从「一排 tab + 平铺列表」
改成**页面内三栏**:版本类型栏 + 主版本栏 + 列表/详情,并加搜索框。

- **三栏几何**(`pages/layout/rails.rs`,纯函数 + 数值表):类型栏 140/64、主版本栏 140/64、
  间距 24、列表列下限 424(行宽红线)。四档由**可用宽**裁决:Wide ≥888 / Narrow ≥676 /
  Compact ≥600 / Chips <600,三个门槛都是 `栏宽 + 间距 + 424` 算出来的,不是拍的
- **主版本分组规则**(`download/group.rs`):正式版/预发布按 `major.minor`(`1.21.9-pre3` → `1.21`)、
  周更按年桶(`24w14a` → `24w`)、远古版**没有主版本**(`rd-132211`/`a1.2.6` → 第二级只有「全部」);
  判据用真实版本样本钉住,不按「第一个点」切
- **跨类型搜索**(`search_bar` + 清空 + 命中数):查询词写进 `AppState.download_query`(页面无状态,
  只能这么活),`hits` 是**跨分类**读数;三条口径分开:「有命中」/「没有匹配」/「命中在别的类型里」
- 两级侧栏都用 `navigation::list_item` / `tabs` / `chip` / `buttons::icon`:Tab 可达、hover/pressed/
  选中态全部来自组件,页面不自己写状态层
- 右栏展开(M104 `content_width` 852)⇒ 可用宽 716 ⇒ Narrow 档,列表 464(任务书那句 492 漏扣
  96px 的页边距与栏间距,报告里给了完整数值表)
- 文案 11 条中英进 `crates/i18n`(新域宏接在装配链末尾 ⇒ `Key::ALL` 只追加,金表只追加)
- 护栏:两层侧栏都存在 + 列表按第二级选中态过滤(变异可证)、三档门槛、424 下限扫描、
  版本行单行宽账、搜索条真的装着
- `install.rs` 只动了「已装」分支(加一句「已安装」解释禁用按钮),没碰 M105 的选项区

取证:`DevDocs/artifacts/m107/`(10 张:三档 × 浅深 + 搜索命中 + 空 + 异地 + 远古版)
This commit is contained in:
AskaEth committed 2026-11-05 01:59:13 +08:00
1 parent 72b0c9d465
commit 89a51a63b1
24 files changed
+2000 -340

No files matched your search

+63 -82
View File
@@ -24,8 +24,7 @@
//! 文案按 `Key` 现求——中文环境自然出中文,英文环境不需要任何中文字体。
use unlucky_presenter::dto::{
human_bytes, AncientKind, InstallProgressView, InstallStage, VersionCategory, VersionKind,
VersionSummary,
human_bytes, InstallProgressView, InstallStage, VersionKind, VersionSummary,
};
use unlucky_presenter::state::AppState;
@@ -43,82 +42,9 @@ pub fn cancelled_toast(lang: Lang) -> String {
lang.catalog().text(Key::DownloadCancelled).to_owned()
}
/// 分类筛选(页面上的四个 filter chip)。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Category {
/// 不过滤:画快照里有什么就是什么(首帧与 `RefreshAll` 的落点)
#[default]
All,
/// 正式版
Release,
/// 预览版
Snapshot,
/// 远古版(快照里按 `AncientKind` 分四类,页面先只认一个 chip)
Ancient,
}
mod category;
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
}
}
}
pub use category::Category;
/// 版本列表的一行(`ListItem` 的两行档:headline = 版本 id,supporting = `正式版 · 发布于 …`)。
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -145,16 +71,21 @@ impl VersionRow {
supporting: catalog.render(
Key::DownloadRowSupporting,
&[
("kind", catalog.text(kind_key(version.kind))),
("kind", catalog.text(kind_label_key(version.kind))),
("date", version.released.as_str()),
],
),
}
}
/// 这个版本属于哪个主版本桶(规则见 [`super::group`])。
pub fn major(&self) -> Option<super::group::MajorKey> {
super::group::key(&self.id, self.kind)
}
}
/// 版本类型 → 文案 key(远古版不分子类:chip 只有一档,行上再分子类等于两套语言)。
const fn kind_key(kind: VersionKind) -> Key {
pub(super) const fn kind_label_key(kind: VersionKind) -> Key {
match kind {
VersionKind::Release => Key::DownloadCategoryRelease,
VersionKind::Snapshot => Key::DownloadCategorySnapshot,
@@ -285,6 +216,16 @@ pub struct Download {
pub install: Option<InstallView>,
/// 快照里有没有版本列表(`false` = 还没拉到 → 加载态)
pub loaded: bool,
/// 搜索词(presenter 的 `download_query` 原样;空串 = 不过滤)
pub query: String,
/// **跨分类**命中数:搜索时侧栏显示它,所以它不随当前分类变化
pub hits: usize,
/// 选中的主版本桶(`None` = 全部;第二级侧栏的选中态,纯视图态)
pub selected_major: Option<String>,
/// 类型栏被用户手动折叠(`true` = 64 图标条;宽度档自己也会折叠,两者取更窄的)
pub type_rail_folded: bool,
/// 主版本桶(第二级侧栏的数据源;由 [`Self::rows`] 聚合,所以只含**当前分类**的桶)
pub buckets: Vec<super::group::Bucket>,
}
impl Download {
@@ -309,6 +250,9 @@ impl Download {
/// 它住在 `App`(照分类筛选的既有做法),因为 `refresh_view` 每次事件都会把整棵模型重建,
/// 页面自己存不住任何东西。选中项还要**在当前列表里**才算数:切分类后那一行不在了,
/// 就不该还留着一个点不动的「安装」。
///
/// M107:`query` / `hits` / `buckets` 也走这条路——查询词来自快照的 `download_query`,
/// 桶由过滤后的行聚合(第二级侧栏的数据源)。
pub fn from_snapshot(
state: &AppState,
category: Category,
@@ -316,12 +260,22 @@ impl Download {
catalog: Catalog,
) -> Self {
let query = state.download_query.trim().to_ascii_lowercase();
// 过滤谓词写一次、两处用:`hits` 是**跨分类**命中数(搜索框旁边的「N 个结果」不能
// 随当前分类变,否则用户在「正式版」里搜一个只有快照才有的串会看到 0)。
let matches = |version: &VersionSummary| {
(!state.installed_only || version.installed)
&& (query.is_empty() || version.id.to_ascii_lowercase().contains(&query))
};
let hits = state
.versions
.iter()
.filter(|version| matches(version))
.count();
let rows = state
.versions
.iter()
.filter(|version| category.matches(version.kind))
.filter(|version| !state.installed_only || version.installed)
.filter(|version| query.is_empty() || version.id.to_ascii_lowercase().contains(&query))
.filter(|version| matches(version) && category.matches(version.kind))
.map(|version| VersionRow::of(version, catalog))
.collect::<Vec<_>>();
@@ -354,6 +308,8 @@ impl Download {
};
Self {
// 桶由**过滤后的行**聚合:第二级侧栏只列当前分类里真实出现的版本族。
buckets: super::group::buckets(&rows),
rows,
// 分类不再从列表反推(那正是「chip 跳回全部」的来源):谁选的就是谁。
// [`Category::of`] 只留给「不经过用户选择」的入口(演示数据的首帧)。
@@ -362,6 +318,13 @@ impl Download {
options,
install,
loaded: !state.versions.is_empty(),
// 搜索词与命中数都来自快照:页面不持有查询(见模块文档「为什么搜索也走快照」)。
query: state.download_query.trim().to_owned(),
hits,
// 主版本桶的选中是**纯视图态**(同 `selected`):快照重建会回到「全部」。
selected_major: None,
// 折叠也是纯视图态;搜索重建时由 `rebuild_after_query` 前后各搬一次。
type_rail_folded: false,
}
}
@@ -380,6 +343,24 @@ impl Download {
pub fn is_empty(&self) -> bool {
self.loaded && self.rows.is_empty()
}
/// 有搜索词(空串不算:用户在框里敲了空格不该触发「没有匹配」)。
pub fn has_query(&self) -> bool {
!self.query.is_empty()
}
/// **整份清单**里一个都不匹配(跨分类也没有)⇒ 画「没有匹配」而不是白屏。
pub fn no_match(&self) -> bool {
self.loaded && self.has_query() && self.hits == 0
}
/// 有命中、但**不在当前分类**里 ⇒ 画「N 个版本在别的类型下」,并给一个「看全部」的出口。
///
/// 这条与 [`Self::no_match`] 分开,是因为两者该说的话完全不同:一个是「真的没有」,
/// 一个是「有,但你站错了分类」——后者让用户多点一次 tab 就白跑一趟。
pub fn only_elsewhere(&self) -> bool {
self.loaded && self.rows.is_empty() && self.hits > 0
}
}
#[cfg(test)]