diff --git a/crates/app-iced/src/app/update/account.rs b/crates/app-iced/src/app/update/account.rs index d248487..9c18bd8 100644 --- a/crates/app-iced/src/app/update/account.rs +++ b/crates/app-iced/src/app/update/account.rs @@ -14,7 +14,7 @@ //! `Task` 是惰性 stream,丢掉返回值等于永不执行(日志页的 Copy summary 就是正确写法)。 use iced::Task; -use unlucky_presenter::dto::{AccountKind, AppCommand, AuthlibRequest}; +use unlucky_presenter::dto::{AccountKind, AppCommand, AuthlibRequest, DeviceLoginPhase}; use crate::app::Message; use crate::pages::account::{Hint, Message as Account, Panel, Track}; @@ -43,8 +43,13 @@ impl App { } Account::Close => { self.account_form.close(); - // 关面板同时收掉登录任务(避免关掉面板后后台还在轮询、也不会凭空多一个账户) - if self.state.device_login.phase.busy() || self.state.authlib_login.phase.busy() { + // 关面板必须把**两条轨**的登录态都收回 Idle(复审 r2 P2-1:只看 `busy()` 时, + // `Failed`/`Succeeded` 不在 busy ⇒ 状态仍是非 Idle ⇒ `Page::build` 又把面板 + // 强制回 Microsoft,按钮成了死按钮)。`CancelDeviceLogin` 在 host 侧本来就 + // 同时收两条轨并发 Idle,所以判据用「非 Idle」而不是「busy」。 + if self.state.device_login.phase != DeviceLoginPhase::Idle + || self.state.authlib_login.phase != DeviceLoginPhase::Idle + { self.dispatch(AppCommand::CancelDeviceLogin); } self.view_dirty = true; @@ -188,6 +193,11 @@ pub(super) fn force_account_state(app: &mut App) { app.account_form.login = "steve@example.com".to_string(); app.account_form.password = "not-a-real-password".to_string(); } + // 「关掉之后」:mock 给的是 failed 种子,这里按一次 Close(走真实的 + // `on_account` → `CancelDeviceLogin` 通道),出「面板真的关了」的对照图。 + "closed" => { + let _ = app.on_account(crate::pages::account::Message::Close); + } // 有账号时「Add an account」展开态(P2-4 的收敛后形态) "add" => { app.account_form.section_open = true; @@ -198,7 +208,99 @@ pub(super) fn force_account_state(app: &mut App) { #[cfg(test)] mod tests { - use crate::pages::account::{Form, Panel, Track}; + use unlucky_design::color::SchemeFlavor; + use unlucky_presenter::dto::{ + AppEvent, AuthlibLoginView, CoreEvent, DeviceLoginPhase, DeviceLoginView, LoginFailure, + }; + use unlucky_presenter::mock::MockBackend; + use unlucky_presenter::settings_bridge::SettingsBridge; + + use crate::pages::account::{Form, Message as Account, Panel, Track}; + use crate::theme::Families; + + use super::App; + + /// 不碰磁盘的 App(mock 后端 + 内存设置桥),与 `app/tests.rs` 的 `headless` 同源。 + fn headless() -> App { + App::with_presenter( + crate::cli::Options::default(), + Families { + text: "Segoe UI", + display: "Segoe UI", + mono: "Consolas", + }, + SchemeFlavor::Light, + Box::new(MockBackend::new()), + SettingsBridge::memory_only(), + ) + } + + fn seed(app: &mut App, event: CoreEvent) { + app.state.apply(AppEvent::Core(event)); + app.refresh_view(); + } + + /// **复审 r2 P2-1 的回归闸**:登录失败/成功态下按 `Close`,面板必须真的关掉。 + /// + /// 为什么这条以前是死按钮:`Page::build` 只要 `device_login.phase != Idle` 就把面板强制 + /// 回 Microsoft,而 `Close` 只在 `busy()`(Requesting/Waiting)时才派 `CancelDeviceLogin` + /// ⇒ Failed/Succeeded 下点了没反应。现在判据改成「非 Idle」,两条轨都覆盖。 + /// + /// 抓得住:判据退回 `busy()`;`Close` 忘了 dispatch;`CancelDeviceLogin` 没有把状态收回 Idle。 + /// 抓不住:`CancelDeviceLogin` 在**真 host** 上的副作用(这里跑的是 mock,但 mock 已按 + /// 真 host 的契约发 Idle,见 `presenter/src/mock.rs`)。 + #[test] + fn close_really_closes_the_login_panel_on_both_tracks() { + // ① 微软轨:失败态 + let mut app = headless(); + seed( + &mut app, + CoreEvent::DeviceLoginChanged(DeviceLoginView { + phase: DeviceLoginPhase::Failed, + message: "微软拒绝了这次登录:AADSTS7000218(HTTP 403)".to_string(), + failure: Some(LoginFailure::NotWhitelisted), + ..DeviceLoginView::new() + }), + ); + assert_eq!(app.account.panel, Panel::Microsoft, "失败态面板要显示"); + let _ = app.on_account(Account::Close); + assert_eq!(app.account.panel, Panel::None, "Close 之后面板必须关掉"); + assert_eq!(app.state.device_login.phase, DeviceLoginPhase::Idle); + + // ② 微软轨:成功态(同样不在 busy 里) + let mut app = headless(); + seed( + &mut app, + CoreEvent::DeviceLoginChanged(DeviceLoginView::succeeded("Steve")), + ); + assert_eq!(app.account.panel, Panel::Microsoft); + let _ = app.on_account(Account::Close); + assert_eq!(app.account.panel, Panel::None, "成功提示也要能关掉"); + + // ③ 第三方轨:失败态 + let mut app = headless(); + seed( + &mut app, + CoreEvent::AuthlibLoginChanged(AuthlibLoginView { + phase: DeviceLoginPhase::Failed, + server: "https://example.invalid/api/yggdrasil/".to_string(), + server_name: String::new(), + message: "服务器返回 HTTP 404".to_string(), + failure: Some(LoginFailure::NotAuthlibServer), + account_name: String::new(), + }), + ); + app.account_form.open(Track::Authlib); + app.refresh_view(); + assert_eq!( + app.account.panel, + Panel::Authlib, + "第三方轨失败态面板要显示" + ); + let _ = app.on_account(Account::Close); + assert_eq!(app.account.panel, Panel::None, "第三方轨也要能关掉"); + assert_eq!(app.state.authlib_login.phase, DeviceLoginPhase::Idle); + } /// 表单态是纯 UI 状态:打开/关闭面板不派命令,但会把面板切对。 /// diff --git a/crates/app-iced/src/pages/account/forms.rs b/crates/app-iced/src/pages/account/forms.rs index 2dd7370..378ba68 100644 --- a/crates/app-iced/src/pages/account/forms.rs +++ b/crates/app-iced/src/pages/account/forms.rs @@ -256,7 +256,7 @@ fn status_line<'a>(ui: &'a Ui, page: &'a Page, catalog: Catalog) -> Element<'a, "", ui.color(Role::OnSurfaceVariant), ), - AuthlibPanel::Running { .. } => frame::text_role( + AuthlibPanel::Running => frame::text_role( ui, TypeRole::LabelMedium, false, @@ -271,7 +271,7 @@ fn status_line<'a>(ui: &'a Ui, page: &'a Page, catalog: Catalog) -> Element<'a, ui.color(Role::Primary), ), // 失败只画**原因码对应的 i18n** + 一行 Code(原文是中文诊断串,上屏就是语言泄漏) - AuthlibPanel::Failed { failure, .. } => column![ + AuthlibPanel::Failed { failure } => column![ frame::text_role( ui, TypeRole::LabelMedium, diff --git a/crates/app-iced/src/pages/account/login.rs b/crates/app-iced/src/pages/account/login.rs index 569887c..17a9455 100644 --- a/crates/app-iced/src/pages/account/login.rs +++ b/crates/app-iced/src/pages/account/login.rs @@ -32,21 +32,19 @@ pub fn panel<'a>( ) -> Element<'a, PageMessage> { match device { DevicePanel::Hidden => idle(ui, catalog), - DevicePanel::Requesting { message } => notice(ui, message, catalog), + DevicePanel::Requesting => requesting(ui, catalog), DevicePanel::Code { code, link, remaining, expired, - .. } => device_code(ui, code, link, *remaining, *expired, catalog), DevicePanel::Browser { link, remaining, expired, - .. } => browser(ui, link, *remaining, *expired, catalog), - DevicePanel::Failed { failure, .. } => failed(ui, *failure, catalog), + DevicePanel::Failed { failure } => failed(ui, *failure, catalog), DevicePanel::Succeeded { name } => succeeded(ui, name, catalog), } } @@ -82,10 +80,10 @@ fn idle<'a>(ui: &'a Ui, catalog: Catalog) -> Element<'a, PageMessage> { frame::card(ui, body) } -/// 「正在申请/正在校验」:一句**i18n**状态 + 取消。 +/// 「正在申请」:一句 i18n 状态 + 取消。 /// -/// 不复读 presenter 的 `message`:那是后端诊断串(中文),上屏就是语言泄漏(复审 r1 P1-1)。 -fn notice<'a>(ui: &'a Ui, _message: &str, catalog: Catalog) -> Element<'a, PageMessage> { +/// presenter 那边的进度串是中文诊断(只进日志),面板态里**根本没有**这个字段可画。 +fn requesting<'a>(ui: &'a Ui, catalog: Catalog) -> Element<'a, PageMessage> { let body = column![ frame::text_role( ui, diff --git a/crates/app-iced/src/pages/account/panels.rs b/crates/app-iced/src/pages/account/panels.rs index fd27a8f..5feb6b9 100644 --- a/crates/app-iced/src/pages/account/panels.rs +++ b/crates/app-iced/src/pages/account/panels.rs @@ -2,6 +2,13 @@ //! //! 从 `model.rs` 拆出来守 400 行硬线:这些类型是「UI 要画什么」的枚举, //! 判定逻辑(倒计时 → 已过期、服务发现 → 可用)也在这里,页面与表单只消费结果。 +//! +//! ## 为什么这里**没有** `message` / `detail` 字段(M114 r3 / 复审 r2 P2-2) +//! +//! 后端的原始诊断串是中文人话,只该进日志。r2 时它们还以 `message`/`detail` 存在于此, +//! 「页面把它们画上屏」只能靠一条**平行实现**的取样断言去拦(复审用变异证明那条断言是哑的)。 +//! 现在直接把字段删掉:视图**没有材料可画**,想画原文是编译错误——比任何测试都硬。 +//! 原文仍在 presenter 的 `DeviceLoginView/AuthlibLoginView::message` 里(只进 `eprintln!`)。 use unlucky_presenter::dto::{AuthlibLoginView, DeviceLoginPhase, DeviceLoginView, LoginFailure}; @@ -10,11 +17,8 @@ use unlucky_presenter::dto::{AuthlibLoginView, DeviceLoginPhase, DeviceLoginView pub enum DevicePanel { /// 没有登录在跑 Hidden, - /// 正在申请设备码 - Requesting { - /// 状态文案(presenter 给的,已脱敏) - message: String, - }, + /// 正在申请设备码(进度文案由页面按 i18n 出) + Requesting, /// 设备码等待用户操作 Code { /// 用户码 @@ -25,8 +29,6 @@ pub enum DevicePanel { remaining: Option, /// 是否已过期 expired: bool, - /// 状态文案 - message: String, }, /// 授权码流:等浏览器回调 Browser { @@ -36,15 +38,11 @@ pub enum DevicePanel { remaining: Option, /// 是否已过期 expired: bool, - /// 状态文案 - message: String, }, - /// 失败(**原因码上屏,原文只进日志**) + /// 失败(**只有原因码**;原文在 presenter 的日志里) Failed { /// 原因码(页面据此选 i18n) failure: LoginFailure, - /// 原始诊断(不上屏) - detail: String, }, /// 成功 Succeeded { @@ -59,23 +57,16 @@ pub enum AuthlibPanel { /// 没在跑 Idle, /// 正在校验/登录 - Running { - /// 状态文案 - message: String, - }, + Running, /// 地址校验通过(服务发现拿到服务器名) Ready { /// 服务器名 server_name: String, - /// 状态文案 - message: String, }, - /// 失败(**原因码上屏,原文只进日志**) + /// 失败(**只有原因码**;原文在 presenter 的日志里) Failed { /// 原因码(页面据此选 i18n) failure: LoginFailure, - /// 原始诊断(不上屏) - detail: String, }, /// 成功 Succeeded { @@ -89,16 +80,13 @@ pub(super) fn device_panel(view: &DeviceLoginView, remaining: Option) -> De let expired = remaining == Some(0); match view.phase { DeviceLoginPhase::Idle => DevicePanel::Hidden, - DeviceLoginPhase::Requesting => DevicePanel::Requesting { - message: view.message.clone(), - }, + DeviceLoginPhase::Requesting => DevicePanel::Requesting, DeviceLoginPhase::Waiting => { if view.user_code.trim().is_empty() { DevicePanel::Browser { link: view.verification_uri.clone(), remaining, expired, - message: view.message.clone(), } } else { DevicePanel::Code { @@ -106,13 +94,11 @@ pub(super) fn device_panel(view: &DeviceLoginView, remaining: Option) -> De link: view.qr_link.clone(), remaining, expired, - message: view.message.clone(), } } } DeviceLoginPhase::Failed => DevicePanel::Failed { failure: view.failure.unwrap_or(LoginFailure::Other), - detail: view.message.clone(), }, DeviceLoginPhase::Succeeded => DevicePanel::Succeeded { name: view.account_name.clone(), @@ -124,16 +110,10 @@ pub(super) fn device_panel(view: &DeviceLoginView, remaining: Option) -> De pub(super) fn authlib_panel(view: &AuthlibLoginView) -> (AuthlibPanel, String) { match view.phase { DeviceLoginPhase::Idle => (AuthlibPanel::Idle, String::new()), - DeviceLoginPhase::Requesting => ( - AuthlibPanel::Running { - message: view.message.clone(), - }, - String::new(), - ), + DeviceLoginPhase::Requesting => (AuthlibPanel::Running, String::new()), DeviceLoginPhase::Waiting => ( AuthlibPanel::Ready { server_name: view.server_name.clone(), - message: view.message.clone(), }, view.server_name.clone(), ), @@ -146,7 +126,6 @@ pub(super) fn authlib_panel(view: &AuthlibLoginView) -> (AuthlibPanel, String) { DeviceLoginPhase::Failed => ( AuthlibPanel::Failed { failure: view.failure.unwrap_or(LoginFailure::Other), - detail: view.message.clone(), }, String::new(), ), diff --git a/crates/app-iced/src/pages/account/tests.rs b/crates/app-iced/src/pages/account/tests.rs index eba4b8a..debfc41 100644 --- a/crates/app-iced/src/pages/account/tests.rs +++ b/crates/app-iced/src/pages/account/tests.rs @@ -3,7 +3,7 @@ //! | 用例 | 抓得住什么 | //! |---|---| //! | `a_jwt_injected_into_events_never_reaches_the_page` | **凭据红线**:DTO → 状态 → 视图模型这一路漏了脱敏 | -//! | `a_failure_with_a_provider_code_renders_english_and_the_code` | **语言泄漏(r1 p1)**:后端中文诊断串又直接上屏了 | +//! | `a_failure_with_a_provider_code_renders_english_and_the_code` | **语言泄漏(r1 p1)**:模型 + i18n 查表这一路又出现中文(视图层由类型禁画原文,见 `tests_text` 的边界说明) | //! | `the_device_code_panel_shows_a_countdown_and_expires` | 设备码「只转圈」/ 倒计时不是人话格式 | use unlucky_i18n::{Key, Lang}; @@ -42,8 +42,8 @@ const JWT: &str = /// **凭据红线(审核项)**:把 JWT 形状串注入事件 → 状态与视图模型里都不许出现。 /// -/// 抓得住:presenter 状态层的脱敏被删(`AppState::apply_device_login` / `apply_authlib_login`), -/// 或页面把 DTO 的错误原文直接搬上屏。 +/// 抓得住:presenter 状态层的脱敏被删(`AppState::apply_device_login` / `apply_authlib_login`)。 +/// 视图层不在此列:面板态里已无 `message`/`detail` 字段,想画原文是编译错误。 #[test] fn a_jwt_injected_into_events_never_reaches_the_page() { let mut state = AppState::new(); @@ -86,10 +86,12 @@ fn a_jwt_injected_into_events_never_reaches_the_page() { assert!(!text.contains("⟪redacted⟫"), "脱敏占位符也不该上屏:{text}"); } -/// **语言泄漏(复审 r1 p1)**:带 AADSTS 码的失败事件 → 英文视图里不许有中文,且要带 code。 +/// **语言泄漏(复审 r1 p1)**:带 AADSTS 码的失败事件 → 英文取样里不许有中文,且要带 code。 /// -/// 抓得住:有人又把 `view.message`(core 的中文诊断串)画回面板上;或者码→i18n 的映射被摘。 -/// 抓不住:catalog 里某条英文译文本身写得差(那只能人工审)。 +/// 抓得住:`failure_body_key` 的码→key 映射被摘/接错;DTO 或状态层把中文混进模型字段。 +/// 抓不住(复审 r2 P2-2 实测过):视图层把原文画上屏——这条断言是**平行实现**。 +/// 那道回归改由**类型**拦:`DevicePanel`/`AuthlibPanel` 已无 `message`/`detail` 字段。 +/// 也抓不住:catalog 里某条英文译文本身写得差(那只能人工审图)。 #[test] fn a_failure_with_a_provider_code_renders_english_and_the_code() { let mut state = AppState::new(); @@ -306,7 +308,7 @@ fn login_phases_map_to_distinct_panels() { }, ))); let page = build(&state, &Form::new()); - assert!(matches!(page.device, DevicePanel::Requesting { .. })); + assert!(matches!(page.device, DevicePanel::Requesting)); // 进行中也只有 i18n(不再复读 presenter 的中文状态串) assert!(!has_cjk(&on_screen(&page))); diff --git a/crates/app-iced/src/pages/account/tests_text.rs b/crates/app-iced/src/pages/account/tests_text.rs index b518458..a10299b 100644 --- a/crates/app-iced/src/pages/account/tests_text.rs +++ b/crates/app-iced/src/pages/account/tests_text.rs @@ -1,8 +1,17 @@ -//! 「**页面会画什么**」的取样器:把视图模型 + 视图层对码的 i18n 查表结果拼成一串。 +//! 「**模型这一层会给出什么**」的取样器:视图模型字段 + **按码做的 i18n 查表**,拼成一串。 +//! +//! ## 边界(复审 r2 P2-2 订正,别把这条读成「视图覆盖」) +//! +//! 这是**平行实现**:它自己调 `catalog` 查表,**不调用** `login::panel` / `forms::status_line`, +//! 也不遍历 iced 的元素树。所以它证明的是「模型 + i18n 表里没有中文」,**不证明**「视图真的照它画」。 +//! +//! 视图层靠两道别的闸: +//! 1. **类型**:`DevicePanel` / `AuthlibPanel` 里**没有** `message`/`detail` 字段(r3 删掉了), +//! 想画后端原文是编译错误,不是测试问题; +//! 2. **出图**:`DevDocs/artifacts/m114/after/*` 由人(tower/复审)逐张看。 //! //! 为什么单独一个文件:`pages/account/tests.rs` 贴着 400 行硬线,而这段是**纯取样** -//! (不给任何断言)——断言都在 `tests.rs` 里。语言泄漏(复审 r1 P1-1)与凭据红线两条判据 -//! 都依赖它:只有把「模型字段 + i18n 文案」一起取样,才能证明「英文界面里没有中文」。 +//! (不给任何断言)——断言都在 `tests.rs` 里。 use unlucky_i18n::{Key, Lang}; use unlucky_presenter::dto::AccountNoticeCode; @@ -47,13 +56,12 @@ pub(super) fn on_screen(page: &Page) -> String { } match &page.device { DevicePanel::Hidden => {} - DevicePanel::Requesting { .. } => out.push_str(catalog.text(Key::AccountDeviceRequesting)), + DevicePanel::Requesting => out.push_str(catalog.text(Key::AccountDeviceRequesting)), DevicePanel::Code { code, link, remaining, expired, - .. } => { out.push_str(code); out.push_str(link); @@ -65,7 +73,7 @@ pub(super) fn on_screen(page: &Page) -> String { } } DevicePanel::Browser { link, .. } => out.push_str(link), - DevicePanel::Failed { failure, .. } => { + DevicePanel::Failed { failure } => { out.push_str(catalog.text(failure_body_key(*failure))); out.push_str(&catalog.render(Key::AccountFailureCode, &[("code", failure.code())])); } @@ -75,9 +83,9 @@ pub(super) fn on_screen(page: &Page) -> String { } match &page.authlib { AuthlibPanel::Idle => {} - AuthlibPanel::Running { .. } => out.push_str(catalog.text(Key::AccountServerChecking)), - AuthlibPanel::Ready { server_name, .. } => out.push_str(server_name), - AuthlibPanel::Failed { failure, .. } => out.push_str(failure.code()), + AuthlibPanel::Running => out.push_str(catalog.text(Key::AccountServerChecking)), + AuthlibPanel::Ready { server_name } => out.push_str(server_name), + AuthlibPanel::Failed { failure } => out.push_str(failure.code()), AuthlibPanel::Succeeded { name } => out.push_str(name), } out diff --git a/crates/app-iced/src/pages/account/widgets.rs b/crates/app-iced/src/pages/account/widgets.rs index d96db4a..d378036 100644 --- a/crates/app-iced/src/pages/account/widgets.rs +++ b/crates/app-iced/src/pages/account/widgets.rs @@ -123,23 +123,20 @@ pub fn alert<'a>( ..container::Style::default() }); - let mut lines = iced::widget::Column::new().spacing(ui.space(Space::X1)); - lines = lines.push(frame::text_role( - ui, - TypeRole::TitleSmall, - true, - title, - ui.color(Role::OnSurface), - )); - lines = lines.push(frame::text_role_owned( - ui, - TypeRole::BodySmall, - false, - body, - ui.color(Role::OnSurfaceVariant), - )); + // 标题与 `Code: …` 同**一行**:`Code` 只是取证用的稳定 id,不值得占一整行高度 + // (1180×760 下多一行就把「All accounts」第三行挤到折线下)。 + let mut title_row = iced::widget::Row::new() + .spacing(ui.space(Space::X2)) + .align_y(Alignment::Center) + .push(frame::text_role( + ui, + TypeRole::TitleSmall, + true, + title, + ui.color(Role::OnSurface), + )); if let Some(code) = code { - lines = lines.push(frame::text_role_owned( + title_row = title_row.push(frame::text_role_owned( ui, TypeRole::LabelSmall, false, @@ -148,6 +145,16 @@ pub fn alert<'a>( )); } + let mut lines = iced::widget::Column::new().spacing(ui.space(Space::X1)); + lines = lines.push(title_row); + lines = lines.push(frame::text_role_owned( + ui, + TypeRole::BodySmall, + false, + body, + ui.color(Role::OnSurfaceVariant), + )); + container( row![ chip, diff --git a/crates/presenter/src/mock.rs b/crates/presenter/src/mock.rs index 7953a9a..8899191 100644 --- a/crates/presenter/src/mock.rs +++ b/crates/presenter/src/mock.rs @@ -284,6 +284,16 @@ impl Backend for MockBackend { ))); } AppCommand::RefreshAll => self.seed_accounts(), + // 与真 host 同一契约:取消登录把**两条轨**都收回 Idle(M114 r3 / 复审 r2 P2-1 的 + // 断言与截图都依赖这条;mock 的 `_ => {}` 兜底会让 Close 看起来「没反应」)。 + AppCommand::CancelDeviceLogin => { + self.emit(AppEvent::Core(CoreEvent::DeviceLoginChanged( + crate::dto::DeviceLoginView::new(), + ))); + self.emit(AppEvent::Core(CoreEvent::AuthlibLoginChanged( + crate::dto::AuthlibLoginView::new(), + ))); + } AppCommand::RefreshLogSessions | AppCommand::StopAgent => {} // 其余指令(安装向导/账户/Java/设置/通知)由真实 host 承接:mock 不模拟 _ => {} diff --git a/crates/presenter/src/mock/accounts.rs b/crates/presenter/src/mock/accounts.rs index ed43ee0..61f78cd 100644 --- a/crates/presenter/src/mock/accounts.rs +++ b/crates/presenter/src/mock/accounts.rs @@ -73,7 +73,8 @@ pub(super) fn device_login(scenario: &str) -> Option { "", )), // 带**原因码**的失败:原文(中文诊断)留在 detail 里只进日志,页面画 i18n + Code - "failed" => Some(DeviceLoginView::failed_as( + // `closed` 用同一份种子,再由 `force_account_state` 按一次「关闭」,出「关掉之后」的图。 + "failed" | "closed" => Some(DeviceLoginView::failed_as( LoginFailure::NotWhitelisted, "微软拒绝了这次登录:AADSTS7000218(HTTP 403)—— 应用白名单还没批准,或网络被拦截", )),