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

+320
View File
@@ -0,0 +1,320 @@
//! 版本管理页的**三栏装配**:类型栏 + 主版本栏 + 列表列(宽档不够时退回顶部 chips)。
//!
//! ## 三栏各自的内容
//!
//! | 栏 | 画什么 | 交互 |
//! |---|---|---|
//! | 版本类型(第一栏) | 折叠开关 + 四个类型项(原顶部 tabs 的四个 chip,语义一字不改) | [`Message::SetCategory`](真指令:`AppCommand::ListVersions`) |
//! | 主版本(第二栏) | 「全部版本」+ 快照里出现的每个主版本桶([`super::group`]) | [`Message::SelectMajor`](纯视图态) |
//! | 列表 / 内容(第三栏) | 搜索条 + 命中数 + 计数头 + 版本行 + 底部安装面板 | [`Message::SetVersionQuery`] / 选版本 / 安装 |
//!
//! ## 宽度不在这里拍
//!
//! 两条栏的宽度来自 [`VersionRails`](宽档裁决,见 `pages/layout/rails.rs` 的数值表)。
//! 本文件只做两件事:把裁决出来的数字用上、把「用户手动折叠类型栏」这条覆盖叠上去。
//!
//! ## 为什么搜索条在第三栏而不是横跨三栏
//!
//! 搜索过滤的对象是**第三栏的列表**;横跨三栏会让人以为它也在搜侧栏里的类型。放进列表列顶部,
//! 「输入 → 下面的行变少」这条因果在视觉上就是同一条竖线;命中数是这条因果的读数,紧贴右侧。
//!
//! ## 键盘可达
//!
//! 两层侧栏的每一项都是真组件按钮(`navigation::list_item` / `navigation::tabs` /
//! `buttons::icon`):焦点由 iced 的焦点系统给,Tab 依次走「类型栏 → 主版本栏 → 搜索框 → 版本行 →
//! 安装」,Enter 激活。页面不自己写焦点环(那是组件的状态层)。
use iced::widget::{column, container, row, scrollable};
use iced::{Alignment, Element, Length, Padding};
use unlucky_design::color::Role;
use unlucky_design::elevation::MaterialLayer;
use unlucky_design::icons::IconKind;
use unlucky_design::spacing::Space;
use unlucky_design::typography::TypeRole;
use unlucky_i18n::{Catalog, Key};
use crate::app::components::buttons::{self, IconVariant};
use crate::app::components::navigation::list_item::{list_item, ListItem};
use crate::app::components::navigation::tabs::{tabs, Tab, Tabs};
use crate::app::components::selection::{chip, Chip};
use crate::theme::Ui;
use super::super::frame;
use super::super::layout::{VersionRails, RAIL_ICON_WIDTH};
use super::super::Message;
use super::model::{Category, Download};
use super::rows;
/// 类型栏里一项的图标。
///
/// 图标语汇只有 28 个描边字形(没有 `History` / `Camera`),所以借用:
/// **全部 = 网格、正式版 = 勾、预览版 = 刷新、远古版 = 档案袋**。已登记为偏离。
pub(super) const fn category_icon(category: Category) -> IconKind {
match category {
Category::All => IconKind::Instances,
Category::Release => IconKind::Check,
Category::Snapshot => IconKind::Refresh,
Category::Ancient => IconKind::Folder,
}
}
/// 三栏装配(`Wide` / `Narrow` / `Compact` 三档共用)。
pub(super) fn three_column<'a>(
ui: &'a Ui,
catalog: Catalog,
model: &'a Download,
rails: VersionRails,
) -> Element<'a, Message> {
row![
panel(
ui,
rails.type_rail,
type_rail(ui, catalog, model, rails.type_rail)
),
panel(
ui,
rails.major_rail,
major_rail(ui, catalog, model, rails.major_rail)
),
container(rows::content(ui, catalog, model))
.width(Length::Fixed(rails.list))
.height(Length::Fill),
]
.spacing(super::super::layout::rails::RAIL_GAP)
.width(Length::Fill)
.height(Length::Fill)
.into()
}
/// 一条侧栏的外壳:固定宽 + 面板底色 + 独立滚动(栏内内容不横向挤)。
fn panel<'a>(
ui: &'a Ui,
width: f32,
content: impl Into<Element<'a, Message>>,
) -> Element<'a, Message> {
container(scrollable(content).height(Length::Fill).style(frame::scroll_style(ui)))
.width(Length::Fixed(width))
.height(Length::Fill)
// 水平不留内边距:140 宽里 `ListItem` 自己的 16+16 已经够,再多 8 会把 `Snapshot` 切掉
// (首轮出图实测「Snapsh」)。垂直留 8 让第一项不贴顶。
//
// 底色取 `Card` 层(`SurfaceContainerLow`,比页面 Canvas 高一档):首轮出图用的是 Canvas,
// 两条栏和页面底色一模一样 ⇒ 读起来像「一堆文字浮在左边」,不像两条侧栏。
.padding(Padding::from([ui.space(Space::X2), 0.0]))
.style(move |_theme| frame::filled(ui.layer(MaterialLayer::Card)))
.into()
}
/// 第一栏:版本类型(`140` 展开 / `64` 图标条)。
fn type_rail<'a>(
ui: &'a Ui,
catalog: Catalog,
model: &'a Download,
width: f32,
) -> Element<'a, Message> {
// 宽度 ≤ 图标条宽 ⇒ 只画图标。用户手动折叠与窄档折叠走同一条判据(不另设开关)。
let icon_only = width <= RAIL_ICON_WIDTH;
let mut body = column![].spacing(ui.space(Space::X1)).width(Length::Fill);
let fold = fold_action(ui, model, icon_only);
if icon_only {
body = body.push(
container(fold)
.width(Length::Fill)
.align_x(Alignment::Center),
);
} else {
body = body.push(rail_title(ui, catalog.text(Key::VersionRailTitle), fold));
}
for category in Category::ALL {
let selected = category == model.category;
let label = catalog.text(category.label_key());
let press = Message::SetCategory(category);
body = body.push(if icon_only {
// 图标条:40×40 图标按钮(命中区 48 由栏内留白补足),选中 = `Tonal`
container(buttons::icon(
ui,
select_variant(selected),
category_icon(category),
Some(press),
None,
))
.width(Length::Fill)
.align_x(Alignment::Center)
.into()
} else {
let mut item = ListItem::new(label, press).leading(category_icon(category));
if selected {
item = item.selected();
}
list_item(ui, item)
});
}
body.into()
}
/// 选中态 → 图标按钮的三式(`Tonal` = 当前项,与 ListItem 的 `SecondaryContainer` 同色系)。
fn select_variant(selected: bool) -> IconVariant {
if selected {
IconVariant::Tonal
} else {
IconVariant::Standard
}
}
/// 折叠 / 展开类型栏的图标按钮(`Sidebar` 字形;折叠中 = `Tonal`)。
fn fold_action<'a>(ui: &'a Ui, model: &Download, icon_only: bool) -> Element<'a, Message> {
// 已经被宽度压成图标条时不再给开关:那时点它也不会变宽(档位说了算),
// 一个按了没反应的按钮正是 D30 判 p1 的那种假控件。
if icon_only && !model.type_rail_folded {
return frame::gap(ui.metrics().nav_item_gap);
}
buttons::icon(
ui,
select_variant(model.type_rail_folded),
IconKind::Sidebar,
Some(Message::ToggleTypeRail),
None,
)
}
/// 栏标题(`LabelMedium` + `OnSurfaceVariant`,与列表计数头同一档字级)。
///
/// 高度固定成 `control_height`(40 = 图标按钮的边长):两条栏的标题必须**横向对齐**,
/// 而第一栏标题右侧有个 40 高的折叠按钮、第二栏没有。不固定高度的话两行标题会差 12px。
fn rail_title<'a>(
ui: &'a Ui,
title: &'a str,
action: Element<'a, Message>,
) -> Element<'a, Message> {
container(
row![
container(frame::text_role(
ui,
TypeRole::LabelMedium,
false,
title,
ui.color(Role::OnSurfaceVariant),
))
.width(Length::Fill),
action,
]
.width(Length::Fill)
.align_y(Alignment::Center)
.padding(Padding::from([0.0, ui.space(Space::X2)])),
)
.height(Length::Fixed(ui.metrics().control_height))
.align_y(Alignment::Center)
.into()
}
/// 第二栏:主版本桶(第一项永远是「全部版本」;没有主版本概念的版本不单独占一项)。
fn major_rail<'a>(
ui: &'a Ui,
catalog: Catalog,
model: &'a Download,
width: f32,
) -> Element<'a, Message> {
let icon_only = width <= RAIL_ICON_WIDTH;
let mut body = column![].spacing(ui.space(Space::X1)).width(Length::Fill);
if !icon_only {
body = body.push(rail_title(
ui,
catalog.text(Key::VersionMajorTitle),
frame::gap(ui.metrics().nav_item_gap),
));
}
body = body.push(major_item(
ui,
catalog.text(Key::VersionMajorAll),
Message::SelectMajor(None),
model.selected_major.is_none(),
));
for bucket in &model.buckets {
// `None` 桶 = 远古版那类没有主版本的版本:第二级只有一个「全部」,不画空桶
let Some(key) = bucket.key.as_deref() else {
continue;
};
body = body.push(major_item(
ui,
key,
Message::SelectMajor(Some(key.to_string())),
model.selected_major.as_deref() == Some(key),
));
}
body.into()
}
/// 主版本的一项:`ListItem` 单行(64 宽里 `1.21` 仍完整——`BodyLarge` 4 字符 ≈ 32px ≤ 64−32 内边距)。
fn major_item<'a>(
ui: &'a Ui,
label: &'a str,
press: Message,
selected: bool,
) -> Element<'a, Message> {
let mut item = ListItem::new(label, press);
if selected {
item = item.selected();
}
list_item(ui, item)
}
/// 顶部 chips(可用宽 < 600 时三栏不成立:类型退回 Primary tabs,主版本变成 Secondary chips 行)。
///
/// 与三栏档**同源**:同一批 [`Category`] / 同一批主版本桶 / 同两条消息,不是另造一套筛选状态。
pub(super) fn stacked_chips<'a>(
ui: &'a Ui,
catalog: Catalog,
model: &'a Download,
) -> Element<'a, Message> {
let mut types = Tabs::primary();
for category in Category::ALL {
let mut tab = Tab::new(
catalog.text(category.label_key()),
Message::SetCategory(category),
);
if category == model.category {
tab = tab.selected();
}
types = types.tab(tab);
}
let mut majors = row![]
.spacing(ui.space(Space::X2))
.align_y(Alignment::Center);
majors = majors.push(chip(
ui,
Chip::filter(
catalog.text(Key::VersionMajorAll),
model.selected_major.is_none(),
Message::SelectMajor(None),
),
));
for bucket in &model.buckets {
let Some(key) = bucket.key.as_deref() else {
continue;
};
majors = majors.push(chip(
ui,
Chip::filter(
key,
model.selected_major.as_deref() == Some(key),
Message::SelectMajor(Some(key.to_string())),
),
));
}
column![tabs(ui, types), majors]
.spacing(ui.space(Space::X2))
.width(Length::Fill)
.into()
}