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

+349
View File
@@ -0,0 +1,349 @@
//! 微软登录面板:设备码(用户码 + 链接 + **倒计时** + 过期重试)与授权码(浏览器 + 可复制链接 + 取消)。
//!
//! ## 为什么不是「一个转圈」
//!
//! 设备码流的等待期可以长到 15 分钟。只画转圈的话,用户既不知道要做什么、也不知道还剩多久,
//! 更不知道已经过期了——M114 把这三件事都做成可见状态:用户码(可复制)、链接(可复制/打开)、
//! 倒计时(`{secs}s left`)与过期后的「已过期,重试」。授权码流同理,另外必须能**取消**。
use iced::widget::{column, container, row};
use iced::{Alignment, Background, Border, Element, Length};
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 crate::app::components::buttons::{self, Button};
use crate::pages::frame;
use crate::theme::Ui;
use crate::pages::account::model::DevicePanel;
use crate::pages::Message as PageMessage;
use super::Message as Account;
/// 微软登录面板。
pub fn panel<'a>(
ui: &'a Ui,
device: &'a DevicePanel,
catalog: Catalog,
) -> Element<'a, PageMessage> {
match device {
DevicePanel::Hidden => idle(ui, catalog),
DevicePanel::Requesting { message } => notice(ui, message, catalog),
DevicePanel::Code {
code,
link,
remaining,
expired,
..
} => device_code(ui, code, link, *remaining, *expired, catalog),
DevicePanel::Browser {
link,
remaining,
expired,
..
} => browser(ui, link, *remaining, *expired, catalog),
DevicePanel::Failed { message } => failed(ui, message, catalog),
DevicePanel::Succeeded { name } => succeeded(ui, name, catalog),
}
}
/// 空闲:给出两条流的入口(GUI 默认授权码流,设备码是备选)。
fn idle<'a>(ui: &'a Ui, catalog: Catalog) -> Element<'a, PageMessage> {
let body = column![row![
buttons::filled(
ui,
Button::new(
catalog.text(Key::AccountDeviceBrowser),
Account::StartBrowser.into_page(),
)
),
buttons::outlined(
ui,
Button::new(
catalog.text(Key::AccountDeviceUseCode),
Account::StartDevice.into_page(),
)
),
buttons::text(
ui,
Button::new(
catalog.text(Key::AccountDeviceCancel),
Account::Close.into_page()
)
),
]
.spacing(ui.space(Space::X2))
.align_y(Alignment::Center),]
.spacing(ui.space(Space::X3));
frame::card(ui, body)
}
/// 「正在申请/正在校验」:一句状态 + 取消。
fn notice<'a>(ui: &'a Ui, message: &'a str, catalog: Catalog) -> Element<'a, PageMessage> {
let body = column![
frame::text_role(
ui,
TypeRole::BodyMedium,
false,
message,
ui.color(Role::OnSurfaceVariant),
),
row![buttons::text(
ui,
Button::new(
catalog.text(Key::AccountDeviceCancel),
Account::Cancel.into_page()
)
)]
.align_y(Alignment::Center),
]
.spacing(ui.space(Space::X3));
frame::card(ui, body)
}
/// 设备码等待态:**码 + 链接 + 倒计时**。
fn device_code<'a>(
ui: &'a Ui,
code: &'a str,
link: &'a str,
remaining: Option<u64>,
expired: bool,
catalog: Catalog,
) -> Element<'a, PageMessage> {
let mut actions = row![
buttons::tonal(
ui,
Button::new(
catalog.text(Key::AccountDeviceCodeCopy),
Account::Copy(code.to_string()).into_page(),
)
),
buttons::outlined(
ui,
Button::new(
catalog.text(Key::AccountDeviceLinkCopy),
Account::Copy(link.to_string()).into_page(),
)
),
]
.spacing(ui.space(Space::X2))
.align_y(Alignment::Center);
if expired {
actions = actions.push(buttons::filled(
ui,
Button::new(
catalog.text(Key::AccountDeviceRetry),
Account::StartDevice.into_page(),
),
));
}
actions = actions.push(buttons::text(
ui,
Button::new(
catalog.text(Key::AccountDeviceCancel),
Account::Cancel.into_page(),
),
));
let countdown = countdown_line(ui, remaining, expired, catalog);
let body = column![
frame::text_role(
ui,
TypeRole::LabelMedium,
false,
catalog.text(Key::AccountDeviceCodeLabel),
ui.color(Role::OnSurfaceVariant),
),
frame::text_role(
ui,
TypeRole::HeadlineSmall,
true,
code,
ui.color(Role::OnSurface),
),
frame::text_role(
ui,
TypeRole::BodySmall,
false,
link,
ui.color(Role::OnSurfaceVariant),
),
countdown,
frame::text_role(
ui,
TypeRole::BodySmall,
false,
catalog.text(Key::AccountDeviceHint),
ui.color(Role::OnSurfaceVariant),
),
actions,
]
.spacing(ui.space(Space::X3));
frame::card(ui, body)
}
/// 授权码等待态:浏览器已拉起,链接可复制;过期可重试。
fn browser<'a>(
ui: &'a Ui,
link: &'a str,
remaining: Option<u64>,
expired: bool,
catalog: Catalog,
) -> Element<'a, PageMessage> {
let mut actions = row![buttons::outlined(
ui,
Button::new(
catalog.text(Key::AccountDeviceLinkCopy),
Account::Copy(link.to_string()).into_page(),
)
)]
.spacing(ui.space(Space::X2))
.align_y(Alignment::Center);
if expired {
actions = actions.push(buttons::filled(
ui,
Button::new(
catalog.text(Key::AccountDeviceRetry),
Account::StartBrowser.into_page(),
),
));
}
actions = actions.push(buttons::text(
ui,
Button::new(
catalog.text(Key::AccountDeviceCancel),
Account::Cancel.into_page(),
),
));
let body = column![
frame::text_role(
ui,
TypeRole::BodyMedium,
false,
catalog.text(Key::AccountDeviceBrowserHint),
ui.color(Role::OnSurface),
),
frame::text_role(
ui,
TypeRole::BodySmall,
false,
link,
ui.color(Role::OnSurfaceVariant),
),
countdown_line(ui, remaining, expired, catalog),
actions,
]
.spacing(ui.space(Space::X3));
frame::card(ui, body)
}
/// 倒计时 / 过期提示(**这一行是「不得只转圈」的落点**)。
fn countdown_line<'a>(
ui: &'a Ui,
remaining: Option<u64>,
expired: bool,
catalog: Catalog,
) -> Element<'a, PageMessage> {
if expired {
return frame::text_role(
ui,
TypeRole::LabelMedium,
true,
catalog.text(Key::AccountDeviceExpired),
ui.color(Role::Error),
);
}
let text = remaining.map_or_else(String::new, |secs| {
catalog.render(Key::AccountDeviceCountdown, &[("secs", &secs.to_string())])
});
frame::text_role_owned(
ui,
TypeRole::LabelMedium,
false,
text,
ui.color(Role::OnSurfaceVariant),
)
}
/// 失败态(`ErrorContainer` 面板:不是一句灰字)。
fn failed<'a>(ui: &'a Ui, message: &'a str, catalog: Catalog) -> Element<'a, PageMessage> {
let body = column![
frame::text_role(
ui,
TypeRole::TitleSmall,
true,
catalog.text(Key::AccountLoginFailed),
ui.color(Role::OnErrorContainer),
),
frame::text_role(
ui,
TypeRole::BodyMedium,
false,
message,
ui.color(Role::OnErrorContainer),
),
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()
}
/// 成功态。
fn succeeded<'a>(ui: &'a Ui, name: &'a str, catalog: Catalog) -> Element<'a, PageMessage> {
let text = format!("{} · {name}", catalog.text(Key::AccountActiveChip));
let body = column![
frame::text_role_owned(
ui,
TypeRole::BodyMedium,
true,
text,
ui.color(Role::OnSurface),
),
row![buttons::text(
ui,
Button::new(
catalog.text(Key::AccountDialogClose),
Account::Close.into_page()
)
)]
.align_y(Alignment::Center),
]
.spacing(ui.space(Space::X3));
frame::card(ui, body)
}