Files
Unlucky-Launcher/crates/app-iced/src/pages/account/widgets.rs
T
AskaEth 8d8552113a fix(app-iced, presenter): M114 r2 —— 登录失败走结构化错误码 + i18n,修 4 条 p2 与 3 条看图意见
p1(语言泄漏):后端诊断串不再上屏
- presenter 新增 LoginFailure 原因码(denied / whitelist_pending / unreachable /
  not_authlib_server / bad_credentials / no_profile / store_failed / other)
- 页面按码查 i18n 出标题+正文+「Code: …」,原文只进日志/详细信息
- 进行中(Requesting)也走 i18n,不再复读后端中文状态串
- presenter/state 不再发中文 toast(成败由页面按 i18n 画)
- 新增守门测试:注入带 AADSTS 的失败事件,断言英文视图无 CJK 且含 code

p2-1 复制:on_account 返回 Task,page_messages 把 iced::clipboard::write 交回 runtime
p2-2 可见失败态:新增 AccountNotice(added/removed/switched/名字非法/重名/落盘失败/
      找不到),页面用 M3 内联 alert 画(不是 toast);离线名字先按 3–16 位规则校验,
      失败不再清空输入
p2-3 重新登录按账户类型分派:微软→授权码流;第三方→预填服务器/登录名并提示补密码;
      离线→明确说明没有可重登的服务器账户
p2-4 入口收敛:删 chip 行;空态=三卡片;有账号时三卡收进 Add an account(按钮不再绑死微软轨)

看图意见/tower 三条:
- 失败横幅改成 M3 alert(SurfaceContainerHigh + 描边,errorContainer 只作图标块,
  不再通栏实心红);色值本身经复审像素采样确认是 90/30 档,未改角色
- 当前账户卡补齐与列表一致的「服务器/登录名」行;无皮肤时明说 No skin available
- 页面级 scrollable 加 8px 右 gutter,滚动条不再压卡片
- 倒计时改 mm:ss(15:00 left),数字与冒号语言无关

p3(顺带):AuthlibRequest / Form 手写 Debug 脱敏密码;HTTP 响应体与 base64 输出加上限;
离线提示文案与行为对齐;删除无人使用的 key;皮肤测试断言帽子层真的 blend

测试:app-iced lib 418 绿、presenter 151 / i18n 24 绿(workspace 全量另跑)
2026-11-05 05:09:51 +08:00

