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

+278
View File
@@ -0,0 +1,278 @@
//! 状态 → 账户页视图模型(页面层不认识 presenter 的其它类型,只认这里)。
//!
//! 三条轨道在 UI 上只有三种「面板」:微软登录面板 / 第三方站表单 / 本地档案表单。
//! 表单里那些**输入串**是纯 UI 状态,归 [`Form`](app 层持有);从 presenter 来的只有
//! 账户列表、登录进度与倒计时。
use iced::widget::image;
use unlucky_i18n::{Key, Lang};
use unlucky_presenter::dto::{AccountKind, AccountView};
use unlucky_presenter::state::AppState;
use super::avatar::{Avatar, Cache};
use super::panels::{authlib_panel, device_panel};
pub use super::panels::{AuthlibPanel, DevicePanel};
/// 三条轨道。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Track {
/// 微软正版(设备码 / 授权码两条流)
Microsoft,
/// 第三方皮肤站(authlib-injector)
Authlib,
/// 本地档案(离线)
Offline,
}
impl Track {
/// 卡片标题的 key。
#[must_use]
pub const fn title_key(self) -> Key {
match self {
Self::Microsoft => Key::AccountTrackMicrosoftTitle,
Self::Authlib => Key::AccountTrackAuthlibTitle,
Self::Offline => Key::AccountTrackOfflineTitle,
}
}
/// 卡片说明的 key。
#[must_use]
pub const fn body_key(self) -> Key {
match self {
Self::Microsoft => Key::AccountTrackMicrosoftBody,
Self::Authlib => Key::AccountTrackAuthlibBody,
Self::Offline => Key::AccountTrackOfflineBody,
}
}
/// 卡片主按钮的 key(微软/第三方是「登录」,本地是「创建档案」)。
#[must_use]
pub const fn action_key(self) -> Key {
match self {
Self::Microsoft => Key::AccountTrackOpen,
Self::Authlib => Key::AccountTrackOpen,
Self::Offline => Key::AccountTrackCreate,
}
}
/// 三条轨道(卡片顺序)。
pub const ALL: [Track; 3] = [Track::Microsoft, Track::Authlib, Track::Offline];
}
/// 当前打开的面板。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Panel {
/// 只画三条轨道入口
#[default]
None,
/// 微软登录面板
Microsoft,
/// 第三方站表单
Authlib,
/// 本地档案表单
Offline,
}
/// 表单的**纯 UI 状态**(app 层持有,随消息改动)。
///
/// 密码只存在这里和「一次 `AppCommand::AddAuthlibAccount` 的载荷」里:不进日志、不进快照、
/// 不进任何回程 DTO(M114 凭据红线)。
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Form {
/// 当前面板
pub panel: Panel,
/// 第三方站地址
pub server: String,
/// 登录名
pub login: String,
/// 密码(一次性的请求值)
pub password: String,
/// 本地档案名
pub offline_name: String,
}
impl Form {
/// 空表单(只画三条轨道)。
pub fn new() -> Self {
Self::default()
}
/// 打开某条轨道的面板(已开着同一个就用原值,避免输入被清掉)。
pub fn open(&mut self, track: Track) {
self.panel = match track {
Track::Microsoft => Panel::Microsoft,
Track::Authlib => Panel::Authlib,
Track::Offline => Panel::Offline,
};
}
/// 关掉面板(**密码一起清掉**:留着没有意义,只是多留一份凭据在内存里)。
pub fn close(&mut self) {
self.password.clear();
self.panel = Panel::None;
}
/// 提交之后清掉一次性凭据(成功与否都清:重试用新输入)。
pub fn clear_secret(&mut self) {
self.password.clear();
}
/// 本地档案名是否已填(`trim` 后非空才算)。
#[must_use]
pub fn offline_ready(&self) -> bool {
!self.offline_name.trim().is_empty()
}
/// 第三方站表单是否已填(三项都非空;地址的语法问题由 host 的规范化兜)。
#[must_use]
pub fn authlib_ready(&self) -> bool {
!self.server.trim().is_empty() && !self.login.trim().is_empty() && !self.password.is_empty()
}
}
/// 列表行 / 当前账户卡片用的数据。
#[derive(Debug, Clone, PartialEq)]
pub struct Row {
/// 稳定 id(切/删/重登都按它派命令)
pub id: String,
/// 展示名
pub name: String,
/// 首字母块的字
pub monogram: String,
/// 首字母块色号(0..6,`profile_id` 稳定推导)
pub tone: u8,
/// 轨道
pub kind: AccountKind,
/// 轨道文案(i18n)
pub kind_label: String,
/// 「添加于 {date}」
pub added: String,
/// 副行:服务器 / 登录名(没有就空)
pub detail: String,
/// 是否是当前启动账户
pub active: bool,
/// 裁好的头像(`None` = 画首字母块)
pub head: Option<image::Handle>,
/// 皮肤原图(皮肤预览区)
pub skin: Option<image::Handle>,
}
/// 整页视图模型。
#[derive(Debug, Clone, PartialEq)]
pub struct Page {
/// 账户行
pub rows: Vec<Row>,
/// 当前启动账户在 `rows` 里的下标
pub active: Option<usize>,
/// 打开的面板
pub panel: Panel,
/// 表单值(渲染要读)
pub form: Form,
/// 微软登录面板
pub device: DevicePanel,
/// 第三方轨面板
pub authlib: AuthlibPanel,
/// 校验通过的服务器名(表单上显示)
pub checked_server: String,
/// 版面宽度(**中栏可用宽**,M104 的 `AgentUi::content_width`;账户页自己的
/// `layout::frame_metrics` 拿它算列宽,与其它页同源)
pub width: f32,
}
impl Page {
/// 从状态与表单构造(`avatars` 是解码缓存,见 [`Cache]`;`width` 是中栏可用宽)。
pub fn build(
state: &AppState,
form: &Form,
avatars: &mut Cache,
lang: Lang,
width: f32,
) -> Self {
let rows: Vec<Row> = state
.accounts
.iter()
.map(|account| row_of(account, avatars, lang))
.collect();
let active = rows.iter().position(|row| row.active);
let device = device_panel(&state.device_login, state.device_code_remaining());
// 登录在进行(或刚失败/刚成功)时,面板**必须**是微软那条轨:否则用户点了登录、
// 后台已经在等设备码,页面上却只剩三张入口卡——那正是「只转圈」的另一种坏法。
let panel = if matches!(device, DevicePanel::Hidden) {
form.panel
} else {
Panel::Microsoft
};
let (authlib, checked_server) = authlib_panel(&state.authlib_login);
// 「校验通过」的服务器名只在该面板打开且地址一致时显示(换了地址就不再是它)
let checked_server = if form.server.trim().is_empty()
|| state.authlib_login.server.trim().is_empty()
|| form.server.trim() != state.authlib_login.server.trim()
{
String::new()
} else {
checked_server
};
Page {
rows,
active,
panel,
form: form.clone(),
device,
authlib,
checked_server,
width,
}
}
/// 当前账户行。
#[must_use]
pub fn active_row(&self) -> Option<&Row> {
self.active.and_then(|index| self.rows.get(index))
}
/// 有没有账户(空态判据)。
#[must_use]
pub fn is_empty(&self) -> bool {
self.rows.is_empty()
}
/// 皮肤预览区要画的那张图(当前账户有皮肤才画)。
#[must_use]
pub fn active_skin(&self) -> Option<&image::Handle> {
self.active_row().and_then(|row| row.skin.as_ref())
}
}
/// 单行。
fn row_of(account: &AccountView, avatars: &mut Cache, lang: Lang) -> Row {
let text = lang.catalog();
let avatar: Avatar = avatars.resolve(&account.id, account.skin.as_ref());
let kind_label = match account.kind {
AccountKind::Microsoft => text.text(Key::AccountKindMicrosoft),
AccountKind::Authlib => text.text(Key::AccountKindAuthlib),
AccountKind::Offline => text.text(Key::AccountKindOffline),
};
let detail = if !account.server_url.is_empty() {
text.render(
Key::AccountServer,
&[("server", account.server_url.as_str())],
)
} else {
account.login_name.clone()
};
Row {
id: account.id.clone(),
name: account.name.clone(),
monogram: account.monogram(),
tone: account.monogram_tone(),
kind: account.kind,
kind_label: kind_label.to_string(),
added: text.render(Key::AccountAddedOn, &[("date", account.created.as_str())]),
detail,
active: account.active,
head: avatar.head,
skin: avatar.skin,
}
}