feat(app-iced): M114 账户页三轨登录(微软正版 / 第三方皮肤站 / 本地档案)

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

No files matched your search

@@ -0,0 +1,189 @@
//! 本页小件:头像块(首字母块 / 真图)、章节标题、轨道徽标。
//!
//! ## 令牌纪律
//!
//! 本文件**零**色值 / 字号 / 半径字面量:颜色一律 [`Ui::color`],尺寸走 `Space` /
//! `LayoutMetrics` / `ui.style(..)`。首字母块的「色号」也只是**挑哪一个角色**,
//! 不是一个具体颜色——深浅两套主题各自成立。
use iced::widget::text::LineHeight;
use iced::widget::{container, image, row, text};
use iced::{Alignment, Background, Border, ContentFit, Element, Length, Pixels};
use unlucky_design::color::Role;
use unlucky_design::shape::ShapeScale;
use unlucky_design::spacing::Space;
use unlucky_design::typography::TypeRole;
use crate::pages::frame;
use crate::theme::Ui;
use super::super::Message;
use super::model::Row;
/// 头像块的可选尺寸(列表 40 / 当前账户 56)。
pub const AVATAR_LIST: f32 = 40.0;
/// 当前账户卡片上的头像边长。
pub const AVATAR_ACTIVE: f32 = 56.0;
/// 色号 → (底色, 字色) 角色对。
///
/// 只有「挑角色」这一步是硬的:六个色号对应六个容器角色,深浅主题各有一套值,
/// 所以同一个账户在两种主题下都成立,也不会撞出低对比。
fn tone(ui: &Ui, tone: u8) -> (iced::Color, iced::Color) {
match tone % 6 {
0 => (
ui.color(Role::PrimaryContainer),
ui.color(Role::OnPrimaryContainer),
),
1 => (
ui.color(Role::SecondaryContainer),
ui.color(Role::OnSecondaryContainer),
),
2 => (
ui.color(Role::TertiaryContainer),
ui.color(Role::OnTertiaryContainer),
),
3 => (
ui.color(Role::SurfaceContainerHighest),
ui.color(Role::OnSurface),
),
4 => (ui.color(Role::PrimaryFixed), ui.color(Role::OnPrimaryFixed)),
_ => (
ui.color(Role::SurfaceVariant),
ui.color(Role::OnSurfaceVariant),
),
}
}
/// 头像块:有真图就画图(圆角裁切),没有就画**首字母块**。
///
/// 三条铁律:① 图解码失败一定回到首字母块(不白块);② 首字母块的字来自 `AccountView::monogram`,
/// 永不空串;③ 两者尺寸完全一致,切换时不跳版。
pub fn avatar<'a>(ui: &'a Ui, row: &Row, size: f32) -> Element<'a, Message> {
if let Some(handle) = &row.head {
return container(
image(handle.clone())
.width(Length::Fixed(size))
.height(Length::Fixed(size))
.content_fit(ContentFit::Cover),
)
.width(Length::Fixed(size))
.height(Length::Fixed(size))
.style(move |_theme| container::Style {
border: Border {
radius: ui.radius(ShapeScale::Full, size),
..Border::default()
},
..container::Style::default()
})
.into();
}
monogram(ui, &row.monogram, row.tone, size)
}
/// 首字母块(没有皮肤时的头像位)。
pub fn monogram<'a>(ui: &'a Ui, letter: &str, tone_index: u8, size: f32) -> Element<'a, Message> {
let (background, foreground) = tone(ui, tone_index);
let style = ui.emphasized(TypeRole::TitleMedium);
container(
text(letter.to_string())
.size(style.size_px)
.line_height(LineHeight::Absolute(Pixels(style.line_height_px)))
.font(ui.font_of(style))
.color(foreground),
)
.width(Length::Fixed(size))
.height(Length::Fixed(size))
.align_x(Alignment::Center)
.align_y(Alignment::Center)
.style(move |_theme| container::Style {
background: Some(Background::Color(background)),
border: Border {
radius: ui.radius(ShapeScale::Full, size),
..Border::default()
},
..container::Style::default()
})
.into()
}
/// 章节标题(`TitleMedium` + 可选副文案)。
pub fn section<'a>(ui: &'a Ui, title: &'a str, support: Option<&'a str>) -> Element<'a, Message> {
let mut column = iced::widget::column![frame::text_role(
ui,
TypeRole::TitleMedium,
true,
title,
ui.color(Role::OnSurface),
)]
.spacing(ui.space(Space::X1));
if let Some(support) = support {
column = column.push(frame::text_role(
ui,
TypeRole::BodySmall,
false,
support,
ui.color(Role::OnSurfaceVariant),
));
}
column.into()
}
/// 轨道徽标(小药丸:`LabelMedium` + `SecondaryContainer`)。
pub fn badge<'a>(ui: &'a Ui, label: &'a str, active: bool) -> Element<'a, Message> {
let (background, foreground) = if active {
(
ui.color(Role::SecondaryContainer),
ui.color(Role::OnSecondaryContainer),
)
} else {
(
ui.color(Role::SurfaceContainerHighest),
ui.color(Role::OnSurfaceVariant),
)
};
let style = ui.style(TypeRole::LabelMedium);
container(
text(label.to_string())
.size(style.size_px)
.line_height(LineHeight::Absolute(Pixels(style.line_height_px)))
.font(ui.font_of(style))
.color(foreground),
)
.padding(iced::Padding::from([0.0, ui.space(Space::X2)]))
.height(ui.metrics().control_height - ui.space(Space::X2) * 2.0)
.align_y(Alignment::Center)
.style(move |_theme| container::Style {
background: Some(Background::Color(background)),
border: Border {
radius: ui.radius(ShapeScale::Full, ui.metrics().control_height),
..Border::default()
},
..container::Style::default()
})
.into()
}
/// 一条「标签 + 值」的元信息行(列表行副文案之外的结构化信息)。
pub fn meta_line<'a>(ui: &'a Ui, label: &'a str, value: &'a str) -> Element<'a, Message> {
row![
frame::text_role(
ui,
TypeRole::LabelSmall,
false,
label,
ui.color(Role::OnSurfaceVariant),
),
frame::text_role(
ui,
TypeRole::LabelMedium,
false,
value,
ui.color(Role::OnSurface)
),
]
.spacing(ui.space(Space::X2))
.align_y(Alignment::Center)
.into()
}