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

201 lines
6.0 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 页面路由与 AI 助手栏形态(09 §1 / D16)。
//!
//! 栈语义抄 HMCL 的导航栈:**顶层页面互相替换**(实例/下载/日志/设置),
//! 子页面(实例详情、AI 独立页)压栈,`back()` 逐层回退。
//! 本模块刻意不依赖 Slint 类型,便于单元测试;UI 侧枚举映射在 main.rs 的转换层做。
use crate::dto::InstanceId;
/// 一级导航项(左侧导航栏高亮单位)。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum NavSection {
/// 实例
Instances,
/// 下载 / 版本管理 + 内容库(M109:内容库复用这一项,不新增第 6 个图标)
Download,
/// 日志
Logs,
/// 设置
Settings,
}
/// 页面路由。
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Route {
/// G1 实例页(主页)
Instances,
/// G2 下载页(M107/M108 起是「版本管理」)
Download,
/// 内容库(M109):整合包 / Mod / 资源包 / 光影,来源 Modrinth / CurseForge
///
/// 它是**顶层页**(跟版本管理一样整栈替换),但高亮复用 [`NavSection::Download`]:
/// 用户令「下载页只负责下载整合包和Mod/光影」⇒ 同一入口下两块内容,不是一个新图标。
Content,
/// G3 日志页
Logs,
/// G6 设置页
Settings,
/// G7 账户页(三轨登录:微软正版 / 第三方皮肤站 / 本地档案)
///
/// 一级页,但**不进主序列**:入口是导航轨底部的头像位(`shell/nav.rs`),
/// 所以 [`Route::nav`] 对它返回 `None`(不点亮四个主项中的任何一个)。
Accounts,
/// 实例详情(G4):从磁贴点进来,返回时回到实例页
Detail(InstanceId),
/// AI 助手独立页(D16 双形态之一)
Agent,
}
impl Route {
/// 是否是一级导航页(互相替换,不进栈)。
pub fn is_top_level(&self) -> bool {
matches!(
self,
// M109 的 `Content` 与 M114 的 `Accounts` 都是一级页:两个都留。
Self::Instances
| Self::Download
| Self::Content
| Self::Logs
| Self::Settings
| Self::Accounts
)
}
/// 该路由高亮哪个导航项(None = 没有导航项高亮,如 AI 独立页)。
pub fn nav(&self) -> Option<NavSection> {
match self {
Self::Instances | Self::Detail(_) => Some(NavSection::Instances),
Self::Download | Self::Content => Some(NavSection::Download),
Self::Logs => Some(NavSection::Logs),
Self::Settings => Some(NavSection::Settings),
Self::Accounts | Self::Agent => None,
}
}
}
/// AI 栏形态:右侧栏 / 独立全宽页(两形态共享同一份会话状态)。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AiPanelMode {
/// 右侧常驻栏
Docked,
/// 拆出的独立全宽页
Page,
}
/// 导航栈。
#[derive(Debug, Clone)]
pub struct Router {
stack: Vec<Route>,
}
impl Default for Router {
fn default() -> Self {
Self::new()
}
}
impl Router {
/// 启动时停在实例页。
pub fn new() -> Self {
Self {
stack: vec![Route::Instances],
}
}
/// 当前页面。
pub fn route(&self) -> &Route {
// 栈不变量:构造后永远非空(navigate/back 都不允许清空)
self.stack.last().expect("router stack is never empty")
}
/// 栈深度(1 表示没有可回退的子页面)。
pub fn depth(&self) -> usize {
self.stack.len()
}
/// 是否能回退。
pub fn can_go_back(&self) -> bool {
self.stack.len() > 1
}
/// 跳转到某路由:一级页替换整栈,子页面压栈(重复目标不重复压栈)。
pub fn navigate(&mut self, route: Route) {
if route.is_top_level() {
self.stack.clear();
self.stack.push(route);
return;
}
if self.stack.last() == Some(&route) {
return;
}
self.stack.push(route);
}
/// 回退一层;已在根页面时返回 `false`。
pub fn back(&mut self) -> bool {
if self.stack.len() <= 1 {
return false;
}
self.stack.pop();
true
}
/// 当前 AI 栏形态(栈顶是 Agent 页即为独立页形态)。
pub fn ai_mode(&self) -> AiPanelMode {
if self.route() == &Route::Agent {
AiPanelMode::Page
} else {
AiPanelMode::Docked
}
}
}
#[cfg(test)]
mod tests {
use super::{AiPanelMode, NavSection, Route, Router};
#[test]
fn top_level_navigation_replaces_the_stack() {
let mut router = Router::new();
router.navigate(Route::Detail("a".into()));
assert_eq!(router.depth(), 2);
router.navigate(Route::Download);
assert_eq!(router.depth(), 1);
assert_eq!(router.route(), &Route::Download);
}
#[test]
fn detail_pushes_and_back_pops_to_instances() {
let mut router = Router::new();
router.navigate(Route::Detail("survival".into()));
assert!(router.can_go_back());
assert_eq!(router.route().nav(), Some(NavSection::Instances));
assert!(router.back());
assert_eq!(router.route(), &Route::Instances);
assert!(!router.back());
}
#[test]
fn agent_page_switches_ai_panel_mode() {
let mut router = Router::new();
assert_eq!(router.ai_mode(), AiPanelMode::Docked);
router.navigate(Route::Agent);
assert_eq!(router.ai_mode(), AiPanelMode::Page);
router.back();
assert_eq!(router.ai_mode(), AiPanelMode::Docked);
}
#[test]
fn navigating_to_the_same_sub_route_does_not_grow_the_stack() {
let mut router = Router::new();
router.navigate(Route::Agent);
router.navigate(Route::Agent);
assert_eq!(router.depth(), 2);
assert_eq!(router.route().nav(), None);
}
}