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

+57 -54
View File
@@ -6,14 +6,14 @@
//! 更不知道已经过期了——M114 把这三件事都做成可见状态:用户码(可复制)、链接(可复制/打开)、
//! 倒计时(`{secs}s left`)与过期后的「已过期,重试」。授权码流同理,另外必须能**取消**。
use iced::widget::{column, container, row};
use iced::{Alignment, Background, Border, Element, Length};
use iced::widget::{column, row};
use iced::{Alignment, Element};
use unlucky_design::color::Role;
use unlucky_design::shape::ShapeScale;
use unlucky_design::spacing::Space;
use unlucky_design::typography::TypeRole;
use unlucky_i18n::{Catalog, Key};
use unlucky_presenter::dto::LoginFailure;
use crate::app::components::buttons::{self, Button};
use crate::pages::frame;
@@ -46,7 +46,7 @@ pub fn panel<'a>(
expired,
..
} => browser(ui, link, *remaining, *expired, catalog),
DevicePanel::Failed { message } => failed(ui, message, catalog),
DevicePanel::Failed { failure, .. } => failed(ui, *failure, catalog),
DevicePanel::Succeeded { name } => succeeded(ui, name, catalog),
}
}
@@ -82,14 +82,16 @@ fn idle<'a>(ui: &'a Ui, catalog: Catalog) -> Element<'a, PageMessage> {
frame::card(ui, body)
}
/// 「正在申请/正在校验」:一句状态 + 取消。
fn notice<'a>(ui: &'a Ui, message: &'a str, catalog: Catalog) -> Element<'a, PageMessage> {
/// 「正在申请/正在校验」:一句**i18n**状态 + 取消。
///
/// 不复读 presenter 的 `message`:那是后端诊断串(中文),上屏就是语言泄漏(复审 r1 P1-1)。
fn notice<'a>(ui: &'a Ui, _message: &str, catalog: Catalog) -> Element<'a, PageMessage> {
let body = column![
frame::text_role(
ui,
TypeRole::BodyMedium,
false,
message,
catalog.text(Key::AccountDeviceRequesting),
ui.color(Role::OnSurfaceVariant),
),
row![buttons::text(
@@ -261,8 +263,12 @@ fn countdown_line<'a>(
);
}
let text = remaining.map_or_else(String::new, |secs| {
catalog.render(Key::AccountDeviceCountdown, &[("secs", &secs.to_string())])
// mm:ss(`15:00 left`)而不是 `900s left`:人读的是分秒,而且不随语言换格式
// (数字与冒号在两种语言里同形,只有 `left`/`剩` 走 catalog)。
let clock = format!("{:02}:{:02}", secs / 60, secs % 60);
catalog.render(Key::AccountDeviceCountdown, &[("time", &clock)])
});
// 运行时格式化的串走 `text_role_owned`(`text_role` 只借 `&'a str`)
frame::text_role_owned(
ui,
TypeRole::LabelMedium,
@@ -272,56 +278,39 @@ fn countdown_line<'a>(
)
}
/// 失败态(`ErrorContainer` 面板:不是一句灰字)。
fn failed<'a>(ui: &'a Ui, message: &'a str, catalog: Catalog) -> Element<'a, PageMessage> {
let body = column![
frame::text_role(
/// 失败态:**M3 内联 alert**(左侧 `ErrorContainer` 图标块 + 标题/正文/Code + 重试/关闭)。
///
/// 两条修订叠在这里:
/// - **语言泄漏(r1 p1)**:只画原因码对应的 i18n + 一行 `Code: …`,后端中文原文不上屏;
/// - **不许通栏实心红(tower r2)**:面板底走 `SurfaceContainerHigh` + 描边,
/// `ErrorContainer`/`OnErrorContainer` 只作左侧图标块的强调(角色不变,面积收小)。
fn failed<'a>(ui: &'a Ui, failure: LoginFailure, catalog: Catalog) -> Element<'a, PageMessage> {
let body = catalog.text(failure_body_key(failure)).to_string();
let code = catalog.render(Key::AccountFailureCode, &[("code", failure.code())]);
let actions = vec![
buttons::tonal(
ui,
TypeRole::TitleSmall,
true,
catalog.text(Key::AccountLoginFailed),
ui.color(Role::OnErrorContainer),
Button::new(
catalog.text(Key::AccountDeviceRetry),
Account::StartDevice.into_page(),
),
),
frame::text_role(
buttons::text(
ui,
TypeRole::BodyMedium,
false,
message,
ui.color(Role::OnErrorContainer),
Button::new(
catalog.text(Key::AccountDialogClose),
Account::Close.into_page(),
),
),
row![
buttons::tonal(
ui,
Button::new(
catalog.text(Key::AccountDeviceRetry),
Account::StartDevice.into_page(),
)
),
buttons::text(
ui,
Button::new(
catalog.text(Key::AccountDialogClose),
Account::Close.into_page()
)
),
]
.spacing(ui.space(Space::X2))
.align_y(Alignment::Center),
]
.spacing(ui.space(Space::X3));
container(body)
.width(Length::Fill)
.padding(ui.metrics().card_padding)
.style(move |_theme| container::Style {
background: Some(Background::Color(ui.color(Role::ErrorContainer))),
border: Border {
radius: ui.radius_dp(ShapeScale::Medium),
..Border::default()
},
..container::Style::default()
})
.into()
];
super::widgets::alert(
ui,
super::widgets::Tone::Error,
catalog.text(Key::AccountLoginFailed),
body,
Some(code),
actions,
)
}
/// 成功态。
@@ -347,3 +336,17 @@ fn succeeded<'a>(ui: &'a Ui, name: &'a str, catalog: Catalog) -> Element<'a, Pag
.spacing(ui.space(Space::X3));
frame::card(ui, body)
}
/// 原因码 → i18n 正文 key(页面里唯一的「码→文案」映射点)。
pub(super) fn failure_body_key(failure: LoginFailure) -> Key {
match failure {
LoginFailure::Denied => Key::AccountFailureDenied,
LoginFailure::NotWhitelisted => Key::AccountFailureWhitelist,
LoginFailure::Unreachable => Key::AccountFailureUnreachable,
LoginFailure::NotAuthlibServer => Key::AccountFailureNotAuthlib,
LoginFailure::BadCredentials => Key::AccountFailureBadCredentials,
LoginFailure::NoProfile => Key::AccountFailureNoProfile,
LoginFailure::StoreFailed => Key::AccountFailureStore,
LoginFailure::Other => Key::AccountFailureOther,
}
}