feat(app-iced): M114 账户页三轨登录(微软正版 / 第三方皮肤站 / 本地档案)
- 账户页:当前账户卡 + 全部账户列表 + 空态三入口 + 皮肤区;五项动作(添加/切换/删除/重新登录/校验) - 三轨添加流程:设备码(用户码+链接+倒计时+过期重试+取消)、授权码(浏览器+可复制链接+取消)、 本地档案(名字校验)、第三方站(服务地址 + 服务发现校验 + 密码登录,密码掩码且用完即弃) - 导航:账户为一等页,入口是轨底头像位(未登录=描边剪影 / 已登录=首字母块+名字;窄高收成图标条) - 凭据红线:DTO 侧形状脱敏 + 状态层二次脱敏 + 密码掩码;Inject-JWT 测试断言视图模型干净(已做变异取证) - presenter:authlib 登录/服务发现/皮肤抓取(复用下载引擎的 HTTP 客户端,无新增依赖)、 皮肤原图经 DTO 到 UI 裁剪成头像(既有 image 依赖,未改 Cargo.toml) - i18n:新增 account 域 51 条中英文案(离线档案口径全量写入),金表同步
This commit is contained in:
1 parent
e7b04c4afe
commit
1ff00bbe50
43 files changed
+4343
-117
No files matched your search
@@ -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()
|
||||
}
|
||||
Reference in new issue
Block a user