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 全量另跑)
This commit is contained in:
AskaEth committed 2026-11-05 05:09:51 +08:00
1 parent 1ff00bbe50
commit 8d8552113a
31 files changed
+1540 -497

No files matched your search

+192 -2
View File
@@ -11,15 +11,26 @@ 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 super::super::Message;
use super::model::Row;
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;
@@ -56,6 +67,185 @@ fn tone(ui: &Ui, tone: u8) -> (iced::Color, iced::Color) {
}
}
/// 提示条的语义档(决定图标块与图标字形用哪一对角色)。
#[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`,