Files
Unlucky-Launcher/crates/app-iced/src/shell/nav.rs
T
AskaEth 1ff00bbe50 feat(app-iced): M114 账户页三轨登录(微软正版 / 第三方皮肤站 / 本地档案)
- 账户页:当前账户卡 + 全部账户列表 + 空态三入口 + 皮肤区;五项动作(添加/切换/删除/重新登录/校验)
- 三轨添加流程:设备码(用户码+链接+倒计时+过期重试+取消)、授权码(浏览器+可复制链接+取消)、
  本地档案(名字校验)、第三方站(服务地址 + 服务发现校验 + 密码登录,密码掩码且用完即弃)
- 导航:账户为一等页,入口是轨底头像位(未登录=描边剪影 / 已登录=首字母块+名字;窄高收成图标条)
- 凭据红线:DTO 侧形状脱敏 + 状态层二次脱敏 + 密码掩码;Inject-JWT 测试断言视图模型干净(已做变异取证)
- presenter:authlib 登录/服务发现/皮肤抓取(复用下载引擎的 HTTP 客户端,无新增依赖)、
  皮肤原图经 DTO 到 UI 裁剪成头像(既有 image 依赖,未改 Cargo.toml)
- i18n:新增 account 域 51 条中英文案(离线档案口径全量写入),金表同步
2026-11-05 05:08:35 +08:00

