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:
1 parent
72b0c9d465
commit
89a51a63b1
24 files changed
+2000
-340
No files matched your search
@@ -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)]
|
||||
|
||||
Reference in new issue
Block a user