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

+328
View File
@@ -0,0 +1,328 @@
//! 三条轨道的入口卡与两张表单(本地档案 / 第三方皮肤站)。
//!
//! ## 为什么是「三条轨道」而不是「一个添加按钮 + 一个类型下拉」
//!
//! 三条轨道的**字段完全不同**(微软没有密码输入框、本地档案没有服务器、第三方站两样都有),
//! 而且失败原因也不同(403 / 白名单未批 / 服务器不可达)。把它们压进一个表单只会让每种情况
//! 都要解释自己不属于另外两种——所以入口就是三张卡,各自展开自己的面板。
use iced::widget::{column, container, row};
use iced::{Alignment, Element, Length};
use unlucky_design::color::Role;
use unlucky_design::spacing::Space;
use unlucky_design::typography::TypeRole;
use unlucky_i18n::{Catalog, Key, Lang};
use crate::app::components::buttons::{self, Button};
use crate::app::components::feedback::empty_state::{empty_state, EmptyState};
use crate::app::components::inputs::{text_field, Field, FieldVariant};
use crate::pages::frame;
use crate::theme::Ui;
use crate::pages::account::model::{AuthlibPanel, Page, Track};
use crate::pages::Message as PageMessage;
use super::{login, widgets, Message as Account};
/// 空态:不是一张白页——标题 + 说明 + **三条轨道的直达入口**。
pub fn empty<'a>(ui: &'a Ui, catalog: Catalog, lang: Lang) -> Element<'a, PageMessage> {
let mut actions = row![]
.spacing(ui.space(Space::X2))
.align_y(Alignment::Center);
for track in Track::ALL {
// 三个按钮各用**轨道的名字**做标签(三条都叫「登录」的话,用户分不清点哪个)
actions = actions.push(buttons::tonal(
ui,
Button::new(
catalog.text(track.title_key()),
Account::Open(track).into_page(),
),
));
}
let state = empty_state(
ui,
EmptyState::new(
unlucky_design::icons::IconKind::User,
catalog.text(Key::AccountEmptyTitle),
catalog.text(Key::AccountEmptyBody),
),
);
let _ = lang;
column![
container(state).height(Length::Fixed(ui.space(Space::X8) * 8.0)),
container(actions)
.width(Length::Fill)
.align_x(Alignment::Center)
.center_x(Length::Fill),
]
.spacing(ui.space(Space::X4))
.width(Length::Fill)
.into()
}
/// 三条轨道的入口卡(面板没打开时画这一排)。
pub fn tracks<'a>(ui: &'a Ui, catalog: Catalog, lang: Lang) -> Element<'a, PageMessage> {
let _ = lang;
let mut row = iced::widget::Row::new().spacing(ui.space(Space::X4));
for track in Track::ALL {
row = row.push(track_card(ui, track, catalog));
}
row.width(Length::Fill).into()
}
fn track_card<'a>(ui: &'a Ui, track: Track, catalog: Catalog) -> Element<'a, PageMessage> {
let body = column![
frame::text_role(
ui,
TypeRole::TitleSmall,
true,
catalog.text(track.title_key()),
ui.color(Role::OnSurface),
),
frame::text_role(
ui,
TypeRole::BodySmall,
false,
catalog.text(track.body_key()),
ui.color(Role::OnSurfaceVariant),
),
buttons::outlined(
ui,
Button::new(
catalog.text(track.action_key()),
Account::Open(track).into_page(),
),
),
]
.spacing(ui.space(Space::X3));
container(frame::card(ui, body))
.width(Length::FillPortion(1))
.into()
}
/// 当前打开的面板。
pub fn panel<'a>(
ui: &'a Ui,
page: &'a Page,
catalog: Catalog,
lang: Lang,
) -> Element<'a, PageMessage> {
let _ = lang;
match page.panel {
crate::pages::account::model::Panel::None => tracks(ui, catalog, Lang::En),
crate::pages::account::model::Panel::Microsoft => login::panel(ui, &page.device, catalog),
crate::pages::account::model::Panel::Authlib => authlib_form(ui, page, catalog),
crate::pages::account::model::Panel::Offline => offline_form(ui, page, catalog),
}
}
/// 本地档案表单(**说明里带「仅本地/隔离网络测试用」这条限定**)。
fn offline_form<'a>(ui: &'a Ui, page: &'a Page, catalog: Catalog) -> Element<'a, PageMessage> {
let ready = page.form.offline_ready();
let submit = if ready {
buttons::filled(
ui,
Button::new(
catalog.text(Key::AccountOfflineSubmit),
Account::Submit.into_page(),
),
)
} else {
buttons::filled(
ui,
Button::disabled(catalog.text(Key::AccountOfflineSubmit)),
)
};
let body = column![
widgets::section(
ui,
catalog.text(Key::AccountTrackOfflineTitle),
Some(catalog.text(Key::AccountTrackOfflineBody)),
),
warning(ui, catalog.text(Key::AccountOfflineWarning)),
text_field(
ui,
Field::new(&page.form.offline_name)
.variant(FieldVariant::Outlined)
.label(catalog.text(Key::AccountOfflineNameLabel))
.placeholder(catalog.text(Key::AccountOfflineNameHint))
.supporting(catalog.text(Key::AccountOfflineNameHint)),
|value| Account::OfflineName(value).into_page(),
),
row![
submit,
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));
frame::card(ui, body)
}
/// 第三方站表单:地址(可单独校验)+ 登录名 + 密码 + 登录。
fn authlib_form<'a>(ui: &'a Ui, page: &'a Page, catalog: Catalog) -> Element<'a, PageMessage> {
let busy = page.authlib.busy();
let ready = page.form.authlib_ready() && !busy;
let submit = if ready {
buttons::filled(
ui,
Button::new(
catalog.text(Key::AccountAuthlibSubmit),
Account::Submit.into_page(),
),
)
} else {
buttons::filled(
ui,
Button::disabled(catalog.text(Key::AccountAuthlibSubmit)),
)
};
let mut body = column![
widgets::section(
ui,
catalog.text(Key::AccountTrackAuthlibTitle),
Some(catalog.text(Key::AccountTrackAuthlibBody)),
),
text_field(
ui,
Field::new(&page.form.server)
.variant(FieldVariant::Outlined)
.label(catalog.text(Key::AccountServerLabel))
.placeholder(catalog.text(Key::AccountServerHint))
.supporting(if page.checked_server.is_empty() {
catalog.text(Key::AccountServerHint)
} else {
page.checked_server.as_str()
}),
|value| Account::Server(value).into_page(),
),
text_field(
ui,
Field::new(&page.form.login)
.variant(FieldVariant::Outlined)
.label(catalog.text(Key::AccountLoginNameLabel))
.placeholder(""),
|value| Account::Login(value).into_page(),
),
text_field(
ui,
Field::new(&page.form.password)
.variant(FieldVariant::Outlined)
.label(catalog.text(Key::AccountPasswordLabel))
.supporting(catalog.text(Key::AccountPasswordHint))
// 密码**掩码**显示:明文只在内存里,不上屏
.secure(true),
|value| Account::Password(value).into_page(),
),
status_line(ui, page, catalog),
row![
buttons::outlined(
ui,
Button::new(
catalog.text(Key::AccountServerProbe),
Account::Probe.into_page()
)
),
submit,
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));
if !ready && !busy {
body = body.push(frame::text_role(
ui,
TypeRole::BodySmall,
false,
catalog.text(Key::AccountAuthlibIdleHint),
ui.color(Role::OnSurfaceVariant),
));
}
frame::card(ui, body)
}
/// 第三方轨的状态行(校验通过 / 进行中 / 失败原文)。
///
/// 进行中与通过都走 **i18n**:presenter 给的 `message` 是后端诊断串(中文),
/// 直接上屏会让英文界面里混一段中文;失败态才用它(那是要人读的错误原因)。
fn status_line<'a>(ui: &'a Ui, page: &'a Page, catalog: Catalog) -> Element<'a, PageMessage> {
match &page.authlib {
AuthlibPanel::Idle => frame::text_role(
ui,
TypeRole::BodySmall,
false,
"",
ui.color(Role::OnSurfaceVariant),
),
AuthlibPanel::Running { .. } => frame::text_role(
ui,
TypeRole::LabelMedium,
false,
catalog.text(Key::AccountServerChecking),
ui.color(Role::OnSurfaceVariant),
),
AuthlibPanel::Ready { server_name, .. } => frame::text_role_owned(
ui,
TypeRole::LabelMedium,
true,
catalog.render(Key::AccountServerOk, &[("server", server_name.as_str())]),
ui.color(Role::Primary),
),
AuthlibPanel::Failed { message } => frame::text_role(
ui,
TypeRole::LabelMedium,
false,
message,
ui.color(Role::Error),
),
AuthlibPanel::Succeeded { name } => frame::text_role(
ui,
TypeRole::LabelMedium,
true,
name,
ui.color(Role::Primary),
),
}
}
/// 「仅本地/隔离网络测试用」这条限定:**带色块**,不是一句灰字(用户要点得明白)。
fn warning<'a>(ui: &'a Ui, text: &'a str) -> Element<'a, PageMessage> {
container(frame::text_role(
ui,
TypeRole::BodySmall,
false,
text,
ui.color(Role::OnErrorContainer),
))
.width(Length::Fill)
.padding(ui.space(Space::X2))
.style(move |_theme| container::Style {
background: Some(iced::Background::Color(ui.color(Role::ErrorContainer))),
border: iced::Border {
radius: ui.radius_dp(unlucky_design::shape::ShapeScale::Small),
..iced::Border::default()
},
..container::Style::default()
})
.into()
}