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

+149
View File
@@ -0,0 +1,149 @@
//! 账户页(M114):**三轨**登录(微软正版 / 第三方皮肤站 / 本地档案)+ 账户列表 + 皮肤区。
//!
//! ## 这一页回答什么
//!
//! 用户的原话是「用户管理页面还没做吗?包括不限于正版登录 离线登录 第三方皮肤站登录」。
//! Slint 版曾有这个页面(`DevDocs/ops/screens-m43/account-data.png`),换 iced 时没搬过来,
//! 于是 iced 版**一个账户入口都没有**——本页就是把它补回来,并按 M114 的口径做成三轨。
//!
//! ## 四条红线(都在本页的断言里)
//!
//! | 红线 | 落点 |
//! |---|---|
//! | 页面不得出现任何 token / refresh_token | 上屏文案只来自 [`model`] 的公开字段;`tests` 里有「把 JWT 塞进事件、断言模型干净」的用例 |
//! | 离线档案的说明必须带「仅本地/隔离网络测试用,无法连接启用正版验证的服务器」 | `Key::AccountTrackOfflineBody` / `Key::AccountOfflineWarning` 两处都写全 |
//! | 设备码不得只转圈 | [`login`]:用户码 + 链接 + 倒计时 + 「已过期,重试」 |
//! | 没有皮肤不给假皮肤 | 头像位 = 首字母块(`profile_id` 稳定取色);第三方轨才可能出真图 |
//!
//! ## 模块
//!
//! | 文件 | 职责 |
//! |---|---|
//! | [`model`] | 状态 → 视图模型(列表行 / 当前账户 / 三条轨道的面板态) |
//! | [`avatar`] | 首字母块 + 皮肤图(解码/裁剪缓存;失败一律退回首字母块,**绝不白块**) |
//! | [`widgets`] | 本页的小件(头像块 / 章节标题 / 徽标),样式全部走令牌 |
//! | [`rows`] | 当前账户卡片 + 账户列表行 |
//! | [`forms`] | 三条轨道的入口卡 + 本地档案/第三方站表单 |
//! | [`login`] | 微软登录面板(设备码倒计时 / 浏览器授权 / 失败态) |
//! | `tests` | 视图模型的判据(凭据红线、空态、倒计时、皮肤回落) |
pub mod avatar;
pub mod forms;
pub mod login;
pub mod model;
pub mod panels;
pub mod rows;
pub mod widgets;
#[cfg(test)]
mod tests;
pub use model::{Form, Page, Panel, Track};
use iced::widget::{column, scrollable};
use iced::{Element, Length};
use unlucky_design::spacing::Space;
use unlucky_i18n::{Key, Lang};
use crate::app::components::buttons::{self, Button};
use crate::pages::frame::{self, Header};
use crate::pages::layout::FrameMetrics;
use crate::theme::Ui;
use super::Message as PageMessage;
/// 账户页的页面消息(app 层把它翻成 `AppCommand` / 状态写入)。
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Message {
/// 打开某条轨道的表单
Open(Track),
/// 关掉表单/登录面板
Close,
/// 第三方站地址输入
Server(String),
/// 登录名输入
Login(String),
/// 密码输入(只存在内存里,随请求交给 host,用完即弃)
Password(String),
/// 本地档案名输入
OfflineName(String),
/// 校验服务器地址(服务发现)
Probe,
/// 表单提交(本地档案 → 建档案;第三方站 → 登录)
Submit,
/// 微软:设备码流
StartDevice,
/// 微软:授权码流(拉起系统浏览器)
StartBrowser,
/// 取消/关掉登录面板
Cancel,
/// 复制一段文本(用户码 / 链接)
Copy(String),
/// 切换当前启动账户
Switch(String),
/// 删除账户
Remove(String),
/// 重新登录(微软轨 → 授权码流)
Relogin,
}
impl Message {
/// 包成页面的消息(本模块内部与 app 层都用它,保证只有一处包装点)。
pub fn into_page(self) -> PageMessage {
PageMessage::Account(self)
}
}
/// 整页。
pub fn view<'a>(
ui: &'a Ui,
metrics: FrameMetrics,
page: &'a Page,
lang: Lang,
) -> Element<'a, PageMessage> {
let text = lang.catalog();
let subtitle = text.text(Key::AccountSubtitle);
let add = text.text(Key::AccountEmptyAction);
let header = Header::new(text.text(Key::AccountTitle))
.subtitle(subtitle)
.action(buttons::tonal(
ui,
Button::new(add, Message::Open(Track::Microsoft).into_page()),
));
let top = if page.rows.is_empty() {
forms::empty(ui, text, lang)
} else {
column![
rows::active_card(ui, page, text, lang),
rows::list(ui, page, text, lang),
]
.spacing(ui.space(Space::X6))
.into()
};
// 三条轨道入口 + 表单/登录面板:面板存在时**只画面板**,且**置顶**——
// 否则在 1180×760 下设备码的「复制用户码 / 取消」会掉到折线以下(D30 复审实测)。
let flows = match page.panel {
Panel::None => forms::tracks(ui, text, lang),
_ => forms::panel(ui, page, text, lang),
};
let mut content = column![].spacing(ui.space(Space::X6)).width(Length::Fill);
content = if page.panel == Panel::None {
content.push(top).push(flows)
} else {
content.push(flows).push(top)
};
frame::page(
ui,
metrics,
header,
scrollable(content)
.style(frame::scroll_style(ui))
.width(Length::Fill)
.height(Length::Fill),
)
}