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
@@ -1,23 +1,39 @@
|
||||
//! 版本列表:分类 Tabs + 计数头 + `ListItem` 行(全是 `components/` 的真组件)。
|
||||
//! 列表列:搜索条 + 计数头 + **单行**版本行 + 底部安装面板。
|
||||
//!
|
||||
//! | 位置 | M84(占位) | M94(真组件) |
|
||||
//! ## 这一列的职责边界
|
||||
//!
|
||||
//! | 位置 | 画什么 | 消息 |
|
||||
//! |---|---|---|
|
||||
//! | 分类 | 无(整页只有一行假状态) | [`navigation::tabs`] 的 Primary 档(48 高、3px `Primary` 指示条) |
|
||||
//! | 列表行 | 无 | [`navigation::list_item`] 的两行档(**60** = `row_height` 44 + 每多一行 16) |
|
||||
//! | 计数头 | 无 | `LabelMedium` + `OnSurfaceVariant`(与实例页列表头同一个语言) |
|
||||
//! | 已装标记 | 无 | overline `Installed` + trailing `Check`(`Primary`) |
|
||||
//! | 搜索条 | 真实控件的 `search_bar`(56 高、药丸)+ 清空按钮 + 命中数 | [`Message::SetVersionQuery`] / [`Message::ClearVersionQuery`] |
|
||||
//! | 计数头 | `{count} versions`(`LabelMedium` + `OnSurfaceVariant`,与实例页列表头同一个语言) | 无 |
|
||||
//! | 列表 | `ListItem` + 三列短文本 + 尾随图标按钮 | [`Message::SelectVersion`] / [`Message::InstallVersion`] |
|
||||
//! | 面板 | 见 [`super::install`](M105 的安装选项区**不在这里**) | — |
|
||||
//!
|
||||
//! 行高从「手搓 56」变成 60 的理由与实例页完全一样:真 `ListItem` 自己的几何真源,页面不覆盖。
|
||||
//! ## 版本行为什么必须是**单行**
|
||||
//!
|
||||
//! ## 行距归零(M102)
|
||||
//! 任务硬约束:列表列最窄 424(行宽下限),此时「版本号 + 类型 + 日期 + 安装按钮」四样仍要在
|
||||
//! 一行里。所以:版本号那一格是 `ListItem`(它自己的 `Wrapping::None` 保证不折行,超长由外层
|
||||
//! clip 切掉),类型 / 日期 / 安装按钮三格是**定宽**([`super::super::layout::rails`] 的音阶)。
|
||||
//! 折行的唯一可能来源是定宽格子挤爆容器——那条由列表列下限 424 挡住(`layout/rails/tests.rs`
|
||||
//! 的扫描断言)。
|
||||
//!
|
||||
//! 原先页面在 `ListItem` 的 60/76 高之外又加了 `Space::X1`(4)的行距,行节奏因此与可视高度
|
||||
//! 除不尽——实例页复现成「末行被卡片底边切半行」(视觉复审 ②)。两页一起归零:行距只由组件的
|
||||
//! 几何真源决定(`ListItem` 自己的上下 8px 内边距已经够了),`Space` 里也没有 0 这一档,
|
||||
//! 所以 `list()` 里**不写** `.spacing(…)`。
|
||||
//! ## 搜索的三条口径
|
||||
//!
|
||||
//! | 情形 | 画什么 | 为什么 |
|
||||
//! |---|---|---|
|
||||
//! | 有命中 | 列表照常,侧栏**位置不动**,搜索条右侧显示「N 个匹配」 | 命中数是「我搜的词有货」的读数,不是分页读数 |
|
||||
//! | 一个都不命中(跨类型也没有) | [`super::states::no_match`]:一句「没有匹配」+ 一句「换短一点的片段」 | 白屏会让人以为列表坏了 |
|
||||
//! | 有命中但都不在当前类型 | [`super::states::elsewhere`]:说清「有 N 个在别的类型里」+ 一个「看全部类型」按钮 | 否则用户会以为自己搜错了 |
|
||||
//!
|
||||
//! ## 行距归零(M102,与本页的单行版本行同时成立)
|
||||
//!
|
||||
//! M102 把列表行距归零:原先页面在 `ListItem` 的可视高度之外又加了 `Space::X1`(4),行节奏
|
||||
//! 与可视高度除不尽,实例页复现成「末行被卡片底边切半行」(视觉复审 ②)。规则:行距只由
|
||||
//! 组件自己的几何决定,`Space` 里也没有 0 这一档 ⇒ `list()` 里**不写** `.spacing(…)`。
|
||||
//! 本页的行是 44 的单行档,同样不加行距;[`ROW_GAP`] 只用在**行内**那三个格子的间距上。
|
||||
|
||||
use iced::widget::{column, scrollable};
|
||||
use iced::{Element, Length, Padding};
|
||||
use iced::widget::{column, container, row, scrollable};
|
||||
use iced::{Alignment, Element, Length, Padding};
|
||||
|
||||
use unlucky_design::color::Role;
|
||||
use unlucky_design::icons::IconKind;
|
||||
@@ -26,65 +42,141 @@ use unlucky_design::typography::TypeRole;
|
||||
|
||||
use unlucky_i18n::{Catalog, Key};
|
||||
|
||||
use crate::app::components::buttons::{self, IconVariant};
|
||||
use crate::app::components::divider;
|
||||
use crate::app::components::inputs::{search_bar, SearchBar};
|
||||
use crate::app::components::navigation::list_item::{list_item, ListItem};
|
||||
use crate::app::components::navigation::tabs::{tabs, Tab, Tabs};
|
||||
use crate::theme::Ui;
|
||||
|
||||
use super::super::frame;
|
||||
use super::super::layout::rails::{LIST_DATE_WIDTH, LIST_KIND_WIDTH, ROW_GAP, ROW_ICON_BUTTON};
|
||||
use super::super::Message;
|
||||
use super::model::{Category, Download, VersionRow};
|
||||
use super::model::{Download, VersionRow};
|
||||
use super::{install, states};
|
||||
|
||||
/// 分类筛选(Primary Tabs:选中项下方 3px `Primary` 指示条)。
|
||||
///
|
||||
/// 点一个分类 = `Message::SetCategory`,由 app 层翻成 `AppCommand::ListVersions`——
|
||||
/// 页面**不**自己过滤列表(列表内容 = 快照里有什么),所以「点了但没反应」只会发生在
|
||||
/// presenter 没回列表的时候,不会出现「页面过滤过、后端还是旧分类」的两份真相。
|
||||
pub(super) fn categories<'a>(
|
||||
/// 列表列(三栏档与 chips 档共用一份)。
|
||||
pub(super) fn content<'a>(
|
||||
ui: &'a Ui,
|
||||
catalog: Catalog,
|
||||
current: Category,
|
||||
model: &'a Download,
|
||||
) -> Element<'a, Message> {
|
||||
let mut row = Tabs::primary();
|
||||
for category in Category::ALL {
|
||||
let mut tab = Tab::new(
|
||||
catalog.text(category.label_key()),
|
||||
Message::SetCategory(category),
|
||||
);
|
||||
if category == current {
|
||||
tab = tab.selected();
|
||||
}
|
||||
row = row.tab(tab);
|
||||
let visible = visible(model);
|
||||
|
||||
let body: Element<'a, Message> = if model.no_match() {
|
||||
states::no_match(ui, catalog)
|
||||
} else if model.only_elsewhere() {
|
||||
states::elsewhere(ui, catalog, model.hits)
|
||||
} else if visible.is_empty() {
|
||||
states::empty(ui, catalog)
|
||||
} else {
|
||||
list(ui, catalog, model, &visible)
|
||||
};
|
||||
|
||||
let tail: Element<'a, Message> = if model.install.is_some() {
|
||||
column![divider::full(ui), install::panel(ui, catalog, model)]
|
||||
.spacing(ui.space(Space::X3))
|
||||
.width(Length::Fill)
|
||||
.into()
|
||||
} else {
|
||||
install::panel(ui, catalog, model)
|
||||
};
|
||||
|
||||
column![
|
||||
search_row(ui, catalog, model),
|
||||
divider::full(ui),
|
||||
head(ui, catalog, visible.len()),
|
||||
body,
|
||||
tail,
|
||||
]
|
||||
.spacing(ui.space(Space::X2))
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill)
|
||||
.into()
|
||||
}
|
||||
|
||||
/// 搜索条 + 清空按钮 + 命中数(三者一行;命中数只在有查询词时出现)。
|
||||
pub(super) fn search_row<'a>(
|
||||
ui: &'a Ui,
|
||||
catalog: Catalog,
|
||||
model: &'a Download,
|
||||
) -> Element<'a, Message> {
|
||||
let bar = search_bar(
|
||||
ui,
|
||||
SearchBar::new(model.query.as_str())
|
||||
.placeholder(catalog.text(Key::VersionSearchPlaceholder)),
|
||||
Message::SetVersionQuery,
|
||||
);
|
||||
|
||||
let mut line = row![container(bar).width(Length::Fill)]
|
||||
.spacing(ui.space(Space::X2))
|
||||
.align_y(Alignment::Center)
|
||||
.width(Length::Fill);
|
||||
|
||||
if model.has_query() {
|
||||
// 命中数是**跨类型**的读数(`Download::hits`),所以它在「当前分类没命中」时也照画
|
||||
line = line.push(frame::text_role_owned(
|
||||
ui,
|
||||
TypeRole::LabelMedium,
|
||||
false,
|
||||
catalog.render(
|
||||
Key::VersionSearchHits,
|
||||
&[("count", &model.hits.to_string())],
|
||||
),
|
||||
ui.color(Role::OnSurfaceVariant),
|
||||
));
|
||||
line = line.push(buttons::icon(
|
||||
ui,
|
||||
IconVariant::Standard,
|
||||
IconKind::Close,
|
||||
Some(Message::ClearVersionQuery),
|
||||
None,
|
||||
));
|
||||
}
|
||||
tabs(ui, row)
|
||||
|
||||
line.into()
|
||||
}
|
||||
|
||||
/// 计数头(`LabelMedium`):一行 = 一个 key,数字走占位符,不拼片段。
|
||||
pub(super) fn head<'a>(ui: &'a Ui, catalog: Catalog, model: &Download) -> Element<'a, Message> {
|
||||
pub(super) fn head<'a>(ui: &'a Ui, catalog: Catalog, count: usize) -> Element<'a, Message> {
|
||||
frame::text_role_owned(
|
||||
ui,
|
||||
TypeRole::LabelMedium,
|
||||
false,
|
||||
catalog.render(
|
||||
Key::DownloadCount,
|
||||
&[("count", &model.rows.len().to_string())],
|
||||
),
|
||||
catalog.render(Key::DownloadCount, &[("count", &count.to_string())]),
|
||||
ui.color(Role::OnSurfaceVariant),
|
||||
)
|
||||
}
|
||||
|
||||
/// 版本行列表(**独立滚动**:只有列表滚,安装面板钉在底部不跟着滚)。
|
||||
pub(super) fn list<'a>(ui: &'a Ui, catalog: Catalog, model: &'a Download) -> Element<'a, Message> {
|
||||
let rows = column(
|
||||
model
|
||||
/// 当前可见的行:先按分类(模型已过滤),再按第二级侧栏选中的主版本桶。
|
||||
fn visible(model: &Download) -> Vec<&VersionRow> {
|
||||
match model.selected_major.as_deref() {
|
||||
None => model.rows.iter().collect(),
|
||||
Some(key) => model
|
||||
.rows
|
||||
.iter()
|
||||
.filter(|entry| entry.major().as_deref() == Some(key))
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 版本行列表(**独立滚动**:只有列表滚,安装面板钉在底部不跟着滚)。
|
||||
fn list<'a>(
|
||||
ui: &'a Ui,
|
||||
catalog: Catalog,
|
||||
model: &'a Download,
|
||||
visible: &[&'a VersionRow],
|
||||
) -> Element<'a, Message> {
|
||||
let entries = column(
|
||||
visible
|
||||
.iter()
|
||||
.map(|entry| version_row(ui, catalog, model, entry))
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
// M102:行距归零(见模块文档),只留横向留白
|
||||
.padding(Padding::from([0.0, ui.space(Space::X2)]))
|
||||
.width(Length::Fill);
|
||||
|
||||
scrollable(rows)
|
||||
scrollable(entries)
|
||||
.direction(scrollable::Direction::Vertical(
|
||||
scrollable::Scrollbar::new()
|
||||
.width(frame::SCROLLBAR_WIDTH)
|
||||
@@ -96,11 +188,10 @@ pub(super) fn list<'a>(ui: &'a Ui, catalog: Catalog, model: &'a Download) -> Ele
|
||||
.into()
|
||||
}
|
||||
|
||||
/// 一行:headline = 版本 id,supporting = `正式版 · 发布于 2026-09-20`,已装给勾。
|
||||
/// 一行:`ListItem`(版本号,可点选)+ 类型 + 日期 + 安装图标按钮(**四样同一行**)。
|
||||
///
|
||||
/// 点一行 = `Message::SelectVersion(id)`(**页面内选中**,不 dispatch);「安装」是旁边那个
|
||||
/// 独立按钮,点它才派 `AppCommand::InstallVersion`。选中不做乐观回显:行的高亮来自模型,
|
||||
/// 模型在 app 层被改写之后才变。
|
||||
/// 点版本号 = [`Message::SelectVersion`](页面内选中,不 dispatch);点尾随按钮 =
|
||||
/// [`Message::InstallVersion`]。已装的版本给勾 + 禁用按钮(不重复安装)。
|
||||
fn version_row<'a>(
|
||||
ui: &'a Ui,
|
||||
catalog: Catalog,
|
||||
@@ -108,17 +199,70 @@ fn version_row<'a>(
|
||||
entry: &'a VersionRow,
|
||||
) -> Element<'a, Message> {
|
||||
let mut item = ListItem::new(entry.id.as_str(), Message::SelectVersion(entry.id.clone()))
|
||||
.supporting(entry.supporting.as_str())
|
||||
.leading(IconKind::Download);
|
||||
|
||||
if entry.installed {
|
||||
item = item
|
||||
.overline(catalog.text(Key::DownloadInstalled))
|
||||
.trailing(IconKind::Check);
|
||||
item = item.trailing(IconKind::Check);
|
||||
}
|
||||
if model.selected.as_deref() == Some(entry.id.as_str()) {
|
||||
item = item.selected();
|
||||
}
|
||||
|
||||
list_item(ui, item)
|
||||
// 版本号那一格吃掉剩下的宽(`ListItem` 自己是 `Wrapping::None`:再长也**不折行**,
|
||||
// 由外层 clip 切掉)。定宽的三格因此永远落在右边,行宽下限 424 保证它们不会被挤出容器。
|
||||
row![
|
||||
container(list_item(ui, item)).width(Length::Fill),
|
||||
cell(
|
||||
ui,
|
||||
catalog.text(super::model::kind_label_key(entry.kind)),
|
||||
LIST_KIND_WIDTH
|
||||
),
|
||||
cell(ui, entry.released.as_str(), LIST_DATE_WIDTH),
|
||||
install_action(ui, entry),
|
||||
]
|
||||
.spacing(ui.space(ROW_GAP))
|
||||
.align_y(Alignment::Center)
|
||||
.width(Length::Fill)
|
||||
.into()
|
||||
}
|
||||
|
||||
/// 一格定宽文本(`LabelMedium` + `OnSurfaceVariant`;单行,超长由外层 clip)。
|
||||
fn cell<'a>(ui: &'a Ui, text: &'a str, width: f32) -> Element<'a, Message> {
|
||||
container(frame::text_role(
|
||||
ui,
|
||||
TypeRole::LabelMedium,
|
||||
false,
|
||||
text,
|
||||
ui.color(Role::OnSurfaceVariant),
|
||||
))
|
||||
.width(Length::Fixed(width))
|
||||
.into()
|
||||
}
|
||||
|
||||
/// 行尾的安装按钮:已装 → `Check`(`Primary`);未装 → `Download` 图标按钮(命中区 40/48)。
|
||||
///
|
||||
/// 「本页唯一实心 accent」留在底部面板那个带文字的「安装」按钮上(rubric K2),行内用标准图标按钮:
|
||||
/// 一行一个实心药丸会让列表变成一排强调色。
|
||||
fn install_action<'a>(ui: &'a Ui, entry: &'a VersionRow) -> Element<'a, Message> {
|
||||
if entry.installed {
|
||||
return container(frame::glyph(
|
||||
IconKind::Check,
|
||||
ui.color(Role::Primary),
|
||||
frame::ICON_PX,
|
||||
))
|
||||
.width(Length::Fixed(ROW_ICON_BUTTON))
|
||||
.align_x(Alignment::Center)
|
||||
.into();
|
||||
}
|
||||
|
||||
container(buttons::icon(
|
||||
ui,
|
||||
IconVariant::Standard,
|
||||
IconKind::Download,
|
||||
Some(Message::InstallVersion(entry.id.clone())),
|
||||
None,
|
||||
))
|
||||
.width(Length::Fixed(ROW_ICON_BUTTON))
|
||||
.align_x(Alignment::Center)
|
||||
.into()
|
||||
}
|
||||
Reference in new issue
Block a user