feat(app-iced): M114 账户页三轨登录(微软正版 / 第三方皮肤站 / 本地档案)
- 账户页:当前账户卡 + 全部账户列表 + 空态三入口 + 皮肤区;五项动作(添加/切换/删除/重新登录/校验) - 三轨添加流程:设备码(用户码+链接+倒计时+过期重试+取消)、授权码(浏览器+可复制链接+取消)、 本地档案(名字校验)、第三方站(服务地址 + 服务发现校验 + 密码登录,密码掩码且用完即弃) - 导航:账户为一等页,入口是轨底头像位(未登录=描边剪影 / 已登录=首字母块+名字;窄高收成图标条) - 凭据红线:DTO 侧形状脱敏 + 状态层二次脱敏 + 密码掩码;Inject-JWT 测试断言视图模型干净(已做变异取证) - presenter:authlib 登录/服务发现/皮肤抓取(复用下载引擎的 HTTP 客户端,无新增依赖)、 皮肤原图经 DTO 到 UI 裁剪成头像(既有 image 依赖,未改 Cargo.toml) - i18n:新增 account 域 51 条中英文案(离线档案口径全量写入),金表同步
This commit is contained in:
1 parent
e7b04c4afe
commit
1ff00bbe50
43 files changed
+4343
-117
No files matched your search
@@ -21,13 +21,14 @@ mod nav;
|
||||
/// 用 `#[path]` 挂到外壳的命名空间。
|
||||
///
|
||||
/// 为什么是 `#[path]` 而不是在 `pages/mod.rs` 加一行 `mod agent;`:`pages/mod.rs` 是四条页面线
|
||||
/// 共用的声明文件(本 mission 的 scope 没给它),而右栗是**外壳的一部分**(它挂在任何页面右边)。
|
||||
/// 共用的声明文件(M104 的 scope 没给它),而右栏是**外壳的一部分**(它挂在任何页面右边)。
|
||||
/// 先例是 `app/mod.rs` 挂 `../components/mod.rs`。物理路径只有一份,逻辑名字有两个用途:
|
||||
/// 宿主(右栗)归外壳,视图(会话组件)归页面目录。
|
||||
/// 宿主(右栏)归外壳,视图(会话组件)归页面目录。
|
||||
#[path = "../pages/agent/mod.rs"]
|
||||
pub(crate) mod agent;
|
||||
|
||||
pub use nav::NavItem;
|
||||
// M114:轨底账户头像位的状态也归外壳(`NavItem` + `RailAccount` 一起导出)。
|
||||
pub use nav::{NavItem, RailAccount};
|
||||
|
||||
use iced::widget::text::LineHeight;
|
||||
use iced::widget::{button, column, container, mouse_area, row, space, stack, text};
|
||||
@@ -47,17 +48,34 @@ use crate::cli::ForcedState;
|
||||
use crate::icons;
|
||||
use crate::theme::Ui;
|
||||
|
||||
/// 整棵树(**内容槽位版**):外壳画标题栏 / 缩放热区 / 导航轨 / 右栗,中间那块交给调用方。
|
||||
/// 整棵树(**内容槽位版**):外壳画标题栏 / 缩放热区 / 导航轨 / 右栏,中间那块交给调用方。
|
||||
///
|
||||
/// 产品页的唯一入口(M90 r2 / P1-1;M104 起多两样东西:右侧助手栏与拖拽取指针层;
|
||||
/// M114 起导航轨还会画轨底的账户头像位)。
|
||||
///
|
||||
/// 分工写死:
|
||||
///
|
||||
/// - 本模块负责:标题栏(拖动面 = **整条 48px 栏里从窗口左缘到第一个 caption** 的 `mouse_area`,
|
||||
/// 没有单独的 8px 热区;三个 caption 键各 46×48)、东/南两条缩放热区(各 `Space::X1` = 4px)、
|
||||
/// 导航轨、右栏、根容器底色;
|
||||
/// - 调用方负责:`content`(页面内容区)。它拿到的可用宽度是 `窗口宽 − rail 88 − 右栏`,
|
||||
/// 而页面自己的 `layout::frame_metrics` 仍以**中栏可用宽**为输入(M104 的 `content_width`)。
|
||||
///
|
||||
/// 面板 α(D36 / M93)不从这里传:它是**表面色**的属性,唯一施加点是 `Ui::layer`
|
||||
/// (`crate::theme`)。外壳的根容器 / 标题栏 / 导航轨都走 `ui.layer(..)`,因此自动同源。
|
||||
///
|
||||
/// 产品页入口:**内容槽位 + 背景层 + 右栏 + 拖拽层 + 轨底账户头像位**。
|
||||
///
|
||||
/// `background` 由 `pages::appearance::layer` 产出(已与 `Message::Page` 映射过);垫在 `stack!`
|
||||
/// 最底层,于是「面板 α < 1」时能真的看见它——这就是 D34 在像素上的落点。
|
||||
// 9 个形参:M104 的 `right`/`overlay` 之后 M106 又加了 `lang`(导航轨标签要按语言取词)。
|
||||
// 拆成结构体要动 `app/view.rs` 的两处调用点,而那正是 M104/M105 反复设锚点的地方——
|
||||
// 等 M105 那轮 rebase(`draft` 与 `lang` 两个形参都要留)时一并结构体化,先就地 `allow`。
|
||||
// 10 个形参:M104 的 `right`/`overlay`、M106 的 `lang`(导航轨标签按语言取词)、
|
||||
// M114 的 `account`(轨底账户头像位状态)。拆成结构体要动 `app/view.rs` / `account_page.rs`
|
||||
// 的调用点,而那正是 M104/M105/M109 反复设锚点的地方——先就地 `allow`,等下次合流再结构体化。
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn view_with_background<'a>(
|
||||
ui: &'a Ui,
|
||||
selected: NavItem,
|
||||
account: &nav::RailAccount,
|
||||
app_title: &'a str,
|
||||
background: Option<Element<'a, Message>>,
|
||||
content: Element<'a, Message>,
|
||||
@@ -66,10 +84,11 @@ pub fn view_with_background<'a>(
|
||||
forced: Option<ForcedState>,
|
||||
lang: Lang,
|
||||
) -> Element<'a, Message> {
|
||||
// 右栗(M104)是 `row` 的**第三个兄弟**而不是叠层:它是布局宽度的一部分,压在上面会让
|
||||
// 中栗以为自己还有那么宽(那就是任务里禁止的「溢出」)。它的宽度由 `agent::docked` 自己声明。
|
||||
// 右栏(M104)是 `row` 的**第三个兄弟**而不是叠层:它是布局宽度的一部分,压在上面会让
|
||||
// 中栏以为自己还有那么宽(那就是任务里禁止的「溢出」)。它的宽度由 `agent::docked` 自己声明。
|
||||
// M106:导航轨标签按当前界面语言取词(`lang` 一路从 `App::lang()` 递进来)。
|
||||
let mut work = row![nav::rail(ui, selected, forced, lang), content]
|
||||
// M114:`account` 只影响轨底那一格,`right`/`overlay` 的语义一字未动。
|
||||
let mut work = row![nav::rail(ui, selected, account, forced, lang), content]
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill);
|
||||
if let Some(right) = right {
|
||||
|
||||
@@ -23,6 +23,12 @@ 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;
|
||||
@@ -42,10 +48,12 @@ pub enum NavItem {
|
||||
Settings,
|
||||
/// AI 助手。
|
||||
Ai,
|
||||
/// 账户页(**不进主序列**:入口是轨底的头像位,见 [`RailAccount`])。
|
||||
Account,
|
||||
}
|
||||
|
||||
impl NavItem {
|
||||
/// 全部占位项(顺序 = 导航栏自上而下)。
|
||||
/// 全部**主序列**项(顺序 = 导航栏自上而下)。账户不在其中:它固定贴在轨底。
|
||||
pub const ALL: [NavItem; 5] = [
|
||||
NavItem::Instances,
|
||||
NavItem::Download,
|
||||
@@ -62,6 +70,8 @@ impl NavItem {
|
||||
NavItem::Logs => Key::NavLogs,
|
||||
NavItem::Settings => Key::NavSettings,
|
||||
NavItem::Ai => Key::NavAi,
|
||||
// M114:轨底头像位的可访问名(不在主序列里,但 `router` 的 `nav()` 会问到它)。
|
||||
NavItem::Account => Key::AccountTitle,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,6 +91,7 @@ impl NavItem {
|
||||
NavItem::Logs => IconKind::Logs,
|
||||
NavItem::Settings => IconKind::Settings,
|
||||
NavItem::Ai => IconKind::Ai,
|
||||
NavItem::Account => IconKind::User,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -89,27 +100,41 @@ impl NavItem {
|
||||
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()
|
||||
.map(|item| rail_item(ui, *item, *item == selected, indicator, forced, lang))
|
||||
// `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` 已含面板 α,导航轨是面板层的一部分。
|
||||
container(column(items).spacing(metrics.nav_item_gap))
|
||||
.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()
|
||||
//
|
||||
// 账户头像位**固定贴在轨底**(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>(
|
||||
@@ -117,6 +142,7 @@ fn rail_item<'a>(
|
||||
item: NavItem,
|
||||
selected: bool,
|
||||
indicator: (f32, f32),
|
||||
compact: bool,
|
||||
forced: Option<ForcedState>,
|
||||
lang: Lang,
|
||||
) -> Element<'a, Message> {
|
||||
@@ -152,26 +178,32 @@ fn rail_item<'a>(
|
||||
});
|
||||
|
||||
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 stack = column![
|
||||
pill,
|
||||
label(
|
||||
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
|
||||
),
|
||||
]
|
||||
.spacing(ui.space(Space::X1))
|
||||
.width(nav_rail_width(ui.metrics()))
|
||||
.align_x(Alignment::Center)
|
||||
.height(item_height);
|
||||
label_color,
|
||||
));
|
||||
}
|
||||
stack = stack.spacing(ui.space(Space::X1));
|
||||
|
||||
button(stack)
|
||||
.width(nav_rail_width(ui.metrics()))
|
||||
.height(item_height)
|
||||
.height(height)
|
||||
.padding(0)
|
||||
.style(move |_theme: &IcedTheme, status| {
|
||||
let background = match overridden(forced, status) {
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
//! 导航轨**底部账户头像位**(M114):状态结构体 + 那个按钮。
|
||||
//!
|
||||
//! 为什么单独一个文件:`shell/nav.rs` 贴着 400 行硬线,而这块是 M114 新增的一整块
|
||||
//! (未登录/已登录两态、窄高收成图标条的判据、点击跳账户页)。
|
||||
|
||||
use iced::widget::{button, column, container, text};
|
||||
use iced::{Alignment, Background, Border, Element, Length, Shadow, Theme as IcedTheme};
|
||||
|
||||
use unlucky_design::color::{Role, StateLayer};
|
||||
use unlucky_design::icons::IconKind;
|
||||
use unlucky_design::shape::ShapeScale;
|
||||
use unlucky_design::spacing::Space;
|
||||
use unlucky_design::typography::TypeRole;
|
||||
|
||||
use crate::app::Message;
|
||||
use crate::cli::ForcedState;
|
||||
use crate::icons;
|
||||
use crate::shell::overridden;
|
||||
use crate::theme::Ui;
|
||||
|
||||
use super::{nav_item_height, nav_rail_width, NavItem};
|
||||
|
||||
/// 导航轨底部的**账户头像位**状态(M114)。
|
||||
///
|
||||
/// 为什么单独传而不是塞进 `NavItem`:它不是主序列里的第 6 项(那会让轨道越堆越长),
|
||||
/// 而是固定在**底部**的一个头像位;未登录/已登录两态要在**形状上**就不同(描边剪影 vs 首字母块)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct RailAccount {
|
||||
/// 是否已登录(至少有一个账户)
|
||||
pub signed_in: bool,
|
||||
/// 首字母块的字(已登录时有意义)
|
||||
pub initial: String,
|
||||
/// 账户名(已登录时作为标签;未登录时是 i18n 的「未登录」)
|
||||
pub label: String,
|
||||
/// 窄高度/窄宽下的图标条形态(隐藏文字标签,只留头像位)
|
||||
pub compact: bool,
|
||||
}
|
||||
|
||||
impl RailAccount {
|
||||
/// 未登录态。
|
||||
pub fn signed_out(label: impl Into<String>) -> Self {
|
||||
Self {
|
||||
signed_in: false,
|
||||
initial: String::new(),
|
||||
label: label.into(),
|
||||
compact: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// 已登录态。
|
||||
pub fn signed_in(initial: impl Into<String>, label: impl Into<String>) -> Self {
|
||||
Self {
|
||||
signed_in: true,
|
||||
initial: initial.into(),
|
||||
label: label.into(),
|
||||
compact: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// 图标条形态(导航项太多/太窄时收起文字标签)。
|
||||
#[must_use]
|
||||
pub fn compact(mut self, compact: bool) -> Self {
|
||||
self.compact = compact;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// 轨底的账户头像位:未登录 = 描边剪影,已登录 = 首字母块(两态在**形状**上就不同)。
|
||||
pub(super) fn account_item<'a>(
|
||||
ui: &'a Ui,
|
||||
selected: NavItem,
|
||||
account: &RailAccount,
|
||||
indicator: (f32, f32),
|
||||
forced: Option<ForcedState>,
|
||||
) -> Element<'a, Message> {
|
||||
let (indicator_width, indicator_height) = indicator;
|
||||
let active = selected == NavItem::Account;
|
||||
let label_color = if active {
|
||||
ui.color(Role::Secondary)
|
||||
} else {
|
||||
ui.color(Role::OnSurfaceVariant)
|
||||
};
|
||||
|
||||
let avatar: Element<'a, Message> = if account.signed_in {
|
||||
let style = ui.emphasized(TypeRole::TitleMedium);
|
||||
container(
|
||||
text(account.initial.clone())
|
||||
.size(style.size_px)
|
||||
.line_height(iced::widget::text::LineHeight::Absolute(iced::Pixels(
|
||||
style.line_height_px,
|
||||
)))
|
||||
.font(ui.font_of(style))
|
||||
.color(ui.color(Role::OnSecondaryContainer)),
|
||||
)
|
||||
.width(Length::Fixed(avatar_size(ui, indicator_width)))
|
||||
.height(Length::Fixed(avatar_size(ui, indicator_width)))
|
||||
.align_x(Alignment::Center)
|
||||
.align_y(Alignment::Center)
|
||||
.style(move |_theme| container::Style {
|
||||
background: Some(Background::Color(ui.color(Role::SecondaryContainer))),
|
||||
border: Border {
|
||||
radius: ui.radius(ShapeScale::Full, avatar_size(ui, indicator_width)),
|
||||
..Border::default()
|
||||
},
|
||||
..container::Style::default()
|
||||
})
|
||||
.into()
|
||||
} else {
|
||||
container(icons::view::<Message>(
|
||||
IconKind::User,
|
||||
ui.color(Role::OnSurfaceVariant),
|
||||
))
|
||||
.width(Length::Fixed(avatar_size(ui, indicator_width)))
|
||||
.height(Length::Fixed(avatar_size(ui, indicator_width)))
|
||||
.align_x(Alignment::Center)
|
||||
.align_y(Alignment::Center)
|
||||
.style(move |_theme| container::Style {
|
||||
border: Border {
|
||||
color: ui.color(Role::Outline),
|
||||
width: 1.0,
|
||||
radius: ui.radius(ShapeScale::Full, avatar_size(ui, indicator_width)),
|
||||
},
|
||||
..container::Style::default()
|
||||
})
|
||||
.into()
|
||||
};
|
||||
|
||||
let height = nav_item_height(ui, indicator_height);
|
||||
let mut stack = column![avatar]
|
||||
.spacing(ui.space(Space::X1))
|
||||
.width(nav_rail_width(ui.metrics()))
|
||||
.align_x(Alignment::Center);
|
||||
if !account.compact {
|
||||
// 用**拥有的**字符串画标签:`RailAccount` 是调用方临时算出来的状态,
|
||||
// 借它的 `&str` 会让整棵树跟着那个临时值一起活(E0515)。
|
||||
let style = ui.style(TypeRole::LabelSmall);
|
||||
stack = stack.push(
|
||||
text(account.label.clone())
|
||||
.size(style.size_px)
|
||||
.line_height(iced::widget::text::LineHeight::Absolute(iced::Pixels(
|
||||
style.line_height_px,
|
||||
)))
|
||||
.font(ui.font_of(style))
|
||||
.color(label_color),
|
||||
);
|
||||
}
|
||||
|
||||
button(stack)
|
||||
.width(nav_rail_width(ui.metrics()))
|
||||
.height(height)
|
||||
.padding(0)
|
||||
.style(move |_theme: &IcedTheme, status| button::Style {
|
||||
background: match overridden(forced, status) {
|
||||
button::Status::Hovered if !active => Some(Background::Color(ui.state_over(
|
||||
StateLayer::Hover,
|
||||
Role::OnSurface,
|
||||
Role::Surface,
|
||||
))),
|
||||
button::Status::Pressed if !active => Some(Background::Color(ui.state_over(
|
||||
StateLayer::Pressed,
|
||||
Role::OnSurface,
|
||||
Role::Surface,
|
||||
))),
|
||||
_ => None,
|
||||
},
|
||||
text_color: label_color,
|
||||
border: Border::default(),
|
||||
shadow: Shadow::default(),
|
||||
snap: true,
|
||||
})
|
||||
.on_press(Message::Select(NavItem::Account))
|
||||
.into()
|
||||
}
|
||||
|
||||
/// 头像位边长 = 指示器宽 − 16,但不超过 48(12b 里没有这个控件,按令牌栅格取整)。
|
||||
fn avatar_size(ui: &Ui, indicator_width: f32) -> f32 {
|
||||
(indicator_width - Space::X4.dp()).min(ui.space(Space::X8) + Space::X2.dp())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn the_two_states_are_distinguishable() {
|
||||
let out = RailAccount::signed_out("Not signed in");
|
||||
assert!(!out.signed_in);
|
||||
let on = RailAccount::signed_in("S", "Steve");
|
||||
assert!(on.signed_in);
|
||||
assert_eq!(on.initial, "S");
|
||||
let compacted = on.clone().compact(true);
|
||||
assert!(compacted.compact, "compact(true) 必须收成图标条");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_avatar_slot_fits_the_rail() {
|
||||
use unlucky_design::color::SchemeFlavor;
|
||||
use unlucky_design::typography::Platform;
|
||||
|
||||
use crate::theme::Families;
|
||||
|
||||
let ui = Ui::with_platform(
|
||||
SchemeFlavor::Light,
|
||||
Platform::Windows,
|
||||
Families {
|
||||
text: "Segoe UI",
|
||||
display: "Segoe UI",
|
||||
mono: "Consolas",
|
||||
},
|
||||
);
|
||||
let width = super::super::indicator_size(ui.metrics()).0;
|
||||
let size = avatar_size(&ui, width);
|
||||
assert!(size > 0.0);
|
||||
assert!(
|
||||
size <= nav_rail_width(ui.metrics()),
|
||||
"头像位不能比导航轨还宽({size} > {})",
|
||||
nav_rail_width(ui.metrics())
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user