336 lines
12 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 左侧导航轨(M3 `NavigationRail`)。
//!
//! 判据来源 `research/12b-component-fidelity.md` §5:
//!
//! | 项 | 参考值 | 令牌落点 |
//! |---|---|---|
//! | 轨宽 | 5.1 官方 96(narrow 80);本项目 **88** | [`nav_rail_width`](`LayoutMetrics` 骨架值,详见该函数文档的 M69 迁移说明) |
//! | 轨容器 | 5.3 形状 None / L0 / `Surface` | `MaterialLayer::Canvas` |
//! | 选中指示器 | 5.4 **56×32**、圆角 full、`SecondaryContainer` | [`indicator_size`](令牌推导) |
//! | 图标 | 5.5 24px | `IconSize::Toolbar` |
//! | 标签 | 5.5 `LabelMedium 12/16` | `TypeRole::LabelMedium` |
//! | 颜色 | 5.6 选中图标 `OnSecondaryContainer` / 选中标签 `Secondary` / 未选中 `OnSurfaceVariant` | `Ui::color` |
//! | 项间距 | 5.7 **4** | `LayoutMetrics::nav_item_gap` |
use iced::widget::{button, column, container};
use iced::{Alignment, Background, Border, Element, Length, Padding, Shadow, Theme as IcedTheme};
use unlucky_design::color::{Role, StateLayer};
use unlucky_design::elevation::MaterialLayer;
use unlucky_design::icons::IconKind;
use unlucky_design::shape::ShapeScale;
use unlucky_design::spacing::{LayoutMetrics, Space};
use unlucky_design::typography::TypeRole;
use unlucky_i18n::{Key, Lang};
mod account;
pub use account::RailAccount;
use account::account_item;
use crate::app::Message;
use crate::cli::ForcedState;
use crate::icons;
use crate::shell::{filled, label, overridden};
use crate::theme::Ui;
/// 导航占位项(任务要求 4~6 个;真正的页面由后续 mission 接管)。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum NavItem {
/// 实例列表。
Instances,
/// 版本管理(显示名;变体名按用户令「只改显示层」不改,标识符改名后置)。
Download,
/// 日志。
Logs,
/// 设置。
Settings,
/// AI 助手。
Ai,
/// 账户页(**不进主序列**:入口是轨底的头像位,见 [`RailAccount`])。
Account,
}
impl NavItem {
/// 全部**主序列**项(顺序 = 导航栏自上而下)。账户不在其中:它固定贴在轨底。
pub const ALL: [NavItem; 5] = [
NavItem::Instances,
NavItem::Download,
NavItem::Logs,
NavItem::Settings,
NavItem::Ai,
];
/// 显示名的 key(M106 起走 catalog;两套目录都有词由 `catalog` 的完整性测试守)。
pub const fn label_key(self) -> Key {
match self {
NavItem::Instances => Key::NavInstances,
NavItem::Download => Key::NavDownload,
NavItem::Logs => Key::NavLogs,
NavItem::Settings => Key::NavSettings,
NavItem::Ai => Key::NavAi,
// M114:轨底头像位的可访问名(不在主序列里,但 `router` 的 `nav()` 会问到它)。
NavItem::Account => Key::AccountTitle,
}
}
/// 显示名(按当前界面语言)。
///
/// 为什么带 `lang` 而不是读全局:这个函数要被单测与 harness 调,全局状态会让「同一份代码在
/// 不同语言下画什么」不可断言。
pub fn label(self, lang: Lang) -> &'static str {
lang.catalog().text(self.label_key())
}
/// 令牌图标。
pub const fn icon(self) -> IconKind {
match self {
NavItem::Instances => IconKind::Instances,
NavItem::Download => IconKind::Download,
NavItem::Logs => IconKind::Logs,
NavItem::Settings => IconKind::Settings,
NavItem::Ai => IconKind::Ai,
NavItem::Account => IconKind::User,
}
}
}
/// 整条导航轨。
pub(super) fn rail<'a>(
ui: &'a Ui,
selected: NavItem,
account: &RailAccount,
forced: Option<ForcedState>,
lang: Lang,
) -> Element<'a, Message> {
let metrics = ui.metrics();
let indicator = indicator_size(metrics);
let compact = account.compact;
let items = NavItem::ALL
.iter()
// `ALL` 本来就不含 `Account`(它固定在轨底),filter 只是把这条意图写明。
.filter(|item| **item != NavItem::Account)
.map(|item| rail_item(ui, *item, *item == selected, indicator, compact, forced, lang))
.collect::<Vec<_>>();
// 轨顶留白走 container padding,而不是「先塞一个 spacer 元素」:后者会被 column 的
// spacing(4)再加一档,实测变 28(视觉复审 P3-5)。
//
// 轨底色与其它面板同源(M93):`ui.layer` 已含面板 α,导航轨是面板层的一部分。
//
// 账户头像位**固定贴在轨底**(M114):主序列四项在上,中间一段 Fill 把头像顶到底部——
// 于是「账户」不是第 6 个导航项,轨的长度也不会随页数增长。
container(
column![
column(items).spacing(metrics.nav_item_gap),
iced::widget::space::vertical(),
account_item(ui, selected, account, indicator, forced),
]
.height(Length::Fill),
)
.width(nav_rail_width(metrics))
.height(Length::Fill)
.padding(Padding::from([ui.space(Space::X6), 0.0]))
.style(move |_theme| filled(ui.layer(MaterialLayer::Canvas)))
.into()
}
fn rail_item<'a>(
ui: &'a Ui,
item: NavItem,
selected: bool,
indicator: (f32, f32),
compact: bool,
forced: Option<ForcedState>,
lang: Lang,
) -> Element<'a, Message> {
let (indicator_width, indicator_height) = indicator;
let icon_color = if selected {
ui.color(Role::OnSecondaryContainer)
} else {
ui.color(Role::OnSurfaceVariant)
};
// 12b 5.6:选中标签用 `Secondary`,未选中 `OnSurfaceVariant`
let label_color = if selected {
ui.color(Role::Secondary)
} else {
ui.color(Role::OnSurfaceVariant)
};
// **不要用 `center_x/center_y(Fill)`**:那会把容器自己的 width/height 改成 Fill,
// 于是 56 被父级(button 宽 88)覆盖成 88、32 靠布局凑巧(视觉复审 P2-1)。
// 尺寸只认令牌推导值,居中交给父 column 的 `align_x`。
let pill = container(icons::view::<Message>(item.icon(), icon_color))
.width(indicator_width)
.height(indicator_height)
.align_x(Alignment::Center)
.align_y(Alignment::Center)
.style(move |_theme| container::Style {
background: selected.then(|| Background::Color(ui.color(Role::SecondaryContainer))),
border: Border {
radius: ui.radius(ShapeScale::Full, indicator_height),
..Border::default()
},
..container::Style::default()
});
let item_height = nav_item_height(ui, indicator_height);
let height = if compact {
indicator_height
} else {
item_height
};
// 列必须显式占满轨宽,否则它会 shrink 到 56,`align_x(Center)` 就变成空操作,
// 药丸会贴左缘(x=0..55 而不是 16..71)——r1 实测踩到的第二个坑。
let mut stack = column![pill]
.width(nav_rail_width(ui.metrics()))
.align_x(Alignment::Center)
.height(height);
if !compact {
// M114 的「窄高收成图标条」+ M106 的「按语言取词」并存。
stack = stack.push(label(
ui,
TypeRole::LabelMedium,
false,
item.label(lang),
label_color,
));
}
stack = stack.spacing(ui.space(Space::X1));
button(stack)
.width(nav_rail_width(ui.metrics()))
.height(height)
.padding(0)
.style(move |_theme: &IcedTheme, status| {
let background = match overridden(forced, status) {
button::Status::Hovered if !selected => Some(Background::Color(ui.state_over(
StateLayer::Hover,
Role::OnSurface,
Role::Surface,
))),
button::Status::Pressed if !selected => Some(Background::Color(ui.state_over(
StateLayer::Pressed,
Role::OnSurface,
Role::Surface,
))),
_ => None,
};
button::Style {
background,
text_color: label_color,
border: Border::default(),
shadow: Shadow::default(),
snap: true,
}
})
.on_press(Message::Select(item))
.into()
}
/// 导航轨宽度(M69 起是令牌层的 `nav_rail_width`)。
///
/// M69(`141e9b1`)把 `LayoutMetrics` 的 nav 字段做了语义纠正:
///
/// | 旧 | 新 |
/// |---|---|
/// | `nav_expanded_width = 88` | **`nav_rail_width = 88`**(值不变,名字从「展开」改成「轨」) |
/// | `nav_collapsed_width = 48` | 删除(48 连 56 宽的选中指示器都放不下) |
/// | —(缺) | `nav_drawer_width = 240`(D33 的展开态抽屉,本 crate 未用) |
///
/// 三处调用点都走这里:将来令牌再改名,仍然只改这一行。
fn nav_rail_width(metrics: LayoutMetrics) -> f32 {
metrics.nav_rail_width
}
/// 选中指示器尺寸(12b 5.4 参考 56×32),全部由令牌推导、不写死:
///
/// - 宽 = [`nav_rail_width`] − 2 × `Space::X4` = 88 − 32 = **56**(轨的左右内边距 16)
/// - 高 = `control_height − Space::X2` = 40 − 8 = **32**(可见高度上下各让 4)
pub fn indicator_size(metrics: LayoutMetrics) -> (f32, f32) {
(
nav_rail_width(metrics) - Space::X4.dp() * 2.0,
metrics.control_height - Space::X2.dp(),
)
}
/// 导航项高度 = 指示器 + 4 + 标签行高 = 32 + 4 + 16 = 52(`LM.nav_item_height` 是列表行节奏,
/// 药丸 + 标签的轨项放不进 44,偏差登记在交接文档里)。
pub fn nav_item_height(ui: &Ui, indicator_height: f32) -> f32 {
indicator_height + ui.space(Space::X1) + ui.style(TypeRole::LabelMedium).line_height_px
}
#[cfg(test)]
mod tests {
use super::*;
use unlucky_design::color::SchemeFlavor;
use unlucky_design::typography::Platform;
use crate::theme::Families;
fn ui() -> Ui {
Ui::with_platform(
SchemeFlavor::Light,
Platform::Windows,
Families {
text: "Segoe UI",
display: "Segoe UI",
mono: "Consolas",
},
)
}
#[test]
fn indicator_matches_the_reference_56x32() {
let (width, height) = indicator_size(LayoutMetrics::DESKTOP);
assert_eq!((width, height), (56.0, 32.0), "12b 5.4 的参考值");
}
#[test]
fn nav_item_height_is_indicator_plus_gap_plus_label() {
let ui = ui();
let (_, indicator_height) = indicator_size(ui.metrics());
// 32 + 4 + 16
assert_eq!(nav_item_height(&ui, indicator_height), 52.0);
}
#[test]
fn every_nav_item_has_a_label_and_an_icon() {
assert!((4..=6).contains(&NavItem::ALL.len()));
for item in NavItem::ALL {
assert!(!item.label(Lang::En).is_empty(), "{item:?} 没有文案");
assert!(!item.icon().icon().path.is_empty(), "{item:?} 没有图标");
}
}
/// **M108**:导航栏上这一项叫 `Versions`(88px 轨放不下全名 `Version management`)。
///
/// 文案本身走目录(M106 的 `label_key()` + `label(lang)`),这条只守**取值**与**宽度余量**:
///
/// - **抓得住**:① 有人把 `Key::NavDownload` 的英文改回「下载」或塞回 18 字符的全名
/// (88px 轨上会截断/换行,实测全名 ≈101px);② 中文被「为了对称」砍成两个字。
/// - **抓不住**:真画出来的截断(那要出图,见 M108 报告里的产品通道与 pages_demo 图);
/// 也抓不住 240 展开抽屉(那一档在产品里还不可达)。
#[test]
fn the_download_item_is_labelled_version_management() {
// 88px 轨的余量:中文 4 字 ≈48px、英文短名 ≈45px,都该有明显的余量
assert_eq!(NavItem::Download.label(Lang::En), "Versions");
assert_eq!(NavItem::Download.label(Lang::ZhCn), "版本管理");
for item in NavItem::ALL {
let label = item.label(Lang::En);
assert!(
!label.to_ascii_lowercase().contains("download"),
"{item:?} 的英文标签还在说下载:{label}"
);
assert!(
label.chars().count() <= 12,
"{item:?} 的标签太长,88px 的轨放不下:{label}"
);
}
}
}