380 lines
13 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.
//! 本页小件:头像块(首字母块 / 真图)、章节标题、轨道徽标。
//!
//! ## 令牌纪律
//!
//! 本文件**零**色值 / 字号 / 半径字面量:颜色一律 [`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::component_map::Component;
use unlucky_design::elevation::MaterialLayer;
use unlucky_design::icons::IconKind;
use unlucky_design::shape::ShapeScale;
use unlucky_design::spacing::Space;
use unlucky_design::typography::TypeRole;
use unlucky_i18n::{Catalog, Key};
use unlucky_presenter::dto::AccountNoticeCode;
use crate::pages::frame;
use crate::pages::frame::ICON_PX;
use crate::theme::Ui;
use crate::app::components::buttons::{self, Button};
use crate::pages::Message as PageMessage;
use crate::pages::Message;
use super::model::{Hint, Notice, Row};
use super::Message as Account;
/// 头像块的可选尺寸(列表 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),
),
}
}
/// 提示条的语义档(决定图标块与图标字形用哪一对角色)。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Tone {
/// 失败(`ErrorContainer` / `OnErrorContainer` 图标块 + `Error` 图形)
Error,
/// 成功/信息(`SecondaryContainer` / `OnSecondaryContainer` 图标块 + `Check` 图形)
Info,
}
/// **M3 内联提示条**(账户页横幅与登录失败面板共用)。
///
/// ## 为什么不是通栏实心色块(tower r2 意见)
///
/// r1 的失败面板把 `errorContainer` 铺满整条内容宽——角色本身是对的(复审按像素采样反证过:
/// 暗色取到容器角色 30 档、浅色取到 90 档,与设计层逐值一致),但**面积**让它像一条通栏告警带。
/// 现在改成 M3 的 alert 形态:面板底走 `SurfaceContainerHigh` + 1dp 描边,
/// `ErrorContainer` / `OnErrorContainer` 只作左侧**图标块**的强调(成功档换成 Secondary 容器),
/// 标题用 `OnSurface`、正文与 code 行用 `OnSurfaceVariant`。语义仍由角色表达,视觉不再是红条。
pub fn alert<'a>(
ui: &'a Ui,
tone: Tone,
title: &'a str,
body: String,
code: Option<String>,
actions: Vec<Element<'a, PageMessage>>,
) -> Element<'a, PageMessage> {
let (chip_bg, chip_fg, glyph, glyph_color) = match tone {
Tone::Error => (
ui.color(Role::ErrorContainer),
ui.color(Role::OnErrorContainer),
IconKind::Error,
ui.color(Role::Error),
),
Tone::Info => (
ui.color(Role::SecondaryContainer),
ui.color(Role::OnSecondaryContainer),
IconKind::Check,
ui.color(Role::OnSecondaryContainer),
),
};
let _ = chip_fg;
let chip = container(frame::glyph_box(glyph, glyph_color, ICON_PX))
.width(Length::Fixed(ui.space(Space::X8)))
.height(Length::Fixed(ui.space(Space::X8)))
.align_x(Alignment::Center)
.align_y(Alignment::Center)
.style(move |_theme| container::Style {
background: Some(Background::Color(chip_bg)),
border: Border {
radius: ui.radius(ShapeScale::Full, ui.space(Space::X8)),
..Border::default()
},
..container::Style::default()
});
let mut lines = iced::widget::Column::new().spacing(ui.space(Space::X1));
lines = lines.push(frame::text_role(
ui,
TypeRole::TitleSmall,
true,
title,
ui.color(Role::OnSurface),
));
lines = lines.push(frame::text_role_owned(
ui,
TypeRole::BodySmall,
false,
body,
ui.color(Role::OnSurfaceVariant),
));
if let Some(code) = code {
lines = lines.push(frame::text_role_owned(
ui,
TypeRole::LabelSmall,
false,
code,
ui.color(Role::OnSurfaceVariant),
));
}
container(
row![
chip,
container(lines).width(Length::Fill),
row(actions)
.spacing(ui.space(Space::X2))
.align_y(Alignment::Center),
]
.spacing(ui.space(Space::X3))
.align_y(Alignment::Center)
.width(Length::Fill),
)
.width(Length::Fill)
.padding(ui.metrics().card_padding)
.style(move |_theme| container::Style {
background: Some(Background::Color(ui.layer(MaterialLayer::Card))),
border: ui.outlined(Component::OutlinedCard.spec().shape),
..container::Style::default()
})
.into()
}
/// 账户页横幅:添加/删除/切换的结果(**页面上真实可见的控件**,不是 toast)。
///
/// 为什么必须有它:app-iced 不渲染 toast(复审 r1 P2-2),离线名字不合法/重名/落盘失败时
/// 用户原本什么都看不到。文案按 `AccountNoticeCode` 查 i18n,后端原文不进 UI。
pub fn banner<'a>(ui: &'a Ui, notice: &'a Notice, catalog: Catalog) -> Element<'a, PageMessage> {
let tone = if notice.failure {
Tone::Error
} else {
Tone::Info
};
let title = if notice.failure {
catalog.text(Key::AccountNoticeTitleFailed)
} else {
catalog.text(Key::AccountNoticeTitleDone)
};
let message = notice_text(notice, catalog);
let dismiss = buttons::text(
ui,
Button::new(
catalog.text(Key::AccountNoticeDismiss),
Account::DismissNotice.into_page(),
),
);
// 只有失败才显示稳定 code(成功态列一串 `added` 是噪音)
let code = notice
.failure
.then(|| catalog.render(Key::AccountFailureCode, &[("code", notice.code.code())]));
alert(ui, tone, title, message, code, vec![dismiss])
}
/// 横幅文案(码 → i18n;主题名拼进带占位符的几句)。
fn notice_text(notice: &Notice, catalog: Catalog) -> String {
let name = notice.subject.as_str();
match notice.code {
AccountNoticeCode::Added => catalog.render(Key::AccountNoticeAdded, &[("name", name)]),
AccountNoticeCode::Removed => catalog.render(Key::AccountNoticeRemoved, &[("name", name)]),
AccountNoticeCode::Switched => {
catalog.render(Key::AccountNoticeSwitched, &[("name", name)])
}
AccountNoticeCode::OfflineNameInvalid => catalog
.text(Key::AccountNoticeOfflineNameInvalid)
.to_string(),
AccountNoticeCode::AccountExists => {
catalog.text(Key::AccountNoticeAccountExists).to_string()
}
AccountNoticeCode::StoreFailed => catalog.text(Key::AccountNoticeStoreFailed).to_string(),
AccountNoticeCode::NotFound => catalog.text(Key::AccountNoticeNotFound).to_string(),
AccountNoticeCode::Other => catalog.text(Key::AccountNoticeOther).to_string(),
}
}
/// 一次性引导(重新登录时告诉用户下一步)。
pub fn hint<'a>(
ui: &'a Ui,
hint: Hint,
page: &'a super::Page,
catalog: Catalog,
) -> Element<'a, PageMessage> {
let message = match hint {
Hint::AuthlibReauth => catalog.render(
Key::AccountHintAuthlibReauth,
&[("server", page.form.server.as_str())],
),
Hint::OfflineNoRelogin => catalog.text(Key::AccountHintOfflineNoRelogin).to_string(),
};
container(frame::text_role_owned(
ui,
TypeRole::BodySmall,
false,
message,
ui.color(Role::OnSurfaceVariant),
))
.width(Length::Fill)
.into()
}
/// 头像块:有真图就画图(圆角裁切),没有就画**首字母块**。
///
/// 三条铁律:① 图解码失败一定回到首字母块(不白块);② 首字母块的字来自 `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()
}