From 4794c162aaf1d6d820b330cab7df6ec4fedebfdc Mon Sep 17 00:00:00 2001 From: kuddev Date: Tue, 25 Aug 2026 11:41:18 +0800 Subject: [PATCH] fix(gpui): align settings and modal interactions --- nebula_app/src/gpui_shell/prelude.rs | 103 +++++++++++++- nebula_app/src/gpui_shell/settings_pane.rs | 39 +++-- .../gpui_shell/settings_pane/font_picker.rs | 28 +++- .../src/gpui_shell/settings_pane/keymap.rs | 1 - nebula_app/src/gpui_shell/terminal/view.rs | 30 ++-- nebula_app/src/gpui_shell/workspace.rs | 134 +++++++++--------- .../src/gpui_shell/workspace/file_tree.rs | 45 +++--- .../src/gpui_shell/workspace/update_dialog.rs | 11 +- nebula_app/src/update_check.rs | 15 ++ 9 files changed, 277 insertions(+), 129 deletions(-) diff --git a/nebula_app/src/gpui_shell/prelude.rs b/nebula_app/src/gpui_shell/prelude.rs index 7ccc1d3..52e7057 100644 --- a/nebula_app/src/gpui_shell/prelude.rs +++ b/nebula_app/src/gpui_shell/prelude.rs @@ -9,10 +9,12 @@ pub use gpui_component::{ ActiveTheme as _, Colorize as _, Disableable as _, Icon, IconName, InteractiveElementExt as _, Selectable as _, Sizable as _, StyledExt as _, TitleBar, alert::{Alert, AlertVariant}, - button::{Button, ButtonVariants as _}, + button::{Button, ButtonVariant, ButtonVariants as _}, checkbox::Checkbox, color_picker::{ColorPicker, ColorPickerEvent, ColorPickerState}, - dialog::{Dialog, DialogAction, DialogButtonProps, DialogClose, DialogFooter}, + dialog::{ + Dialog, DialogAction, DialogButtonProps, DialogClose, DialogDescription, DialogFooter, + }, dock::{ DockArea, DockAreaState, DockItem, Panel, PanelControl, PanelEvent, PanelState, TabPanel, }, @@ -39,10 +41,97 @@ pub use gpui_component::WindowExt as _; // 现有的 glob 用法不受影响。 pub use gpui_component::{ActiveTheme, Colorize}; -use gpui::{Window, px}; +use gpui::{ParentElement as _, SharedString, Styled as _, Window, div, px, relative}; -/// 保留旧壳确认框的 480px 内容宽度;位置和交互采用新版 Dialog。 -pub fn center_confirm_dialog(dialog: Dialog, _window: &Window) -> Dialog { - const DIALOG_W: f32 = 480.0; - dialog.width(px(DIALOG_W)) +const CONFIRM_DIALOG_WIDTH: f32 = 480.0; +const CONFIRM_DIALOG_HORIZONTAL_PADDING: f32 = 26.0; +const CONFIRM_DIALOG_TOP_PADDING: f32 = 26.0; +const CONFIRM_DIALOG_BOTTOM_PADDING: f32 = 20.0; +const CONFIRM_DIALOG_BASE_HEIGHT: f32 = 130.0; +const CONFIRM_DIALOG_BODY_LINE_HEIGHT: f32 = 24.0; + +/// gpui-component 0.5.2 默认把 Dialog 放在视口高度的 1/10;旧 GPUI 壳则 +/// 使用 480px 内容宽度并垂直居中。这里集中恢复旧壳几何,同时保留窄窗口限幅。 +pub fn center_modal_dialog(dialog: Dialog, window: &Window, estimated_height: f32) -> Dialog { + let viewport = window.viewport_size(); + let width = CONFIRM_DIALOG_WIDTH.min((f32::from(viewport.width) - 32.0).max(240.0)); + let margin_top = ((f32::from(viewport.height) - estimated_height) * 0.5).max(16.0); + + dialog + .width(px(width)) + .margin_top(px(margin_top)) + .pt(px(CONFIRM_DIALOG_TOP_PADDING)) + .pb(px(CONFIRM_DIALOG_BOTTOM_PADDING)) + .px(px(CONFIRM_DIALOG_HORIZONTAL_PADDING)) +} + +/// 构造与旧 GPUI 壳一致的确认框。显式 footer 是必要的:固定依赖版本的普通 +/// Dialog 不会仅凭 button_props 生成按钮;DialogAction/DialogClose 负责把鼠标 +/// 点击重新汇入 Enter/Esc 共用的确认与取消回调。 +pub fn confirm_dialog( + dialog: Dialog, + window: &mut Window, + title: impl Into, + description: impl Into, + ok_text: impl Into, + cancel_text: impl Into, + ok_variant: ButtonVariant, +) -> Dialog { + let title = title.into(); + let description = description.into(); + let ok_text = ok_text.into(); + let cancel_text = cancel_text.into(); + + let text_style = window.text_style(); + let body_width = if description.is_empty() { + 0.0 + } else { + f32::from( + window + .text_system() + .shape_line( + description.clone(), + px(16.0), + &[text_style.to_run(description.len())], + None, + ) + .width, + ) + }; + let available_body_width = + (CONFIRM_DIALOG_WIDTH - CONFIRM_DIALOG_HORIZONTAL_PADDING * 2.0).max(1.0); + let body_lines = (body_width / available_body_width).ceil().max(1.0); + let estimated_height = + CONFIRM_DIALOG_BASE_HEIGHT + body_lines * CONFIRM_DIALOG_BODY_LINE_HEIGHT; + + let content_description = description.clone(); + center_modal_dialog(dialog, window, estimated_height) + .close_button(false) + .overlay_closable(false) + .title(div().text_lg().font_semibold().line_height(relative(1.0)).child(title)) + .content(move |content, _, _| { + content.child( + DialogDescription::new() + .text_base() + .line_height(relative(1.5)) + .child(content_description.clone()), + ) + }) + .footer( + DialogFooter::new() + .child( + DialogClose::new() + .child(Button::new("confirm-cancel").label(cancel_text.clone())), + ) + .child(DialogAction::new().child( + Button::new("confirm-ok").label(ok_text.clone()).with_variant(ok_variant), + )), + ) + .button_props( + DialogButtonProps::default() + .ok_text(ok_text) + .ok_variant(ok_variant) + .cancel_text(cancel_text) + .show_cancel(true), + ) } diff --git a/nebula_app/src/gpui_shell/settings_pane.rs b/nebula_app/src/gpui_shell/settings_pane.rs index 56c632f..e560bf8 100644 --- a/nebula_app/src/gpui_shell/settings_pane.rs +++ b/nebula_app/src/gpui_shell/settings_pane.rs @@ -1046,12 +1046,17 @@ impl SettingsPane { let pane = cx.entity().downgrade(); window.open_dialog(cx, move |dialog, window, _cx| { let pane = pane.clone(); - center_confirm_dialog(dialog, window) - .title("让背景图覆盖窗口控件区域?") - .button_props(DialogButtonProps::default().show_cancel(true).ok_text("开启").cancel_text("取消")) - .child(SharedString::from( + confirm_dialog( + dialog, + window, + "让背景图覆盖窗口控件区域?", + SharedString::from( "背景图会延伸到标题栏、窗口按钮、Tab 与 SSH 侧栏下方,低对比度图片可能影响操作可见性;界面仍会保留最低不透明度保护。", - )) + ), + "开启", + "取消", + ButtonVariant::Primary, + ) .on_ok(move |_, _, cx| { let _ = pane.update(cx, |this, cx| { this.persist(&[("background_image_cover_chrome", "1".to_owned())], cx); @@ -1145,7 +1150,15 @@ impl SettingsPane { // 成一次重绘,所以每帧调是安全的。 cx.refresh_windows(); cx.notify(); - self.schedule_slider_persist("opacity", format!("{opacity:.2}"), cx); + // 用户拖动后 opacity 已是显式选择;同时把旧 blur=1 规范成枚举值, + // 否则共享迁移层会再次把 blur=1 + opacity=1.00 解读成默认 0.82。 + self.schedule_slider_persist( + vec![ + ("opacity", format!("{opacity:.2}")), + ("blur", self.runtime.blur.settings_value().to_owned()), + ], + cx, + ); } /// 背景图透明度是**烘焙进纹理**的(decode + 长边压到 2560 + 逐像素乘 @@ -1158,26 +1171,28 @@ impl SettingsPane { } self.runtime.background_image_opacity = opacity; cx.notify(); - self.schedule_slider_persist("background_image_opacity", format!("{opacity:.2}"), cx); + self.schedule_slider_persist( + vec![("background_image_opacity", format!("{opacity:.2}"))], + cx, + ); } - /// 滑块停手后才落盘。滑块只有 `Change` 事件(组件库没有"松手"信号), - /// 所以用 debounce:每个新事件覆盖上一个待落盘任务,旧 `Task` drop 即取消。 + /// `Change` 同时覆盖鼠标拖动和键盘调整,因此统一用 debounce 延后落盘: + /// 每个新事件覆盖上一个待落盘任务,旧 `Task` drop 即取消。 /// /// 落盘那一次会走完整的 [`Self::persist`](写盘 + 设置重载 + 每终端 /// `apply_settings` + 主题重建 + 壁纸重烘焙),跑一次没问题;每帧跑就是 /// 2026-08-21 报的"拖拽卡顿的要死"。 fn schedule_slider_persist( &mut self, - key: &'static str, - value: String, + updates: Vec<(&'static str, String)>, cx: &mut Context, ) { let executor = cx.background_executor().clone(); self.slider_persist = Some(cx.spawn(async move |this, cx| { executor.timer(Duration::from_millis(180)).await; let _ = this.update(cx, |this, cx| { - this.persist(&[(key, value)], cx); + this.persist(&updates, cx); }); })); } diff --git a/nebula_app/src/gpui_shell/settings_pane/font_picker.rs b/nebula_app/src/gpui_shell/settings_pane/font_picker.rs index 39c8ecd..c57670e 100644 --- a/nebula_app/src/gpui_shell/settings_pane/font_picker.rs +++ b/nebula_app/src/gpui_shell/settings_pane/font_picker.rs @@ -209,8 +209,16 @@ impl SettingsPane { .child( gpui::canvas( move |bounds, _, cx| { - let _ = picker.update(cx, |picker, _| { + let _ = picker.update(cx, |picker, cx| { + if picker.font_picker_trigger_bounds == Some(bounds) { + return; + } picker.font_picker_trigger_bounds = Some(bounds); + // anchored() 消费的是上一轮 render 拿到的窗口坐标; + // 聚焦自动滚动后补一帧,弹层才会继续贴住输入框。 + if picker.font_picker_open { + cx.notify(); + } }); }, |_, _, _, _| {}, @@ -292,6 +300,8 @@ impl SettingsPane { .enumerate() .map(|(index, family)| { let family_name: SharedString = family.clone().into(); + let display_name: SharedString = font_display_name(family).into(); + let tooltip_name = display_name.clone(); h_flex() .id(SharedString::from(format!("font-chain-row-{index}"))) .h(px(38.0)) @@ -312,12 +322,17 @@ impl SettingsPane { ) .child( div() + .id(("font-chain-name", index)) .flex_1() .min_w_0() .truncate() .text_sm() .font_family(family_name) - .child(font_display_name(family)), + .tooltip(move |window, cx| { + gpui_component::tooltip::Tooltip::new(tooltip_name.clone()) + .build(window, cx) + }) + .child(display_name), ) .when(index == 0, |row| row.child(badge("主", muted))) .child(Self::chain_action( @@ -358,6 +373,8 @@ impl SettingsPane { let select_name = entry.name.clone(); let append_name = entry.name.clone(); let family: SharedString = entry.name.clone().into(); + let display_name: SharedString = font_display_name(&entry.name).into(); + let tooltip_name = display_name.clone(); let bundled = entry.source == FontSource::Bundled; let already_selected = families.iter().any(|family| family.eq_ignore_ascii_case(&entry.name)); @@ -374,12 +391,17 @@ impl SettingsPane { .hover(|row| row.bg(hover_bg)) .child( div() + .id(("font-available-name", index)) .flex_1() .min_w_0() .truncate() .text_sm() .font_family(family) - .child(font_display_name(&entry.name)), + .tooltip(move |window, cx| { + gpui_component::tooltip::Tooltip::new(tooltip_name.clone()) + .build(window, cx) + }) + .child(display_name), ) .when(bundled, |row| row.child(badge("内置", muted))) .when(entry.source == FontSource::Imported, |row| { diff --git a/nebula_app/src/gpui_shell/settings_pane/keymap.rs b/nebula_app/src/gpui_shell/settings_pane/keymap.rs index a76f5c4..680281a 100644 --- a/nebula_app/src/gpui_shell/settings_pane/keymap.rs +++ b/nebula_app/src/gpui_shell/settings_pane/keymap.rs @@ -371,7 +371,6 @@ impl SettingsPane { // 占位「搜索动作或按键…」,下面再空 12px 才到冲突条 / 分组。 v_flex() .w_full() - .gap(px(GROUP_GAP)) // 捕获态的「点击任何位置先撤销」(旧壳 input/chrome.rs 的统一撤 // 销合同):行的 mouse_down 会 stop_propagation 自行处理转移/ // 取消,搜索框这里显式撤销(旧壳点搜索框 = blur 捕获),其余 diff --git a/nebula_app/src/gpui_shell/terminal/view.rs b/nebula_app/src/gpui_shell/terminal/view.rs index d1aaa48..0b5f5fe 100644 --- a/nebula_app/src/gpui_shell/terminal/view.rs +++ b/nebula_app/src/gpui_shell/terminal/view.rs @@ -33,9 +33,7 @@ use super::session::{self, TerminalSession}; use super::suggest; use super::{KEY_CONTEXT, TerminalBackTab, TerminalTab}; use crate::gpui_shell::config::Settings; -use crate::gpui_shell::prelude::{ - ActiveTheme as _, Colorize as _, DialogButtonProps, center_confirm_dialog, -}; +use crate::gpui_shell::prelude::{ActiveTheme as _, ButtonVariant, Colorize as _, confirm_dialog}; use crate::{config::UiConfig, font_install::REQUIRED_FONT_FAMILY}; use futures::StreamExt as _; @@ -1398,19 +1396,19 @@ impl TerminalView { window.open_dialog(cx, move |dialog, window, _cx| { let text = text.clone(); let view = view.clone(); - center_confirm_dialog(dialog, window) - .title(title.clone()) - .button_props( - DialogButtonProps::default() - .ok_text(ok_text.clone()) - .cancel_text(cancel_text.clone()) - .show_cancel(true), - ) - .child(body.clone()) - .on_ok(move |_, _window, cx| { - let _ = view.update(cx, |this, cx| this.paste_now(&text, cx)); - true - }) + confirm_dialog( + dialog, + window, + title.clone(), + body.clone(), + ok_text.clone(), + cancel_text.clone(), + ButtonVariant::Primary, + ) + .on_ok(move |_, _window, cx| { + let _ = view.update(cx, |this, cx| this.paste_now(&text, cx)); + true + }) }); } diff --git a/nebula_app/src/gpui_shell/workspace.rs b/nebula_app/src/gpui_shell/workspace.rs index b990923..4658cc6 100644 --- a/nebula_app/src/gpui_shell/workspace.rs +++ b/nebula_app/src/gpui_shell/workspace.rs @@ -106,6 +106,10 @@ gpui::actions!( /// 不能挂全局:否则 CC/Codex 的终止对话键到不了 PTY。 const PALETTE_KEY_CONTEXT: &str = "NebulaCommandPalette"; +/// 侧栏槽位右缘到正文卡可见左缘的 8px 卡缝;拖拽热区必须对准后者。 +const SIDEBAR_RESIZE_VISUAL_OFFSET: f32 = 8.0; +const SIDEBAR_RESIZE_HANDLE_WIDTH: f32 = 6.0; + /// 工作区静态默认绑定的 combo 集([`init`] 的镜像)。撤销已失效的自定义 /// 注入时要排除:gpui 的 NoAction 打在静态默认键上会误杀基础功能。 const STATIC_DEFAULT_COMBOS: &[&str] = &[ @@ -1571,32 +1575,31 @@ impl NebulaWorkspace { window.open_dialog(cx, move |dialog, window, _cx| { let confirm_workspace = confirm_workspace.clone(); let close_workspace = close_workspace.clone(); - center_confirm_dialog(dialog, window) - .title("关闭窗口?") - .button_props( - DialogButtonProps::default() - .ok_text("关闭") - .ok_variant(gpui_component::button::ButtonVariant::Danger) - .cancel_text("取消") - .show_cancel(true), - ) - .child(body.clone()) - .on_ok(move |_, window, cx| { - let _ = confirm_workspace.update(cx, |workspace, cx| { - workspace.save_clean_window_session(cx); - workspace.window_close_confirm_open = false; - // `remove_window` 是确认后的最终动作,不会重新触发 - // should-close,从而避免再次弹出同一确认框。 - window.remove_window(); - }); - true - }) - .on_close(move |_, _, cx| { - let _ = close_workspace.update(cx, |workspace, cx| { - workspace.window_close_confirm_open = false; - cx.notify(); - }); - }) + confirm_dialog( + dialog, + window, + "关闭窗口?", + body.clone(), + "关闭", + "取消", + ButtonVariant::Danger, + ) + .on_ok(move |_, window, cx| { + let _ = confirm_workspace.update(cx, |workspace, cx| { + workspace.save_clean_window_session(cx); + workspace.window_close_confirm_open = false; + // `remove_window` 是确认后的最终动作,不会重新触发 + // should-close,从而避免再次弹出同一确认框。 + window.remove_window(); + }); + true + }) + .on_close(move |_, _, cx| { + let _ = close_workspace.update(cx, |workspace, cx| { + workspace.window_close_confirm_open = false; + cx.notify(); + }); + }) }); false } @@ -1616,22 +1619,21 @@ impl NebulaWorkspace { let workspace = cx.entity().downgrade(); window.open_dialog(cx, move |dialog, window, _cx| { let workspace = workspace.clone(); - center_confirm_dialog(dialog, window) - .title("关闭此分栏?") - .button_props( - DialogButtonProps::default() - .ok_text("关闭") - .ok_variant(gpui_component::button::ButtonVariant::Danger) - .cancel_text("取消") - .show_cancel(true), - ) - .child(body.clone()) - .on_ok(move |_, window, cx| { - let _ = workspace.update(cx, |workspace, cx| { - workspace.close_pane(tab_ix, pane_id, window, cx); - }); - true - }) + confirm_dialog( + dialog, + window, + "关闭此分栏?", + body.clone(), + "关闭", + "取消", + ButtonVariant::Danger, + ) + .on_ok(move |_, window, cx| { + let _ = workspace.update(cx, |workspace, cx| { + workspace.close_pane(tab_ix, pane_id, window, cx); + }); + true + }) }); } @@ -1644,22 +1646,21 @@ impl NebulaWorkspace { let workspace = cx.entity().downgrade(); window.open_dialog(cx, move |dialog, window, _cx| { let workspace = workspace.clone(); - center_confirm_dialog(dialog, window) - .title("关闭此标签页?") - .button_props( - DialogButtonProps::default() - .ok_text("关闭") - .ok_variant(gpui_component::button::ButtonVariant::Danger) - .cancel_text("取消") - .show_cancel(true), - ) - .child(body.clone()) - .on_ok(move |_, window, cx| { - let _ = workspace.update(cx, |workspace, cx| { - workspace.close_tab(tab_ix, window, cx); - }); - true - }) + confirm_dialog( + dialog, + window, + "关闭此标签页?", + body.clone(), + "关闭", + "取消", + ButtonVariant::Danger, + ) + .on_ok(move |_, window, cx| { + let _ = workspace.update(cx, |workspace, cx| { + workspace.close_tab(tab_ix, window, cx); + }); + true + }) }); } @@ -4508,8 +4509,11 @@ impl Render for NebulaWorkspace { .absolute() .top_0() .bottom_0() - .left(px(-3.0)) - .w(px(6.0)) + .left(px( + SIDEBAR_RESIZE_VISUAL_OFFSET + - SIDEBAR_RESIZE_HANDLE_WIDTH * 0.5, + )) + .w(px(SIDEBAR_RESIZE_HANDLE_WIDTH)) .cursor_col_resize() .on_mouse_down( MouseButton::Left, @@ -4621,10 +4625,12 @@ impl Render for NebulaWorkspace { .occlude() .cursor_col_resize() .on_mouse_move(cx.listener(|this, event: &gpui::MouseMoveEvent, _, cx| { - let width = f32::from(event.position.x).clamp( - nebula_settings::MIN_SIDEBAR_WIDTH, - nebula_settings::MAX_SIDEBAR_WIDTH, - ); + let width = (f32::from(event.position.x) + - SIDEBAR_RESIZE_VISUAL_OFFSET) + .clamp( + nebula_settings::MIN_SIDEBAR_WIDTH, + nebula_settings::MAX_SIDEBAR_WIDTH, + ); if (width - this.sidebar_width).abs() >= 0.5 { this.sidebar_width = width; cx.notify(); diff --git a/nebula_app/src/gpui_shell/workspace/file_tree.rs b/nebula_app/src/gpui_shell/workspace/file_tree.rs index 4bfc4f7..5824ceb 100644 --- a/nebula_app/src/gpui_shell/workspace/file_tree.rs +++ b/nebula_app/src/gpui_shell/workspace/file_tree.rs @@ -417,29 +417,28 @@ impl NebulaWorkspace { window.open_dialog(cx, move |dialog, window, _cx| { let workspace = workspace.clone(); let path = path.clone(); - center_confirm_dialog(dialog, window) - .title(title.clone()) - .button_props( - DialogButtonProps::default() - .ok_text("删除") - .ok_variant(gpui_component::button::ButtonVariant::Danger) - .cancel_text("取消") - .show_cancel(true), - ) - .child(body.clone()) - .on_ok(move |_, _, cx| { - let _ = workspace.update(cx, |this, cx| { - match crate::display::send_to_recycle_bin(&path) { - Ok(()) => { - this.side_panel.request_refresh(); - this.sync_side_panel_to_active(false, cx); - }, - Err(error) => this.side_panel.set_notice(format!("删除失败:{error}")), - } - cx.notify(); - }); - true - }) + confirm_dialog( + dialog, + window, + title.clone(), + body.clone(), + "删除", + "取消", + ButtonVariant::Danger, + ) + .on_ok(move |_, _, cx| { + let _ = workspace.update(cx, |this, cx| { + match crate::display::send_to_recycle_bin(&path) { + Ok(()) => { + this.side_panel.request_refresh(); + this.sync_side_panel_to_active(false, cx); + }, + Err(error) => this.side_panel.set_notice(format!("删除失败:{error}")), + } + cx.notify(); + }); + true + }) }); } } diff --git a/nebula_app/src/gpui_shell/workspace/update_dialog.rs b/nebula_app/src/gpui_shell/workspace/update_dialog.rs index 7fea83a..0acdd0d 100644 --- a/nebula_app/src/gpui_shell/workspace/update_dialog.rs +++ b/nebula_app/src/gpui_shell/workspace/update_dialog.rs @@ -32,6 +32,9 @@ pub(crate) fn show_update_notification( let action_result = result.clone(); let notification = Notification::warning(message) .title(title) + // 更新是需要用户明确处理的待办;保持到查看或手动关闭,避免短暂出现后 + // 被误判为“没有右下角提示”。 + .autohide(false) .w_auto() .min_w(px(300.0)) .max_w(px(440.0)) @@ -129,10 +132,12 @@ pub(crate) fn open_update_dialog( ), ) .child( - div().text_sm().line_height(relative(1.55)).text_color(muted).child(hint.clone()), + div().text_base().line_height(relative(1.5)).text_color(muted).child(hint.clone()), ); - center_confirm_dialog(dialog, window) - .title(title.clone()) + center_modal_dialog(dialog, window, 250.0) + .close_button(false) + .overlay_closable(false) + .title(div().text_lg().font_semibold().line_height(relative(1.0)).child(title.clone())) .footer( DialogFooter::new() .child( diff --git a/nebula_app/src/update_check.rs b/nebula_app/src/update_check.rs index 369eff6..8d07165 100644 --- a/nebula_app/src/update_check.rs +++ b/nebula_app/src/update_check.rs @@ -118,6 +118,21 @@ pub fn spawn_gpui_once(sender: std::sync::mpsc::Sender 右下角通知 -> 详情弹窗”链路。 + // STARTED 仍保证每进程仅一次;正式包的该条件恒为 false,不会触发。 + std::thread::sleep(Duration::from_secs(2)); + let current = env!("CARGO_PKG_VERSION").to_owned(); + let result = UpdateCheckResult { + latest: format!("{current}-test"), + current, + update_available: true, + }; + log::info!("update-check: forcing one GPUI update reminder in debug build"); + let _ = sender.send(crate::gpui_shell::GpuiShellEvent::UpdateAvailable(result)); + return; + } + // 对齐旧壳:首屏和首个终端会话稳定后再联网。 std::thread::sleep(Duration::from_secs(12)); let result = match check_now() {