跳转至

crates\nfarms-gui\src\plugins\core_pages.rs

源码路径:crates\nfarms-gui\src\plugins\core_pages.rs · 6631 行

//! 真正画出来的那几页。
//!
//! 其余的页面在 `stubs.rs` 里共用一个占位绘制函数 —— 这里只放
//! **已经能用**的功能:天气、背景图片设置、本地大模型三页、以及插件装载表。
//!
//! 原来这些函数都挂在 `App` 上(`self.weather_page(...)` 之类),
//! 拆成插件后统一收成 `fn(&mut PageCtx)`,于是「加一页」不再需要碰
//! `App` 的实现。

use eframe::egui::{self, Align2, Color32, FontId, RichText};

use nfarms_core::envprobe::PkgMgr;
use nfarms_core::observability::{self, Service};
use nfarms_core::{libs, weather};

use super::{PageCtx, SERVICE_PAGES, ui};
use crate::pages::{Page, WeatherState};
use crate::theme;
use crate::{AppState, Job, background, icons};

use std::cell::RefCell;
use std::path::{Path, PathBuf};

// ===========================================================================
// 天气
// ===========================================================================

pub fn weather(cx: &mut PageCtx<'_>) {
    // ---- 地点 + 刷新 ----
    let mut refresh = false;
    cx.ui.horizontal(|ui| {
        ui.label(RichText::new("地点").size(13.0).color(theme::TEXT_SUB));
        let resp = ui.add_sized(
            [200.0, 30.0],
            egui::TextEdit::singleline(&mut cx.state.weather_location).hint_text("shanghai"),
        );
        let submitted = resp.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter));
        let clicked = ui
            .add_sized([96.0, 30.0], egui::Button::new("刷新天气"))
            .clicked();
        if (submitted || clicked) && cx.state.weather_rx.is_none() {
            refresh = true;
        }
        if cx.state.weather_rx.is_some() {
            ui.spinner();
            ui.label(RichText::new("获取中…").size(12.0).color(theme::TEXT_SUB));
        }
    });
    if refresh {
        cx.state.settings.weather_location = cx.state.weather_location.clone();
        cx.state.save_settings();
        cx.state.start_weather_fetch(cx.egui);
    }
    cx.ui.add_space(12.0);

    match &cx.state.weather {
        WeatherState::Loading => {
            ui::card(cx.ui, |ui| {
                ui.add_space(24.0);
                ui.vertical_centered(|ui| {
                    ui.spinner();
                    ui.add_space(6.0);
                    ui.label(
                        RichText::new("正在获取天气…")
                            .size(14.0)
                            .color(theme::TEXT_SUB),
                    );
                });
                ui.add_space(24.0);
            });
        }
        WeatherState::Failed(err) => {
            let err = err.clone();
            ui::card(cx.ui, |ui| {
                ui::err_hint(ui, "获取失败");
                ui.add_space(4.0);
                ui.label(RichText::new(&err).size(12.0).color(theme::TEXT_SUB));
                ui.add_space(4.0);
                ui.label(
                    RichText::new("离线时不影响其它页面。检查地点拼写或网络后点「刷新天气」重试。")
                        .size(12.0)
                        .color(theme::TEXT_SUB),
                );
            });
        }
        WeatherState::Ready(report) => {
            weather_body(cx.ui, report, &cx.state.background);
        }
    }
}

fn weather_body(ui: &mut egui::Ui, report: &weather::WeatherReport, bg: &background::Background) {
    let now = &report.now;

    // ---- 渐变主卡片 ----
    let hero_h = 156.0;
    let (hero, _) = ui.allocate_exact_size(
        egui::vec2(ui.available_width(), hero_h),
        egui::Sense::hover(),
    );
    {
        let p = ui.painter();
        // 圆角靠「用背景色把角补回来」实现。背景是纯色就用纯色;
        // 背景是照片就按同一个 cover 映射从照片上取那一小块的颜色,
        // 否则四个角会露出色块(见 background::Background::sample)。
        let screen = ui.ctx().screen_rect();
        theme::gradient_card(
            p,
            hero,
            theme::RADIUS_CARD,
            theme::HERO_TOP,
            theme::HERO_BOTTOM,
            |pos| bg.sample(pos, screen).unwrap_or(theme::APP_BG),
        );

        let x = hero.left() + 28.0;
        p.text(
            egui::pos2(x, hero.top() + 22.0),
            Align2::LEFT_TOP,
            format!("{}°C", now.temp),
            FontId::proportional(50.0),
            Color32::WHITE,
        );
        p.text(
            egui::pos2(x, hero.top() + 86.0),
            Align2::LEFT_TOP,
            format!("{}  {}", weather::icon_for(&now.desc), now.desc),
            FontId::proportional(17.0),
            Color32::from_rgba_unmultiplied(255, 255, 255, 225),
        );
        p.text(
            egui::pos2(x, hero.top() + 112.0),
            Align2::LEFT_TOP,
            format!("体感 {}°C", now.feels),
            FontId::proportional(13.0),
            Color32::from_rgba_unmultiplied(255, 255, 255, 170),
        );

        let rx = hero.right() - 28.0;
        let mut city = now.city.clone();
        if !now.region.is_empty() {
            city.push_str(" / ");
            city.push_str(&now.region);
        }
        p.text(
            egui::pos2(rx, hero.top() + 24.0),
            Align2::RIGHT_TOP,
            city,
            FontId::proportional(16.0),
            Color32::WHITE,
        );
        p.text(
            egui::pos2(rx, hero.top() + 48.0),
            Align2::RIGHT_TOP,
            format!("观测 {}", now.obs_time),
            FontId::proportional(12.0),
            Color32::from_rgba_unmultiplied(255, 255, 255, 165),
        );

        let metrics = [
            ("湿度", format!("{}%", now.humidity)),
            ("风速", now.wind.clone()),
            ("紫外线", now.uv.clone()),
            ("能见度", format!("{} km", now.visibility)),
        ];
        for (i, (label, value)) in metrics.iter().enumerate() {
            let bx = rx - 300.0 + (i as f32) * 78.0;
            p.text(
                egui::pos2(bx, hero.top() + 92.0),
                Align2::LEFT_TOP,
                *label,
                FontId::proportional(11.0),
                Color32::from_rgba_unmultiplied(255, 255, 255, 150),
            );
            p.text(
                egui::pos2(bx, hero.top() + 108.0),
                Align2::LEFT_TOP,
                value.clone(),
                FontId::proportional(14.0),
                Color32::WHITE,
            );
        }
    }

    ui.add_space(14.0);
    ui.label(RichText::new("未来 3 天").size(14.0).color(theme::TEXT_MAIN));
    ui.add_space(8.0);

    if report.days.is_empty() {
        ui::card(ui, |ui| {
            ui.label(
                RichText::new("这份响应里没有预报数据")
                    .size(13.0)
                    .color(theme::TEXT_SUB),
            );
        });
        return;
    }

    let n = report.days.len().min(3);
    ui.columns(n, |cols| {
        for (i, day) in report.days.iter().take(n).enumerate() {
            ui::card(&mut cols[i], |ui| {
                ui.label(
                    RichText::new(format!("{}  {}", day.date, day.week))
                        .size(12.0)
                        .color(theme::TEXT_SUB),
                );
                ui.add_space(6.0);
                ui.label(
                    RichText::new(format!("{} {}", day.icon, day.desc))
                        .size(15.0)
                        .color(theme::TEXT_MAIN),
                );
                ui.add_space(6.0);
                ui.label(
                    RichText::new(format!("{}°C ~ {}°C", day.min, day.max))
                        .size(18.0)
                        .color(theme::TEXT_MAIN),
                );
            });
        }
    });
}

// ===========================================================================
// Setting → 背景图片 / 启动动画
// ===========================================================================

pub fn settings(cx: &mut PageCtx<'_>) {
    // ---- 背景图片 ----
    ui::card(cx.ui, |ui| {
        ui::section(
            ui,
            "背景图片",
            "留空 = 使用默认纯色底。也可以直接把图片拖进窗口。",
        );
        ui.add_space(8.0);

        ui.horizontal(|ui| {
            let (rect, _) = ui.allocate_exact_size(egui::vec2(160.0, 90.0), egui::Sense::hover());
            let p = ui.painter().clone();
            if cx.state.background.is_set() {
                cx.state.background.paint(&p, rect);
            } else {
                p.rect_filled(rect, 6.0, theme::APP_BG);
                p.rect_stroke(
                    rect,
                    6.0,
                    egui::Stroke::new(1.0_f32, theme::BORDER),
                    egui::StrokeKind::Inside,
                );
                p.text(
                    rect.center(),
                    Align2::CENTER_CENTER,
                    "默认纯色",
                    FontId::proportional(12.0),
                    theme::TEXT_SUB,
                );
            }

            ui.vertical(|ui| {
                ui.label(
                    RichText::new(match &cx.state.background.path {
                        Some(p) => p.display().to_string(),
                        None => "(未设置)".to_string(),
                    })
                    .size(12.0)
                    .color(theme::TEXT_MAIN),
                );
                if let Some((w, h)) = cx.state.background.source_size {
                    ui.label(
                        RichText::new(format!("原始尺寸 {w} × {h}"))
                            .size(11.0)
                            .color(theme::TEXT_SUB),
                    );
                }
                if let Some(err) = cx.state.background.error.clone() {
                    ui::err_hint(ui, &err);
                }
                ui.add_space(6.0);

                let mut pick = false;
                let mut clear = false;
                ui.horizontal(|ui| {
                    let pending = cx.state.picker.is_pending();
                    if background::PICKER_SUPPORTED {
                        // 按钮上不摆 📂 / ↺ 这种符号:egui 自带字体只覆盖 emoji,
                        // 覆盖不了几何符号和箭头,真机上会有一半变成豆腐块。
                        // 要么用 icons::draw 自己画,要么老老实实写字 —— 这里写字。
                        if ui
                            .add_enabled(
                                !pending,
                                egui::Button::new(if pending {
                                    "选择中…"
                                } else {
                                    "选择图片…"
                                })
                                .min_size(egui::vec2(120.0, 28.0)),
                            )
                            .clicked()
                        {
                            pick = true;
                        }
                    }
                    if ui
                        .add_enabled(
                            cx.state.background.is_set(),
                            egui::Button::new("恢复默认").min_size(egui::vec2(110.0, 28.0)),
                        )
                        .clicked()
                    {
                        clear = true;
                    }
                });
                if pick {
                    cx.state.picker.pick_image(cx.egui);
                }
                if clear {
                    cx.state.background.clear();
                    cx.state.settings.background_image.clear();
                    cx.state.save_settings();
                    cx.state.log.write("gui", "背景图已恢复默认");
                }

                if !background::PICKER_SUPPORTED {
                    ui.label(
                        RichText::new("本平台没有系统文件对话框 —— 把图片直接拖进窗口即可")
                            .size(11.0)
                            .color(theme::TEXT_SUB),
                    );
                }
            });
        });
    });

    cx.ui.add_space(12.0);

    // ---- 启动动画 ----
    //
    // 原来这是**独立的一页**(Setting › 启动动图),现在并进本页:两件事都是
    // 「开机第一眼看到什么」,分两页要来回点两次才能配完。
    // (路由那一半 —— `Page` 变体 / `PAGES` 表 / `mod.rs` 的映射 —— 由另一处
    //  改动同步收尾;本文件只负责把卡片并过来并删掉原来的绘制函数。)
    boot_card(cx);

    cx.ui.add_space(12.0);

    // ---- 天气地点 ----
    ui::card(cx.ui, |ui| {
        ui::section(
            ui,
            "天气地点",
            "wttr.in 支持城市名、拼音、区县名;天气页也能改。",
        );
        ui.add_space(6.0);
        ui.add_sized(
            [260.0, 30.0],
            egui::TextEdit::singleline(&mut cx.state.draft_location),
        );
    });

    cx.ui.add_space(12.0);

    // 端口编辑已移到 Setting › Proxy and Port。这里原来那张「DSH-Starter
    cx.ui.add_space(14.0);

    let mut save = false;
    let mut revert = false;
    cx.ui.horizontal(|ui| {
        if ui::primary_button(ui, "保存", [110.0, 32.0]) {
            save = true;
        }
        if ui
            .add_sized([110.0, 32.0], egui::Button::new("还原"))
            .clicked()
        {
            revert = true;
        }
        if cx.state.last_saved.is_some() {
            ui::ok_hint(ui, "已保存到 settings.json");
        }
    });
    if save {
        cx.state.settings.weather_location = cx.state.draft_location.trim().to_string();

        cx.state.weather_location = cx.state.settings.weather_location.clone();
        cx.state.save_settings();
        cx.state.last_saved = Some(crate::now_stamp());
        cx.state.start_weather_fetch(cx.egui);
    }
    if revert {
        cx.state.draft_location = cx.state.settings.weather_location.clone();
    }

    cx.ui.add_space(10.0);
    ui::kv(
        cx.ui,
        "设置文件",
        &cx.state.settings_path.display().to_string(),
    );
}

// ---------------------------------------------------------------------------
// 启动动画(原 Setting › 启动动图 那一页,已并进上面的背景图片页)
// ---------------------------------------------------------------------------

/// 内置动画的总时长(毫秒)。
///
/// 和 `lib.rs` 里的 `BUILTIN_BOOT_TOTAL_MS` 是**同一个值**(那边那份是私有的,
/// 页面拿不到,只能在 `boot_store_keys_match_lib_reads` 里核对):页面拿它算进度
/// 文案,那边拿它决定什么时候收遮罩。两处一旦不一致,症状是进度条停在
/// 87% 就没了,或者遮罩早退之后再黑一下 —— 都不像代码问题。
const BOOT_BUILTIN_MS: i64 = 1400;

/// 「启动时播放」这个开关在 store.json 里的键名。
///
/// 写成常量而不是在调用点散字面量:它是**跨文件**的(本页写、`lib.rs` 启动时读),
/// 拼错一个字母的症状是「勾选框点得动、重启就回到默认」—— 编译器一声不吭,
/// 日志和别的测试也全绿。真正拦得住它的是下面的 `boot_store_keys_match_lib_reads`。
///
/// **另外两个键(`boot_video` / `boot_sound`)这里不抄**:它们归 `lib.rs` 的
/// `BOOT_VIDEO_KEY` / `BOOT_SOUND_KEY` 管,本页只调 `AppState::boot_video_set` /
/// `boot_sound_set`,不自己碰 store —— 同一个键有两份字面量就是两个会各自漂移的来源。
const KEY_BOOT_ANIM: &str = "boot_anim";

/// 本卡片写的提示统一带这个前缀(渲染时据此过滤,见 [`boot_message`])。
///
/// `lib.rs` 里有一份同值的私有常量,它写的提示也带这个前缀 ——
/// `boot_store_keys_match_lib_reads` 会核对两边还是同一个串。
const BOOT_MSG_PREFIX: &str = "启动动画:";

/// 「选择 MP4…」这个按钮能不能用。
///
/// **现在是 `true`,用途和路由都接上了**:`background.rs` 的 `PickPurpose` 里有
/// `BootVideo`,选中文件后由 `lib.rs::poll_background_drop` 的对应分支交给
/// `AppState::boot_video_set(Some(path), ctx)`,而那边把「记住路径 + 落盘 +
/// 后台抽帧」当成一个动作做完。
///
/// **为什么不把这个常量删掉**:它是"这条路通不通"的**唯一开关**,而且只有两处
/// 用法 —— 按钮的 `add_enabled` 和下面那句解释文案。路由要是被回退(比如
/// `background.rs` 删了 `BootVideo` 变体、或 `lib.rs` 少了那条分支),编译器只会在
/// `lib.rs` 报"match 不穷尽",本页**一声不吭**;那时把这里改回 `false`,界面就退成
/// "灰按钮 + 说明为什么点不动",而不是让用户点一个没反应的按钮(那种"点了没事发生"
/// 是这次整条通路一开始的样子)。删掉常量的话,回退时连这个开关都没有了。
///
/// 借别的用途(比如 `Video`)顶上是不行的:那会把选出来的 MP4 甩给视频播放器,
/// 而 `background.rs` 的注释里记着的那次事故(用途和路由对不上 → 整窗背景图被换掉)
/// 就是这么发生的。
const BOOT_PICK_READY: bool = true;

/// 开关存进 store 的字符串形式。
///
/// 沿用 `boot_anim` 原来的 `"1"` / `"0"`:同一个 store.json 里同一个语义不该有
/// 两种写法 —— `lib.rs` 是把 `boot_anim` 当 `== Some("1")` 读的,这里要是写成
/// `"true"`,那个开关就"存进去了但读不回来"。
fn flag_str(on: bool) -> &'static str {
    if on {
        "1"
    } else {
        "0"
    }
}

/// 启动这一帧该拿哪一路画面。
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
enum BootVisual {
    /// 内置的 1.4 秒遮罩(logo + 扫描线 + 进度条)
    Builtin,
    /// 用户选的自定义 MP4,按抽好的帧播
    Video,
}

/// 「当前该用内置动画还是 MP4」。
///
/// 判据是**路径和帧两个都在**,不是只看路径:只有路径、一帧都没有
/// (ffmpeg 没装好 / 文件损坏 / 抽帧还没跑完)时若照路径去播,用户看到的
/// 是一片黑,只会以为程序坏了;退一档播内置动画,至少还能看出「它在启动」。
fn boot_visual(video: Option<&Path>, frames: usize) -> BootVisual {
    match video {
        Some(_) if frames > 0 => BootVisual::Video,
        _ => BootVisual::Builtin,
    }
}

/// 这一路画面一共放多久(毫秒)—— 进度文案的分母。
///
/// MP4 那一路**直接问 `lib.rs` 的 `boot_video_total_ms`**,不在这里重算:
/// 那个函数算的是「最后一帧要显示到什么时候」(最后一个偏移 + 一帧的间隔),
/// 播放那边也用同一个值决定何时收遮罩 —— 这里少加那一格的话,文案上写着演完了,
/// 画面其实还差一帧。
fn boot_total_ms(visual: BootVisual, frame_at: &[u64]) -> i64 {
    match visual {
        BootVisual::Builtin => BOOT_BUILTIN_MS,
        BootVisual::Video => {
            let t = crate::boot_video_total_ms(frame_at);
            // 帧表为空时那边返回 0(`Video` 正常不会走到这里,但不留一个
            // `0 毫秒 / 0 毫秒` 的分母)。
            if t > 0 { t } else { BOOT_BUILTIN_MS }
        }
    }
}

/// 卡片上显示的文件名:只取末段。
///
/// 整条路径直接往卡片上摆会把卡片顶破(egui 不会自动省略,长到一定程度还会
/// 画到卡片外面去);完整路径另起一行用 [`ui::kv_one_line`] 显示,那里会中间省略。
fn boot_file_label(p: &Path) -> String {
    match p.file_name() {
        Some(n) => n.to_string_lossy().to_string(),
        // 拿不到末段(路径以 `..` 收尾之类)就退回整条 —— 这里返回空串的话,
        // 用户看到的是「选了一个文件,但名字是空的」。
        None => p.display().to_string(),
    }
}

/// 只认本卡片自己写的提示。
///
/// `misc.message` 是**全 App 共用一个槽**(视频页、ffmpeg 页、拉流页都在写)。
/// 「启动动图」还是独立一页时显示它,顶多显示一条陈旧的话;并进背景图页之后,
/// 用户从视频页回来会看到「载入成功,时长 …」挂在启动动画卡片上,像是本页干的。
/// 约定:本卡片(以及 `lib.rs` 里给启动动画写的那条路由)的消息都以
/// [`BOOT_MSG_PREFIX`] 开头。
fn boot_message(msg: &str) -> Option<&str> {
    msg.strip_prefix(BOOT_MSG_PREFIX)
}

/// 预览时到底有没有声音。三件事都容易说漏:内置动画没有音轨、选了 MP4 但没勾
/// 开关同样是静音、勾了才出声 —— 所以分开写死,而不是拼一句含糊的「按设置播放」。
fn boot_preview_audio_note(visual: BootVisual, sound: bool) -> &'static str {
    match (visual, sound) {
        (BootVisual::Builtin, _) => {
            "预览只有画面 —— 内置动画没有音轨。选一个 MP4 才会连声音一起放。"
        }
        (BootVisual::Video, true) => {
            "预览会把 MP4 的声音一起放出来(音量跟系统当前音量一致,不用另找播放器)。"
        }
        (BootVisual::Video, false) => {
            "预览只放画面:上面的「播放声音」没勾。要连声音一起听,勾上它再点预览。"
        }
    }
}

/// 「启动动画」卡片。
///
/// 两个开关(`boot_anim` / `boot_sound`)和自定义 MP4 都存在 store.json 里 ——
/// **不写 settings.json**:那个文件有 C# 逐字节还原的护栏测试,多一个键就红。
fn boot_card(cx: &mut PageCtx<'_>) {
    // 三个动作先记成标志、出了 `ui::card` 的闭包再动状态:闭包里 `cx` 的字段
    // 正被借着画画,在里面改状态容易和借用检查打结。
    let mut play = false;
    let mut reset = false;
    let mut clear = false;

    // `cx.egui` 先拷出来:下面要拿它调 `AppState::boot_video_set(None, egui)`,
    // 而那一句同时要可变借 `cx.state` —— 拆成两个字段的借用就不会打结。
    let egui = cx.egui;

    // 这几个值闭包里要反复用,而且必须**同一帧内一致** ——
    // 在闭包里现取的话,用户一点「清除」,同一帧后面的文案就跟着变了。
    let video = cx.state.misc.boot_video.clone();
    let has_mp4 = video.is_some();
    let visual = boot_visual(video.as_deref(), cx.state.misc.boot_frames.len());
    let frames = cx.state.misc.boot_frames.len();
    let total = boot_total_ms(visual, &cx.state.misc.boot_frame_at);

    ui::card(cx.ui, |ui| {
        ui::section(
            ui,
            "启动动画",
            "不选 MP4 = 内置动画(约 1.4 秒的全屏遮罩);选了 MP4 = 逐帧播那个文件",
        );
        ui.add_space(6.0);
        ui.label(
            RichText::new(ui::md(
                "内置动画是**在主窗口上叠一层全屏遮罩**:logo 淡入 + 纵向扫描线 + 进度条,\
                 约 1.4 秒后自动淡出、开始正常绘制。不另开窗口 —— 另开窗口会有\
                 「两个窗口先后出现」的抖动,比不做还难看。",
            ))
            .size(11.5)
            .color(theme::TEXT_SUB),
        );
        ui.add_space(4.0);
        ui.label(
            RichText::new(ui::md(
                "自定义 MP4 是**启动时用 ffmpeg 逐帧抽出来**、再按每帧的时间戳播的\
                 (和视频播放器同一套地基)。所以文件别太大:抽帧就发生在启动那一下,\
                 几百 MB 的文件会让首屏干等十几秒,看着像卡死。几秒到十几秒、\
                 1080p 以内最稳。",
            ))
            .size(11.5)
            .color(theme::TEXT_SUB),
        );

        ui.add_space(10.0);
        ui.horizontal(|ui| {
            if ui
                .checkbox(
                    &mut cx.state.misc.boot_enabled,
                    RichText::new("启动时播放").size(12.5),
                )
                .changed()
            {
                cx.state
                    .store
                    .set_str(KEY_BOOT_ANIM, flag_str(cx.state.misc.boot_enabled));
                let _ = cx.state.store.save();
            }
            // 声音只对自定义 MP4 有意义(内置动画根本没有音轨)。没有 MP4 时
            // **禁掉而不是藏起来**:藏起来用户会以为"这软件不能带声音",
            // 灰着加一句悬停说明才知道是"还没选文件"。
            //
            // 值先落到局部变量再交给 `boot_sound_set`:它负责把开关写进 store
            // 并立刻落盘,本页不自己碰 store(理由见 [`KEY_BOOT_ANIM`] 那段)。
            let mut sound = cx.state.misc.boot_sound;
            if ui
                .add_enabled(
                    has_mp4,
                    egui::Checkbox::new(&mut sound, RichText::new("播放声音").size(12.5)),
                )
                .on_disabled_hover_text("内置动画没有声音 —— 选一个自定义 MP4 才能开这一项")
                .changed()
            {
                cx.state.boot_sound_set(sound);
            }
        });

        ui.add_space(8.0);
        ui.horizontal(|ui| {
            // 用途 `BootVideo` 已经在 `lib.rs::poll_background_drop` 里接上路由:
            // 选出来的文件交给 `AppState::boot_video_set(Some(path), ctx)`
            // (记住 + 落盘 + 起抽帧是一个动作)。本页**不自己改 `misc.boot_video`、
            // 也不碰 store** —— 抄一半的症状是:只记路径不抽帧 → 点完没画面,
            // 只记路径不落盘 → 重启回到内置动画,两种都"看着能用、其实没接完"。
            // 只加用途不加路由比不加更危险(文件会落进默认分支去换整窗背景图)。
            //
            // `egui` 用的是上面拷出来的那个局部量:`pick_file` 要 `&Context`,
            // 而这一句同时要可变借 `cx.state.picker`。
            if ui
                .add_enabled(
                    BOOT_PICK_READY,
                    egui::Button::new("使用自定义 MP4…").min_size(egui::vec2(150.0, 28.0)),
                )
                .on_disabled_hover_text(
                    "「启动视频」这条路由在当前构建里没接上(见 BOOT_PICK_READY)",
                )
                .clicked()
            {
                cx.state.picker.pick_file(
                    egui,
                    crate::background::PickPurpose::BootVideo,
                    "选择启动视频",
                    background::FilePicker::VIDEO_FILTER,
                );
            }
            if ui
                .add_enabled(
                    has_mp4,
                    egui::Button::new("清除(用内置动画)").min_size(egui::vec2(150.0, 28.0)),
                )
                .clicked()
            {
                clear = true;
            }
            match &video {
                Some(p) => {
                    ui::one_line(ui, &boot_file_label(p), 12.0)
                        .on_hover_text(p.display().to_string());
                }
                None => {
                    ui.label(
                        RichText::new("(未选 MP4 —— 用内置动画)")
                            .size(12.0)
                            .color(theme::TEXT_SUB),
                    );
                }
            }
        });
        // 「选了 MP4,但启动时不会播」—— 按钮行下面这句醒目的警告。
        //
        // **这个状态现在几乎走不到**:`AppState::boot_video_set` 在选中文件时会把
        // 「启动时播放」顶成开(见 `lib.rs::boot_enabled_after_pick`)。但**不能因此
        // 把它删掉** —— 还有两条路能到这儿:用户手改 store.json(把 `boot_anim`
        // 改回 `"0"` 而 `boot_video` 留着),或者先勾了「启动时播放」又取消勾。
        // 那时若无这句话,用户看到的只有"动画不播"这一个现象,界面上没有任何解释,
        // 他反推不出原因(这正是这次要修的那个缺陷的形状)。
        //
        // 判据里的 `boot_enabled` **现取现用**,不跟上面那几个"同一帧内一致"的量走:
        // 用户在同一帧里刚取消勾,这句警告就该立刻出现,而不是等下一帧才冒出来。
        //
        // 文案走 `ui::md`:`RichText` 不解析 markdown,星号会原样画在卡片上。
        // 强调靠 `theme::DANGER` 这个颜色(egui 没有粗体字重,见 `ui::md` 的说明)。
        if has_mp4 && !cx.state.misc.boot_enabled {
            ui.add_space(6.0);
            ui.label(
                RichText::new(ui::md(
                    "**注意:启动时不会播。**上面「启动时播放」没勾上 —— 启动那边先看\
                     这个开关,没勾就不起这段动画,所以重启后这个 MP4 不会有任何动静\
                     (抽好的帧也用不上)。要它播:勾上上面的「启动时播放」。",
                ))
                .size(12.0)
                .color(theme::DANGER),
            );
        }
        if let Some(p) = &video {
            ui::kv_one_line(ui, "文件", &p.display().to_string());
        }
        // 这一段只在 `BOOT_PICK_READY` 被回退成 `false` 时才会画出来 ——
        // 它是那个开关的"解释那一半"(配对关系见常量上的说明)。现在这个值是
        // `true`,所以用户看不到它;留着是为了路由万一被回退时不只剩下一个
        // 灰按钮:"点了没反应"和"告诉你为什么点不动"是两种体验,前者会让人
        // 以为程序坏了。
        if !BOOT_PICK_READY {
            ui.label(
                RichText::new(
                    "「使用自定义 MP4…」暂时点不动:「启动视频」这条用途的路由在当前构建里\
                     没接上,选中了也没法告诉程序它该给谁用(接了错的路由会把整窗背景图换掉)。\
                     所以现在只能用内置动画。",
                )
                .size(11.0)
                .color(theme::TEXT_SUB),
            );
        }
        if !background::PICKER_SUPPORTED {
            ui.label(
                RichText::new("本平台没有系统文件对话框 —— 桌面版才有「选择文件」这条路。")
                    .size(11.0)
                    .color(theme::TEXT_SUB),
            );
        }

        ui.add_space(8.0);
        match visual {
            BootVisual::Video => {
                ui::kv(
                    ui,
                    "抽到的帧",
                    &format!("{frames} 帧,共 {total} 毫秒"),
                );
            }
            // 选了文件却一帧都没有:这一版会**退回内置动画**,得说出来,
            // 否则用户会以为"选了 MP4 没生效"而反复点那个按钮。
            BootVisual::Builtin if has_mp4 => {
                ui.label(
                    RichText::new(
                        "已经选了 MP4,但现在一帧都没抽到 —— 这一版仍按内置动画走。\
                         可能是抽帧还在跑,或者 ffmpeg 没装好(Tools › ffmpeg 那一页看得到)。",
                    )
                    .size(11.5)
                    .color(theme::TEXT_SUB),
                );
            }
            BootVisual::Builtin => {
                ui.label(
                    RichText::new(format!("当前画面:内置动画({BOOT_BUILTIN_MS} 毫秒)"))
                        .size(12.0)
                        .color(theme::TEXT_MAIN),
                );
            }
        }

        ui.add_space(8.0);
        ui.horizontal(|ui| {
            if ui.button("立即预览").clicked() {
                play = true;
            }
            if ui.button("重置").clicked() {
                reset = true;
            }
        });
        ui.add_space(4.0);
        ui.label(
            RichText::new(boot_preview_audio_note(visual, cx.state.misc.boot_sound))
                .size(11.5)
                .color(theme::TEXT_SUB),
        );
        if let Some(t0) = cx.state.misc.boot_started_ms {
            let ms = nfarms_core::netclock::now_ms() - t0;
            if ms < total {
                ui.add_space(6.0);
                ui.label(
                    RichText::new(format!("正在播放…({ms} 毫秒 / {total} 毫秒)"))
                        .size(11.5)
                        .color(theme::ACCENT_DARK),
                );
            }
        }
        // 只显示本卡片自己写的提示(见 `boot_message`),别把视频页那句话
        // 挂到这一页上。
        if let Some(m) = cx.state.misc.message.as_deref().and_then(boot_message) {
            ui.add_space(4.0);
            ui::ok_hint(ui, m);
        }
    });

    if play {
        // 预览就是把「启动那一刻」挪到现在:`lib.rs::render_boot_anim` 只看这一个
        // 时间戳。页面上**不自己再画一遍** —— 画两份的话,预览和真启动迟早不是一个东西。
        cx.state.misc.boot_started_ms = Some(nfarms_core::netclock::now_ms());
    }
    if reset {
        cx.state.misc.boot_started_ms = None;
        cx.state.misc.message = None;
    }
    if clear {
        // 清除走 `AppState::boot_video_set(None, …)`,不自己改字段 + 写 store:
        // 「置空路径 / 放掉帧 / 删 store 键 / 写一句提示」在那边是一个动作,
        // 这里再抄一遍迟早和它走散(症状:选了又清,重启后视频又回来了)。
        cx.state.boot_video_set(None, egui);
    }
}

// ===========================================================================
// Environment → DSH-Starter
// ===========================================================================

/// 老的 DSH-Starter 占位(当时只是把本地大模型指向 Model 组)。
///
/// 现在 DSH-Starter 有自己的真页面([`dsh_page`]),这个函数不再挂给任何页面。
/// 留着是因为它那段「本来该做什么」的说明还有参考价值。
#[allow(dead_code)]
pub fn harness(cx: &mut PageCtx<'_>) {
    ui::placeholder_body(cx.ui, cx.page.info());

    cx.ui.add_space(12.0);
    let mut go = false;
    ui::card(cx.ui, |ui| {
        ui::section(
            ui,
            "本地大模型界面已收进 Environment › Plugins › Model",
            "llama.cpp / stable-diffusion.cpp / Qwen 系列权重现在都由那一页统一管。",
        );
        ui.add_space(6.0);
        go = ui
            .add_sized([160.0, 28.0], egui::Button::new("去 Model 页"))
            .clicked();
    });
    if go {
        cx.goto(Page::Model);
    }
}

// ===========================================================================
// Model
// ===========================================================================

// 统一模型管理页当前显示哪一块。
//
// 为什么用 `thread_local` 而不是往 `AppState` 上再加一个字段:egui 的绘制固定在
// 主线程上,所以两者等价,而这一处只服务本页 —— 加进 `AppState` 会让那个结构体
// 再多一个「只有这一页读」的字段。同一文件里 `ensure_engines` 的槽位就是这个先例。
thread_local! {
    static MODEL_TAB: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
}

/// Environment › Plugins › Model —— **统一模型管理**。
///
/// 本地模型这一块原来横向铺成四页(llama.cpp / stable-diffusion.cpp / qwen-3 /
/// qwen-image-2.1)。新菜单把它们收成一页,所以这里做一层选择:
/// 四个标签各自是**原来那一页的实现**,一行没改 —— 这样「统一入口」不是重写,
/// 而是把已经验过的四块重新组织起来。
pub fn model_page(cx: &mut PageCtx<'_>) {
    /// 四个标签:(名字, 一句话说明)
    const TABS: [(&str, &str); 4] = [
        ("llama.cpp", "文本推理后端(llama-server)"),
        ("stable-diffusion.cpp", "图像生成后端(sd-cli / sd-server)"),
        ("qwen-3", "编程(Qwen3-Coder-30B-A3B)+ 对话(Qwen3-VL-8B)"),
        ("qwen-image-2.1", "图像生成 / 图像编辑的四个权重"),
    ];

    let mut cur = MODEL_TAB.with(|t| t.get()).min(TABS.len() - 1);
    ui::card(cx.ui, |ui| {
        ui.horizontal_wrapped(|ui| {
            for (i, (name, _)) in TABS.iter().enumerate() {
                if ui.selectable_label(cur == i, *name).clicked() {
                    cur = i;
                }
            }
        });
        ui.add_space(4.0);
        ui.label(
            RichText::new(TABS[cur].1)
                .size(11.5)
                .color(theme::TEXT_SUB),
        );
    });
    MODEL_TAB.with(|t| t.set(cur));

    cx.ui.add_space(12.0);
    match cur {
        0 => model_runtime(cx),
        1 => model_sd_cpp(cx),
        2 => model_qwen3(cx),
        _ => model_qwen_image(cx),
    }
}

/// Model › llama.cpp:运行时、组件状态与权重的总入口。
fn model_runtime(cx: &mut PageCtx<'_>) {
    #[cfg(windows)]
    {
        // 大模型这一块是新的(C# 版没有)。它要靠子进程拉 llama-server / sd-cli,
        // 安卓上没有这些可执行文件,所以整块只在 Windows 下编。
        //
        // 注意这里三个字段是**分开借**的(local_model 可变,paths/log 只读)——
        // 同一结构体的不同字段可以同时借,前提是别写成 self 的整体方法。
        cx.state
            .local_model
            .ui(cx.ui, &cx.state.paths, &cx.state.log);
    }
    #[cfg(not(windows))]
    {
        ui::card(cx.ui, |ui| {
            ui::section(
                ui,
                "本地大模型在移动端不可用",
                "llama.cpp / stable-diffusion.cpp 是桌面可执行文件,安卓版没有子进程可以拉它们。",
            );
        });
    }

    cx.ui.add_space(14.0);
    ui::card(cx.ui, |ui| {
        ui::section(ui, "组件状态", &libs::model_hint(&cx.state.paths));
        ui.add_space(6.0);
        for comp in libs::COMPONENTS {
            let st = libs::component_status(&cx.state.paths, comp);
            let (mark, color) = if st.installed() {
                ("已就绪", theme::ACCENT_DARK)
            } else {
                ("缺失", theme::DANGER)
            };
            ui.horizontal(|ui| {
                ui.add_sized(
                    [150.0, 18.0],
                    egui::Label::new(RichText::new(comp.name).size(12.5).color(theme::TEXT_MAIN)),
                );
                ui.label(RichText::new(mark).size(12.0).color(color));
                ui.label(
                    RichText::new(if st.total_bytes > 0 {
                        ui::human_bytes(st.total_bytes)
                    } else {
                        String::new()
                    })
                    .size(12.0)
                    .color(theme::TEXT_SUB),
                );
                if !st.missing.is_empty() {
                    ui.label(
                        RichText::new(format!("缺 {}", st.missing.join(", ")))
                            .size(11.0)
                            .color(theme::TEXT_SUB),
                    );
                }
            });
        }
    });
}

/// Model › stable-diffusion.cpp:图像生成后端的组件与权重。
///
/// **为什么和 llama.cpp 分成两块**:它们是两个独立后端 —— 一个管文本、一个管图像,
/// 各有自己的二进制、构建标记和显存占用。合在一起时「哪个装了、哪个没装」
/// 要在一大段里翻找;分开之后各自的下载入口就在自己那一块上。
///
/// **刻意不引 `local_model` 里的常量**:那一块的 `SD` 是私有 const,
/// 为了复用去把它改成 pub,会让两处从此耦合在一起。这里直接扫目录 ——
/// 就几行,而且这一页要展示的本来就是「磁盘上有什么」。
fn model_sd_cpp(cx: &mut PageCtx<'_>) {
    let dir = cx.state.paths.libs().join("stable-diffusion.cpp");
    // (文件名, 字节数)
    let mut files: Vec<(String, u64)> = Vec::new();
    if let Ok(rd) = std::fs::read_dir(&dir) {
        for e in rd.flatten() {
            if let Ok(md) = e.metadata()
                && md.is_file()
            {
                files.push((e.file_name().to_string_lossy().to_string(), md.len()));
            }
        }
    }
    // 同上:降序用 `Reverse`,别写成 `b.1.cmp(&a.1)` —— 那个容易被读成升序
    files.sort_by_key(|f| std::cmp::Reverse(f.1));
    let total: u64 = files.iter().map(|f| f.1).sum();
    let has_cli = dir.join("sd-cli.exe").is_file();
    // 构建标记:fetch-libs 下载后会写这个文件,用来知道装的是哪一版
    let build = std::fs::read_to_string(dir.join(".nfarms-version"))
        .ok()
        .map(|s| s.trim().to_string())
        .filter(|s| !s.is_empty());

    ui::card(cx.ui, |ui| {
        ui::section(
            ui,
            "stable-diffusion.cpp(图像生成后端)",
            "sd-cli 的下载、版本与依赖 —— 权重本身在 qwen-image-2.1 页",
        );
        ui.add_space(6.0);

        let (mark, color) = if has_cli {
            ("已就位", theme::ACCENT_DARK)
        } else {
            ("未安装", theme::DANGER)
        };
        ui.horizontal(|ui| {
            ui.add_sized(
                [110.0, 17.0],
                egui::Label::new(RichText::new("sd-cli.exe").size(12.0).color(theme::TEXT_MAIN)),
            );
            ui.label(RichText::new(mark).size(11.5).color(color));
            if let Some(b) = &build {
                ui.label(RichText::new(format!("构建 {b}")).size(11.0).color(theme::TEXT_SUB));
            }
        });
        ui.horizontal(|ui| {
            ui.add_sized(
                [110.0, 17.0],
                egui::Label::new(RichText::new("目录").size(12.0).color(theme::TEXT_MAIN)),
            );
            ui.label(
                RichText::new(format!(
                    "libs\\stable-diffusion.cpp  ·  {} 个文件 / {:.1} MB",
                    files.len(),
                    total as f64 / 1_048_576.0
                ))
                .size(11.5)
                .color(theme::TEXT_SUB),
            );
        });

        if files.is_empty() {
            ui.add_space(6.0);
            ui::err_hint(
                ui,
                "目录是空的 —— 还没下过后端。跑 .\\libs\\scripts\\fetch-libs.ps1 -BinariesOnly,\
                 或在 llama.cpp 页点「下载缺失组件」。",
            );
        } else {
            ui.add_space(6.0);
            for (name, size) in files.iter().take(20) {
                ui.horizontal(|ui| {
                    ui.add_space(122.0);
                    ui.add_sized(
                        [280.0, 17.0],
                        egui::Label::new(RichText::new(name).size(11.0).color(theme::TEXT_SUB)),
                    );
                    ui.label(
                        RichText::new(format!("{:.1} MB", *size as f64 / 1_048_576.0))
                            .size(11.0)
                            .color(theme::TEXT_SUB),
                    );
                });
            }
        }

        ui.add_space(8.0);
        ui.horizontal(|ui| {
            if ui
                .add_sized([150.0, 26.0], egui::Button::new("打开目录"))
                .clicked()
            {
                nfarms_core::ffmpeg::open_dir(&dir);
            }
        });
        ui.add_space(4.0);
        ui.label(
            RichText::new(
                "GPU 后端:Vulkan 走 vulkan-1.dll、SYCL 走 oneAPI 运行时 —— \
                 缺了会退回 CPU(能跑但很慢),在 Environment 页的「模型运行环境」里能看到。",
            )
            .size(10.5)
            .color(theme::TEXT_SUB),
        );
    });
}

/// Model › qwen-3:编程(Qwen3-Coder-30B-A3B)与对话(Qwen3-VL-8B)。
///
/// 出图那一段(权重清单、提示词、出图、显存提示)原样调 `local_model` 里拆出来的
/// `ui_image` —— 不重写,免得把已经调好的东西再抄一遍。
fn model_qwen3(cx: &mut PageCtx<'_>) {
    #[cfg(windows)]
    {
        cx.state
            .local_model
            .ui_image(cx.ui, &cx.state.paths, &cx.state.log);
    }
    #[cfg(not(windows))]
    {
        ui::card(cx.ui, |ui| {
            ui::section(ui, "本地大模型在移动端不可用", "安卓版没有子进程可以拉 llama-server。");
        });
    }
}

/// Model › qwen-image-2.1:sd.cpp 用的四个权重。
fn model_qwen_image(cx: &mut PageCtx<'_>) {
    let status = libs::model_status(&cx.state.paths);
    let ready = status.ready();
    let can_edit = status.can_edit();

    ui::card(cx.ui, |ui| {
        ui::section(
            ui,
            "Qwen-Image 2.1",
            "由 stable-diffusion.cpp(sd-cli / sd-server)运行,不是 llama.cpp。",
        );
        ui.add_space(6.0);
        ui::kv(ui, "权重目录", &cx.state.paths.show(&status.dir));
        ui::kv(ui, "已占空间", &ui::human_bytes(status.total_bytes()));
        ui.add_space(6.0);
        ui::status_pill(
            ui,
            ready,
            if ready {
                "三件必需权重齐了,可以出图"
            } else {
                "必需权重还没下齐"
            },
        );
        if !can_edit {
            ui.label(
                RichText::new("mmproj 没下载 —— 只能文生图,图片编辑(图生图)要它。")
                    .size(11.5)
                    .color(theme::TEXT_SUB),
            );
        }
    });

    cx.ui.add_space(12.0);

    for f in libs::QWEN_IMAGE_2_1.iter() {
        let present = status
            .present
            .iter()
            .find(|(p, _)| std::ptr::eq(*p, f))
            .map(|(_, b)| *b);
        ui::card(cx.ui, |ui| {
            ui.horizontal(|ui| {
                let (rect, _) =
                    ui.allocate_exact_size(egui::vec2(22.0, 22.0), egui::Sense::hover());
                icons::draw(
                    ui.painter(),
                    rect,
                    if present.is_some() { "disk" } else { "database" },
                    if present.is_some() {
                        theme::ACCENT
                    } else {
                        theme::TEXT_SUB
                    },
                );
                ui.label(
                    RichText::new(f.name)
                        .size(13.0)
                        .color(theme::TEXT_MAIN),
                );
                if !f.required {
                    ui.label(RichText::new("可选").size(11.0).color(theme::TEXT_SUB));
                }
            });
            ui.add_space(4.0);
            ui::kv(ui, "用途", f.role);
            ui::kv(
                ui,
                "状态",
                &match present {
                    Some(b) => ui::human_bytes(b),
                    None => format!("未下载(约 {} MB)", f.approx_mb),
                },
            );
        });
        cx.ui.add_space(10.0);
    }

    let mut go = false;
    ui::card(cx.ui, |ui| {
        ui::section(ui, "权重不进安装包", "libs\\model 这一层现在有 28 GB,装不进去也不该装。");
        ui.add_space(8.0);
        go = ui
            .add_sized([180.0, 28.0], egui::Button::new("去运行时页下载 / 启动"))
            .clicked();
    });
    if go {
        cx.goto(Page::Model);
    }
}

// ===========================================================================
// Environment
// ===========================================================================

// 包管理器探测(winget / choco / scoop)的跨帧槽位。
//
// 为什么不是 `AppState` 上再加一个字段:这一批修复只允许动本文件,而
// `environment()` 每帧都会被调用 —— 「在跑」和「结果」必须跨帧活着。
// egui 的绘制固定在主线程上,所以 thread_local 在这里与结构体字段等价,
// 只是生命周期跟着进程、不跟着 `App`。
//
// 写成 `//` 而不是 `///`:rustdoc 不为宏调用生成文档,`///` 只会换来一条
// `unused_doc_comments` 警告。
thread_local! {
    static MGR_PROBE: RefCell<Job<Vec<PkgMgr>>> = RefCell::new(Job::default());
}

/// 包管理器探测的一步:该发起就发起,结果到了就搬进界面状态。
///
/// **界面线程只做这两件事,绝不等 `where`。** 一次探测是三个 `where`,
/// 每个 15 秒超时(`envprobe.rs:878` 的 `capture(..., 15_000)`)——
/// 以前这里是一句同步的 `ep::package_managers()`,`env_mgrs_probed` 只挡住了
/// 「每帧跑一次」,挡不住「第一次进来卡最多 45 秒」,那 45 秒里整页不重绘。
///
/// `work` 做成参数是为了测试能塞替身:真跑 `package_managers()` 要起三个
/// `cmd.exe`,测试里既慢、又取决于跑测试的机器上装了什么。
///
/// 返回 `true` 表示**这一帧刚拿到结果**。
fn pump_mgr_probe(
    job: &mut Job<Vec<PkgMgr>>,
    ctx: &egui::Context,
    done: &mut bool,
    out: &mut Vec<PkgMgr>,
    work: fn() -> Vec<PkgMgr>,
) -> bool {
    if !*done && !job.is_running() {
        job.start(ctx, work);
    }
    if *done || !job.poll() {
        return false;
    }
    // 拿到结果、或者后台线程没了(panic),都算探测结束。
    //
    // `result()` 为空**不能**把 `done` 留在 false:那样下一帧 `is_running()`
    // 又是 false,会每帧重开一个线程 —— 从「卡 45 秒」变成「疯狂起线程」。
    if let Some(m) = job.result() {
        out.clone_from(m);
    }
    *done = true;
    true
}

pub fn environment(cx: &mut PageCtx<'_>) {
    use nfarms_core::envprobe as ep;

    let mut start = false;
    if !cx.state.env_probe_auto {
        cx.state.env_probe_auto = true;
        start = true;
    }
    // 包管理器探测走 `Job`,和上面 `env_probe` / 下面 `env_action` 一样:
    // 页面每帧 poll 一次,拿不到就继续画「探测中…」,界面线程一秒都不停。
    {
        let ctx = cx.egui;
        let got = MGR_PROBE.with(|job| {
            pump_mgr_probe(
                &mut job.borrow_mut(),
                ctx,
                &mut cx.state.env_mgrs_probed,
                &mut cx.state.env_mgrs,
                ep::package_managers,
            )
        });
        if got {
            let names: Vec<&str> = cx
                .state
                .env_mgrs
                .iter()
                .filter(|m| m.available())
                .map(|m| m.key)
                .collect();
            let msg = if names.is_empty() {
                "一个都没有(winget / choco / scoop)".to_string()
            } else {
                names.join(" / ")
            };
            cx.state
                .log
                .write("env", &format!("包管理器探测完成:{msg}"));
        }
    }

    let running = cx.state.env_probe.is_running();
    let acting = cx.state.env_action.is_running();
    let mut rescan = false;
    let mut run_action: Option<String> = None;
    // 折叠切换:Some(None) = 全收起;Some(Some(k)) = 展开/收起 k
    let mut toggle: Option<Option<&'static str>> = None;
    let report = cx.state.env_probe.result().cloned();
    let mgrs = cx.state.env_mgrs.clone();
    let cur_mgr = mgrs.get(cx.state.env_mgr).map(|m| m.key).unwrap_or("none");
    let opened = cx.state.env_open;

    // ================= 顶部:控制条 =================
    //
    // 这里**原来是一张「运行时环境」卡片**,把九类汇总在一起。按你的要求去掉了 ——
    // 九类现在各自是一个可折叠分组,再套一层汇总就成了「分组里还有分组」。
    //
    // 但「检测全部」和「包管理器」是**操作**不是分组,所以留下 —— 只是从卡片
    // 变成页面上一条细控制条。
    cx.ui.horizontal(|ui| {
        if ui
            .add_enabled(!running, egui::Button::new("检测全部").min_size(egui::vec2(88.0, 28.0)))
            .clicked()
        {
            rescan = true;
        }
        if running {
            ui.spinner();
            ui.label(RichText::new("检测中…").size(12.0).color(theme::TEXT_SUB));
        }
        ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
            for (i, m) in mgrs.iter().enumerate().rev() {
                let label = if m.available() {
                    RichText::new(m.key).size(12.0).color(theme::TEXT_MAIN)
                } else {
                    RichText::new(format!("{} (没装)", m.key))
                        .size(12.0)
                        .color(theme::TEXT_SUB)
                };
                if m.available() {
                    if ui
                        .selectable_label(cx.state.env_mgr == i, label)
                        .on_hover_text(
                            m.path
                                .as_ref()
                                .map(|p| p.display().to_string())
                                .unwrap_or_default(),
                        )
                        .clicked()
                    {
                        cx.state.env_mgr = i;
                    }
                } else {
                    ui.add_enabled(false, egui::Label::new(label));
                }
            }
            if cx.state.env_mgrs_probed {
                ui.label(RichText::new("包管理器").size(12.0).color(theme::TEXT_SUB));
            } else {
                // 探测结果没回来之前列表必然是空的 —— 不写这一句的话,
                // 用户看到的是「包管理器」后面什么都没有,还会以为本机一个都没装。
                ui.spinner();
                ui.label(RichText::new("包管理器探测中…").size(12.0).color(theme::TEXT_SUB));
            }
        });
    });
    if let Some(m) = &cx.state.env_msg {
        cx.ui.add_space(4.0);
        ui::ok_hint(cx.ui, m);
    }
    // `all()` 对**空表**恒为 true。探测还没回来时 `mgrs` 正是空表,不加
    // `env_mgrs_probed` 这个守卫的话,进页面的头几百毫秒会先闪一条
    // 「本机没有任何包管理器」的红色错误,然后再自己消失。
    if cx.state.env_mgrs_probed && mgrs.iter().all(|m| !m.available()) {
        cx.ui.add_space(4.0);
        ui::err_hint(
            cx.ui,
            "本机没有任何包管理器(winget / choco / scoop)。**安装 / 更新按钮不会出现** —— \
             与其给一个点不动的按钮,不如直说。",
        );
    }

    if start || rescan {
        let paths = cx.state.paths.clone();
        cx.state.env_probe.start(cx.egui, move || ep::collect(&paths));
        cx.state.env_msg = None;
        cx.state.env_started = Some(std::time::Instant::now());
    }

    // **必须每帧 poll** —— `Job::poll()` 是把后台线程的结果从通道搬进
    // `done` 的那一步;不调它 `result()` 永远是 `None`,页面就**永远停在
    // 「正在检测…」**。
    //
    // 我重写这一页时把这段漏掉了,表现是「检测明明跑完了,界面一直转圈」——
    // 而且因为完成日志也在这段里,连日志都查不到,只能靠盯着界面猜。
    if cx.state.env_probe.poll()
        && let Some(r) = cx.state.env_probe.result()
    {
        let (set, tot) = r.vars_set();
        let msg = format!(
            "环境检测完成:{}/{} 类可用,环境变量 {set}/{tot} 已设,镜像源 {} 条",
            r.usable_groups(),
            r.groups.len(),
            r.mirrors.len()
        );
        cx.state.log.write("env", &msg);
        cx.state.env_msg = Some(msg);
        cx.state.env_started = None;
    }
    if cx.state.env_action.poll()
        && let Some(out) = cx.state.env_action.result().cloned()
    {
        cx.state.log.write("env", &format!("安装/更新动作结束:{}", first_line(&out)));
        cx.state.env_action_log = out;
    }

    let Some(report) = &report else {
        cx.ui.add_space(12.0);
        // **显示真实已用时长,不写死秒数**。
        //
        // 原来这里写「约 15 秒」,实测要 45 秒 —— 一个错误的预期比没有预期更糟:
        // 用户等到 20 秒会以为卡死了,然后去点「检测全部」,又开一次。
        // 现在把已用时间如实报出来,慢就是慢,但至少不像坏了。
        let elapsed = cx
            .state
            .env_started
            .map(|t| t.elapsed().as_secs())
            .unwrap_or(0);
        ui::card(cx.ui, |ui| {
            ui.horizontal(|ui| {
                ui.spinner();
                ui.label(
                    RichText::new(format!("正在检测…已用 {elapsed} 秒"))
                        .size(12.5)
                        .color(theme::TEXT_MAIN),
                );
            });
            ui.label(
                RichText::new(
                    "六十来次子进程探测(java/gradle/sdkmanager 各自要启 JVM),点一次跑一遍,\
                     期间可以先看别的页。",
                )
                .size(11.0)
                .color(theme::TEXT_SUB),
            );
        });
        cx.egui
            .request_repaint_after(std::time::Duration::from_millis(500));
        return;
    };

    // ================= 九类:各自一个可折叠分组 =================
    for g in &report.groups {
        cx.ui.add_space(10.0);
        let key = g.key;
        let is_open = opened == Some(key);
        let (ok, total) = g.found_count();
        let state = g.state();
        let state_color = match state {
            "可用" => theme::ACCENT_DARK,
            "部分可用" => theme::TEXT_MAIN,
            _ => theme::DANGER,
        };
        let name = g.name;
        let purpose = g.purpose;
        let items: Vec<(String, String, bool)> = g
            .items
            .iter()
            .map(|i| (i.label.clone(), i.summary(), i.found()))
            .collect();
        let acts = ep::actions_for(&cx.state.paths, key, cur_mgr);

        ui::card(cx.ui, |ui| {
            // ---- 组头:整行可点 ----
            let head = ui
                .horizontal(|ui| {
                    let mark = if is_open { "▼" } else { "▶" };
                    let resp = ui
                        .add(
                            egui::Button::new(
                                RichText::new(format!("{mark} {name}")).size(14.5).color(theme::TEXT_MAIN),
                            )
                            .frame(false),
                        )
                        .on_hover_text("点一下展开 / 收起");
                    ui.label(
                        RichText::new(format!("{ok}/{total}"))
                            .size(11.5)
                            .color(theme::TEXT_SUB),
                    );
                    ui.label(RichText::new(state).size(11.5).color(state_color));
                    resp
                })
                .inner;
            if head.clicked() {
                toggle = Some(if is_open { None } else { Some(key) });
            }

            if !is_open {
                // 收起时给一行「有什么」,不然九行全是一样的摘要看不出区别
                ui.label(RichText::new(purpose).size(11.0).color(theme::TEXT_SUB));
                return;
            }

            ui.label(RichText::new(purpose).size(11.0).color(theme::TEXT_SUB));
            ui.add_space(8.0);
            for (label, summary, found) in &items {
                ui.horizontal(|ui| {
                    ui.add_sized(
                        [190.0, 17.0],
                        egui::Label::new(RichText::new(label).size(12.0).color(if *found {
                            theme::TEXT_MAIN
                        } else {
                            theme::TEXT_SUB
                        })),
                    );
                    let color = if *found { theme::TEXT_SUB } else { theme::DANGER };
                    ui.add_sized(
                        [420.0, 17.0],
                        egui::Label::new(RichText::new(summary).size(11.5).color(color)),
                    );
                });
            }
            if !acts.is_empty() {
                ui.add_space(8.0);
            }
            for a in &acts {
                ui.horizontal(|ui| match a.kind {
                    "安装" | "更新" => {
                        let hit = ui
                            .add_enabled_ui(!acting, |ui| {
                                ui.add_sized(
                                    [168.0, 26.0],
                                    egui::Button::new(format!("{}{}", a.kind, a.what)),
                                )
                            })
                            .inner
                            .on_hover_text(&a.cmd);
                        if hit.clicked() {
                            run_action = Some(a.cmd.clone());
                        }
                        ui.label(
                            RichText::new(&a.cmd)
                                .size(10.5)
                                .monospace()
                                .color(theme::TEXT_SUB),
                        );
                    }
                    _ => {
                        ui.label(
                            RichText::new(format!("【手动】{}", a.what))
                                .size(11.5)
                                .color(theme::TEXT_SUB),
                        );
                    }
                });
                if !a.note.is_empty() {
                    ui.horizontal(|ui| {
                        ui.add_space(176.0);
                        ui.label(RichText::new(a.note).size(10.5).color(theme::TEXT_SUB));
                    });
                }
            }
        });
    }

    // ================= 三个横向维度:同样是可折叠分组 =================
    let (set, tot) = report.vars_set();
    let vars: Vec<(String, Option<String>, String, String)> = report
        .vars
        .iter()
        .map(|v| {
            (
                v.name.to_string(),
                v.value.clone(),
                v.note.to_string(),
                v.hint.to_string(),
            )
        })
        .collect();
    let mirrors: Vec<(String, String, bool)> = report
        .mirrors
        .iter()
        .map(|m| (m.name.to_string(), m.current.clone(), m.official))
        .collect();
    let sys: Vec<(String, String, bool, String)> = report
        .system
        .iter()
        .map(|i| (i.label.clone(), i.summary(), i.found(), i.note.clone()))
        .collect();

    let mut fold = |cx: &mut PageCtx<'_>,
                    key: &'static str,
                    title: String,
                    hint: &str,
                    body: &mut dyn FnMut(&mut egui::Ui)| {
        cx.ui.add_space(10.0);
        let is_open = opened == Some(key);
        let mut local_toggle = false;
        ui::card(cx.ui, |ui| {
            let mark = if is_open { "▼" } else { "▶" };
            let head = ui
                .horizontal(|ui| {
                    let r = ui
                        .add(
                            egui::Button::new(
                                RichText::new(format!("{mark} {title}")).size(14.0).color(theme::TEXT_MAIN),
                            )
                            .frame(false),
                        )
                        .on_hover_text("点一下展开 / 收起");
                    ui.label(RichText::new(hint).size(11.0).color(theme::TEXT_SUB));
                    r
                })
                .inner;
            if head.clicked() {
                local_toggle = true;
                return;
            }
            if is_open {
                ui.add_space(6.0);
                body(ui);
            }
        });
        if local_toggle {
            toggle = Some(if is_open { None } else { Some(key) });
        }
    };

    fold(
        cx,
        "vars",
        format!("环境变量({set}/{tot} 已设)"),
        "工具链的隐形依赖 —— JAVA_HOME 指错比不装还难查",
        &mut |ui| {
            for (name, value, note, hint) in &vars {
                ui.horizontal(|ui| {
                    ui.add_sized(
                        [190.0, 17.0],
                        egui::Label::new(
                            RichText::new(name).size(11.5).monospace().color(if value.is_some() {
                                theme::TEXT_MAIN
                            } else {
                                theme::TEXT_SUB
                            }),
                        ),
                    );
                    if let Some(v) = value {
                        ui.add_sized(
                            [330.0, 17.0],
                            egui::Label::new(
                                RichText::new(ui::ellipsize(v, 60))
                                    .size(11.0)
                                    .color(theme::ACCENT_DARK),
                            ),
                        );
                    } else {
                        ui.add_sized(
                            [330.0, 17.0],
                            egui::Label::new(
                                RichText::new(format!("未设 → 建议 {hint}"))
                                    .size(11.0)
                                    .color(theme::TEXT_SUB),
                            ),
                        );
                    }
                });
                ui.horizontal(|ui| {
                    ui.add_space(192.0);
                    ui.label(RichText::new(note).size(10.5).color(theme::TEXT_SUB));
                });
            }
        },
    );

    fold(
        cx,
        "mirrors",
        format!("镜像源({} 条)", mirrors.len()),
        "国内网络下「装不上」的头号原因",
        &mut |ui| {
            for (name, current, official) in &mirrors {
                ui.horizontal(|ui| {
                    ui.add_sized(
                        [80.0, 17.0],
                        egui::Label::new(RichText::new(name).size(12.0).color(theme::TEXT_MAIN)),
                    );
                    if *official {
                        ui.label(RichText::new("官方源").size(11.0).color(theme::DANGER));
                    } else {
                        ui.label(RichText::new("已换源").size(11.0).color(theme::ACCENT_DARK));
                    }
                    ui.label(
                        RichText::new(ui::ellipsize(current, 70))
                            .size(10.5)
                            .monospace()
                            .color(theme::TEXT_SUB),
                    );
                });
            }
        },
    );

    fold(
        cx,
        "system",
        format!("系统开关与运行库({} 项)", sys.len()),
        "缺了只会在运行时报错(找不到 xxx.dll、路径太长建不了文件)",
        &mut |ui| {
            for (label, summary, found, note) in &sys {
                ui.horizontal(|ui| {
                    ui.add_sized(
                        [240.0, 17.0],
                        egui::Label::new(RichText::new(label).size(12.0).color(theme::TEXT_MAIN)),
                    );
                    let (mark, color) = if *found {
                        ("有", theme::ACCENT_DARK)
                    } else {
                        ("缺", theme::DANGER)
                    };
                    ui.label(RichText::new(mark).size(11.0).color(color));
                    ui.label(
                        RichText::new(ui::ellipsize(summary, 60))
                            .size(11.0)
                            .color(theme::TEXT_SUB),
                    );
                });
                if !note.is_empty() {
                    ui.horizontal(|ui| {
                        ui.add_space(242.0);
                        ui.label(RichText::new(note).size(10.5).color(theme::TEXT_SUB));
                    });
                }
            }
        },
    );

    // ================= 动作输出 =================
    if !cx.state.env_action_log.is_empty() || acting {
        cx.ui.add_space(10.0);
        let log = cx.state.env_action_log.clone();
        let max_h = (cx.egui.screen_rect().height() - 420.0).max(160.0);
        ui::card(cx.ui, |ui| {
            ui::section(ui, "安装 / 更新输出", "命令的原始输出,失败原因都在这里");
            ui.add_space(6.0);
            if acting {
                ui.spinner();
            }
            egui::ScrollArea::vertical().max_height(max_h).show(ui, |ui| {
                for line in log.lines().rev().take(200).collect::<Vec<_>>().into_iter().rev() {
                    ui.label(
                        RichText::new(line)
                            .size(11.0)
                            .monospace()
                            .color(log_line_color(line)),
                    );
                }
            });
        });
    }

    // ================= 收尾:提交折叠 / 执行动作 =================
    if let Some(t) = toggle {
        cx.state.env_open = t;
    }
    if let Some(cmd) = run_action {
        cx.state.log.write("env", &format!("执行: {cmd}"));
        cx.state.env_action_log.clear();
        cx.state.env_msg = Some(format!("已开始执行:{cmd}"));
        cx.state.env_action.start(cx.egui, move || ep::run_action(&cmd));
    }
}
fn first_line(s: &str) -> String {
    s.lines()
        .map(|l| l.trim())
        .find(|l| !l.is_empty())
        .unwrap_or("(没有输出)")
        .to_string()
}

// ===========================================================================
// Zero Trust VPN → Channel
// ===========================================================================

pub fn channel(cx: &mut PageCtx<'_>) {
    use nfarms_core::ztna;

    let running = cx.state.channel.is_running();
    let mut start = false;

    // 客户端路径是纯本地判断,不需要后台线程
    let client = ztna::find_client_path(&cx.state.paths, &cx.state.settings);

    ui::card(cx.ui, |ui| {
        ui::section(
            ui,
            "Channel",
            "管理现有 nfztna.exe 客户端:登录 / 连接 / 断开 / 状态 / 停止",
        );
        ui.add_space(6.0);
        ui::kv(
            ui,
            "客户端",
            if client.is_empty() {
                "(没找到 nfztna.exe —— 在设置里指定路径,或把它放到程序根)"
            } else {
                &client
            },
        );
        if let Some(s) = cx.state.channel.result() {
            ui::kv(ui, "进程", if s.running { "运行中" } else { "未运行" });
            if let Some(t) = &cx.state.last_saved {
                let _ = t;
            }
        }
        ui.add_space(10.0);
        ui.horizontal(|ui| {
            if ui
                .add_enabled(
                    !running && !client.is_empty(),
                    egui::Button::new("刷新状态").min_size(egui::vec2(100.0, 30.0)),
                )
                .clicked()
            {
                start = true;
            }
            if ui
                .add_enabled(
                    !running,
                    egui::Button::new("停止客户端").min_size(egui::vec2(110.0, 30.0)),
                )
                .clicked()
            {
                let ok = ztna::stop();
                cx.state.log.write(
                    "ztna",
                    if ok { "已发送停止命令" } else { "停止失败(进程可能本来就没跑)" },
                );
            }
            if running {
                ui.spinner();
            }
        });
    });

    if start {
        let exe = client.clone();
        cx.state.channel.start(cx.egui, move || {
            let running = ztna::is_running();
            let status = if exe.is_empty() {
                String::new()
            } else {
                ztna::run_status(&exe)
            };
            crate::ChannelSnapshot {
                client_path: exe,
                running,
                status,
            }
        });
    }

    if let Some(s) = cx.state.channel.result()
        && !s.status.is_empty()
    {
        cx.ui.add_space(12.0);
        ui::card(cx.ui, |ui| {
            ui::section(ui, "status 输出", "原版直接打印客户端 status 子命令的原文。");
            ui.add_space(6.0);
            ui.label(
                RichText::new(&s.status)
                    .size(11.5)
                    .monospace()
                    .color(theme::TEXT_MAIN),
            );
        });
    }

    cx.ui.add_space(12.0);

    // 登录 / 连接:开一个可见控制台窗口 —— 客户端要读标准输入
    ui::card(cx.ui, |ui| {
        ui::section(
            ui,
            "登录 / 连接",
            &ui::plain(
                "会在**单独的可见控制台窗口**里执行(cmd /k)—— 客户端要读标准输入,而且失败时得让你看见原因。",
            ),
        );
        ui.add_space(8.0);
        ui::kv(ui, "目标", &cx.state.settings.ztna_target);
        ui::kv(ui, "用户名", &cx.state.settings.ztna_username);
        ui::kv(ui, "身份", &cx.state.settings.ztna_identity);
        ui.add_space(8.0);
        ui.horizontal(|ui| {
            for cmd in ["login", "connect", "disconnect"] {
                let label = match cmd {
                    "login" => "登录",
                    "connect" => "连接",
                    _ => "断开",
                };
                if ui
                    .add_enabled(
                        !client.is_empty(),
                        egui::Button::new(label).min_size(egui::vec2(90.0, 30.0)),
                    )
                    .clicked()
                {
                    match ztna::run_interactive(
                        &client,
                        cmd,
                        &cx.state.settings.ztna_target,
                        &cx.state.settings.ztna_username,
                        &cx.state.settings.ztna_identity,
                    ) {
                        Ok(()) => {
                            cx.state
                                .log
                                .write("ztna", &format!("已在新控制台窗口执行 {cmd}"));
                            if cmd != "disconnect" {
                                // 登录/连接会创建会话,顺手记一下时间
                                cx.state.last_saved = Some(crate::now_stamp());
                            }
                        }
                        Err(e) => {
                            cx.state.log.write("ztna", &format!("{cmd} 启动失败: {e}"));
                        }
                    }
                }
            }
        });
        ui.add_space(6.0);
        ui.label(
            RichText::new(
                "目标 / 用户名 / 身份在 Setting › 背景图片 那一页改(它们和其它设置存在同一个 settings.json 里)。",
            )
            .size(11.0)
            .color(theme::TEXT_SUB),
        );
    });
}

// ===========================================================================
// Player → 图片阅读器
// ===========================================================================

pub fn image_viewer(cx: &mut PageCtx<'_>) {
    let mut open = false;

    ui::card(cx.ui, |ui| {
        ui.horizontal(|ui| {
            if ui
                .add_enabled(
                    !cx.state.viewer.is_loading(),
                    egui::Button::new("选择图片…").min_size(egui::vec2(110.0, 30.0)),
                )
                .clicked()
            {
                open = true;
            }
            ui.label(
                RichText::new(match cx.state.viewer.path() {
                    Some(p) => p.display().to_string(),
                    None => "(还没有打开图片)".to_string(),
                })
                .size(12.0)
                .color(theme::TEXT_MAIN),
            );
            if let Some((w, h)) = cx.state.viewer.source_size {
                ui.label(
                    RichText::new(format!("原图 {w} × {h}"))
                        .size(11.0)
                        .color(theme::TEXT_SUB),
                );
            }
            if let Some(d) = cx.state.viewer.dir() {
                let (i, n) = cx.state.viewer.position();
                ui.label(
                    RichText::new(format!("{}  ·  该目录 {n} 张", d.display()))
                        .size(11.0)
                        .color(theme::TEXT_SUB),
                )
                .on_hover_text(format!("第 {i} 张"));
            }
            if cx.state.viewer.is_loading() {
                ui.spinner();
            }
        });
        ui.add_space(4.0);
        ui.label(
            RichText::new(
                "同目录的图片可以直接翻上/下一张(按文件名排序,和资源管理器一致)。\
                 也可以把图片直接拖进窗口 —— 在这一页是「打开」,在别的页面是「设为窗口背景」。",
            )
            .size(11.5)
            .color(theme::TEXT_SUB),
        );
    });

    if open {
        // **不能**用 `pick_image` —— 那个用途是「设为背景图」,
        // 在这里选一张图会把整窗背景换掉(用户报的就是这个)。
        cx.state.picker.pick_file(
            cx.egui,
            crate::background::PickPurpose::Viewer,
            "打开图片",
            crate::background::FilePicker::IMAGE_FILTER,
        );
    }

    cx.ui.add_space(10.0);
    cx.state.viewer.ui(cx.ui, true);

    // 「设为背景」在 `viewer.ui()` 里只是置一个 action —— 真正执行放在
    // `poll_jobs`(下一帧开头)。阅读器不直接碰 AppState 的解码/设置,
    // 免得两个模块互相依赖;代价是晚一帧,肉眼看不出来。
}

// ===========================================================================
// Tools → ffmpeg
// ===========================================================================

pub fn ffmpeg_page(cx: &mut PageCtx<'_>) {
    use nfarms_core::ffmpeg;

    let mut detect = false;
    let mut info = false;
    let mut transcode = false;
    let mut snapshot = false;
    let mut clean = false;
    let mut open_dir = false;

    // 首次进页面自动探测一次(ffmpeg 探测要扫 PATH,放后台)
    if cx.state.ffmpeg.detected.is_none() && !cx.state.ffmpeg.detect_job.is_running() {
        cx.state.ffmpeg.detect_job.start(cx.egui, || {
            ffmpeg::Ffmpeg::detect(&nfarms_core::Paths::detect())
        });
    }

    let detected = cx.state.ffmpeg.detected.clone();
    let dir = cx
        .state
        .paths
        .lib("ffmpeg");

    ui::card(cx.ui, |ui| {
        ui::section(
            ui,
            "ffmpeg",
            "转码 / 抽帧 / 合流 · 界面不再依赖系统 codec",
        );
        ui.add_space(6.0);
        ui.label(
            RichText::new(
                "原版视频播放依赖 Windows Media Player ActiveX,解码能力受系统限制。\
                 Rust 这版统一走 ffmpeg:播放器抽帧上纹理,工具页则直接暴露转码 / 抽帧 / 媒体信息。",
            )
            .size(11.5)
            .color(theme::TEXT_SUB),
        );
        ui.add_space(10.0);
        match &detected {
            None => {
                ui.horizontal(|ui| {
                    ui.spinner();
                    ui.label(RichText::new("正在查找 ffmpeg…").size(12.0).color(theme::TEXT_SUB));
                });
            }
            Some(ff) => {
                ui::kv(
                    ui,
                    "状态",
                    if ff.available() { "可用" } else { "未安装" },
                );
                if let Some(e) = &ff.exe {
                    ui::kv(ui, "ffmpeg", &e.display().to_string());
                }
                ui::kv(
                    ui,
                    "ffprobe",
                    &ff.probe
                        .as_ref()
                        .map(|p| p.display().to_string())
                        .unwrap_or_else(|| "(没有 —— 媒体信息改用 ffmpeg -i)".to_string()),
                );
                ui::kv(ui, "目录", &cx.state.paths.show(&dir));
                ui.add_space(8.0);
                ui.label(RichText::new(ff.status_text()).size(11.5).color(theme::TEXT_SUB));
            }
        }
        ui.add_space(10.0);
        ui.horizontal(|ui| {
            if ui.button("重新检测").clicked() {
                detect = true;
            }
            if ui.button("打开目录").clicked() {
                open_dir = true;
            }
            let src_pkg = detected
                .as_ref()
                .map(|f| f.looks_like_source_package())
                .unwrap_or(false);
            if ui
                .add_enabled(src_pkg, egui::Button::new("清理源码包"))
                .on_hover_text("把 libs\\ffmpeg 里的非二进制内容移到 bak\\,不直接删")
                .clicked()
            {
                clean = true;
            }
        });
    });

    if detect {
        cx.state.ffmpeg.detected = None;
        cx.state.ffmpeg.detect_job.start(cx.egui, || {
            ffmpeg::Ffmpeg::detect(&nfarms_core::Paths::detect())
        });
    }
    if open_dir {
        ffmpeg::open_dir(&dir);
    }
    if clean {
        match cx.state.ffmpeg.detected.as_ref() {
            Some(ff) => match ff.clean_non_binary(&cx.state.paths, Some(&cx.state.log)) {
                Ok(n) => cx.state.ffmpeg.message = Some(format!("已移走 {n} 项到 bak\\")),
                Err(e) => cx.state.ffmpeg.message = Some(e),
            },
            None => cx.state.ffmpeg.message = Some("还没探测到 ffmpeg".to_string()),
        }
    }

    cx.ui.add_space(12.0);

    // ---- 媒体信息 / 转码 / 截图 ----
    ui::card(cx.ui, |ui| {
        ui::section(ui, "媒体工具", "源文件可以是本地文件,也可以是 http/m3u8/rtmp 地址。");
        ui.add_space(8.0);
        ui.horizontal(|ui| {
            ui.label(RichText::new("源").size(12.0).color(theme::TEXT_SUB));
            ui.add_sized(
                [520.0, 26.0],
                egui::TextEdit::singleline(&mut cx.state.ffmpeg.src)
                    .hint_text(r"C:\video\input.mkv   或   https://.../live.m3u8"),
            );
            if ui.button("浏览…").clicked() {
                // 同上:这里要的是「填进源路径」,不是「换背景图」
                cx.state.picker.pick_file(
                    cx.egui,
                    crate::background::PickPurpose::FfmpegSrc,
                    "选择媒体文件",
                    crate::background::FilePicker::VIDEO_FILTER,
                );
            }
        });
        ui.add_space(4.0);
        ui.horizontal(|ui| {
            ui.label(RichText::new("输出").size(12.0).color(theme::TEXT_SUB));
            ui.add_sized(
                [520.0, 26.0],
                egui::TextEdit::singleline(&mut cx.state.ffmpeg.out)
                    .hint_text(r"留空 = 与源同目录(转码 .mp4 / 截图 .png)"),
            );
        });
        ui.add_space(8.0);
        ui.horizontal(|ui| {
            if ui.button("读媒体信息").clicked() {
                info = true;
            }
            if ui.button("转码为 mp4").clicked() {
                transcode = true;
            }
            ui.label(RichText::new("截图于").size(12.0).color(theme::TEXT_SUB));
            ui.add_sized(
                [70.0, 26.0],
                egui::TextEdit::singleline(&mut cx.state.ffmpeg.at_seconds),
            );
            ui.label(RichText::new("秒").size(12.0).color(theme::TEXT_SUB));
            if ui.button("抽帧截图").clicked() {
                snapshot = true;
            }
            if cx.state.ffmpeg.work.is_running() {
                ui.spinner();
                ui.label(RichText::new("执行中…").size(12.0).color(theme::TEXT_SUB));
            }
        });
        if let Some(m) = &cx.state.ffmpeg.message {
            ui.add_space(6.0);
            ui::ok_hint(ui, m);
        }
    });

    if info {
        match cx.state.ffmpeg.detected.clone() {
            Some(ff) => {
                let p = std::path::PathBuf::from(cx.state.ffmpeg.src.trim());
                let text = ff.media_info(&p);
                cx.state.ffmpeg.info = Some(text);
            }
            None => cx.state.ffmpeg.message = Some("ffmpeg 还没探测到".to_string()),
        }
    }

    if transcode || snapshot {
        let src = std::path::PathBuf::from(cx.state.ffmpeg.src.trim());
        let out_field = cx.state.ffmpeg.out.trim().to_string();
        let at: f64 = cx.state.ffmpeg.at_seconds.trim().parse().unwrap_or(1.0);
        let which = if transcode { "transcode" } else { "snapshot" };
        // ffmpeg 的探测结果要搬进闭包(它是 Send 的)
        if let Some(ff) = cx.state.ffmpeg.detected.clone() {
            let default_ext = if transcode { "mp4" } else { "png" };
            let out = if out_field.is_empty() {
                let mut o = src.clone();
                o.set_extension(default_ext);
                o
            } else {
                std::path::PathBuf::from(&out_field)
            };
            cx.state.ffmpeg.message = None;
            cx.state.ffmpeg.work.start(cx.egui, move || match which {
                "transcode" => match ff.transcode_to_mp4(&src, &out, None) {
                    Ok(()) => format!("转码完成: {}", out.display()),
                    Err(e) => format!("转码失败: {e}"),
                },
                _ => match ff.snapshot(&src, &out, at) {
                    Ok(()) => format!("截图完成: {}", out.display()),
                    Err(e) => format!("截图失败: {e}"),
                },
            });
        }
    }

    if let Some(text) = &cx.state.ffmpeg.info {
        cx.ui.add_space(12.0);
        ui::card(cx.ui, |ui| {
            ui::section(ui, "媒体信息", "ffprobe 原文(没有 ffprobe 就用 ffmpeg -i)。");
            ui.add_space(6.0);
            ui.label(
                RichText::new(text)
                    .size(11.0)
                    .monospace()
                    .color(theme::TEXT_MAIN),
            );
        });
    }
}

// ===========================================================================
// Player → 音乐播放器
// ===========================================================================

pub fn music(cx: &mut PageCtx<'_>) {
    use crate::background::FilePicker as FP;
    use nfarms_core::music::PlayState;

    let mut pick = false;
    let mut scan = false;
    let mut play_idx: Option<usize> = None;
    let mut cmd: Option<&str> = None;
    let mut seek_to: Option<i64> = None;
    let mut volume: Option<u32> = None;

    let opened = cx.state.media.music.is_opened();
    let state = cx.state.media.music.state;
    let len = cx.state.media.music.length_ms();
    let pos = if opened && state == PlayState::Playing {
        // 播放中才每帧读位置;停着的时候读会白起一次 MCI 调用
        cx.ui.ctx().request_repaint_after(std::time::Duration::from_millis(500));
        cx.state.media.music.position_ms()
    } else {
        cx.state.media.music.position_ms()
    };

    ui::card(cx.ui, |ui| {
        ui::section(
            ui,
            "音乐播放器",
            "走 Windows 自带 MCI(winmm)—— 零第三方依赖,和 C# 原版同一个后端",
        );
        ui.add_space(6.0);
        ui.label(
            RichText::new(
                "原版用 mciSendString,这里照抄:直接链 winmm,不引 rodio / cpal 那一大串。\
                 支持 mp3 / wav / wma(装了相应解码器还能更多)。",
            )
            .size(11.5)
            .color(theme::TEXT_SUB),
        );
        ui.add_space(10.0);
        ui.horizontal(|ui| {
            if ui.button("选择音频…").clicked() {
                pick = true;
            }
            if ui.button("扫描目录").clicked() {
                scan = true;
            }
            ui.add_sized(
                [380.0, 26.0],
                egui::TextEdit::singleline(&mut cx.state.media.playlist_dir)
                    .hint_text(r"目录,例如 C:\Music"),
            );
            let (n, i) = (
                cx.state.media.playlist.len(),
                cx.state.media.track + 1,
            );
            if n > 0 {
                ui.label(
                    RichText::new(format!("{i} / {n}"))
                        .size(12.0)
                        .color(theme::TEXT_SUB),
                );
            }
        });
    });

    if pick {
        cx.state
            .picker
            .pick_file(cx.egui, crate::background::PickPurpose::Music, "选择音频", FP::AUDIO_FILTER);
    }
    if scan {
        let d = std::path::PathBuf::from(cx.state.media.playlist_dir.trim());
        if d.is_dir() {
            cx.state.media.scan_playlist(&d);
            let n = cx.state.media.playlist.len();
            cx.state.media.message = Some(format!("扫到 {n} 首"));
        } else {
            cx.state.media.message = Some(format!("不是目录: {}", d.display()));
        }
    }

    cx.ui.add_space(12.0);

    // ---- 播放控制 ----
    ui::card(cx.ui, |ui| {
        ui.horizontal(|ui| {
            let (w, h) = (76.0, 30.0);
            if ui
                .add_enabled(opened, egui::Button::new("播放").min_size(egui::vec2(w, h)))
                .clicked()
            {
                cmd = Some("play");
            }
            if ui
                .add_enabled(opened, egui::Button::new("暂停").min_size(egui::vec2(w, h)))
                .clicked()
            {
                cmd = Some("pause");
            }
            if ui
                .add_enabled(opened, egui::Button::new("停止").min_size(egui::vec2(w, h)))
                .clicked()
            {
                cmd = Some("stop");
            }
            ui.separator();
            if ui
                .add_enabled(
                    !cx.state.media.playlist.is_empty(),
                    egui::Button::new("上一首").min_size(egui::vec2(72.0, h)),
                )
                .clicked()
            {
                cmd = Some("prev");
            }
            if ui
                .add_enabled(
                    !cx.state.media.playlist.is_empty(),
                    egui::Button::new("下一首").min_size(egui::vec2(72.0, h)),
                )
                .clicked()
            {
                cmd = Some("next");
            }
            ui.separator();
            ui.label(RichText::new("音量").size(12.0).color(theme::TEXT_SUB));
            let mut v = cx.state.media.music.volume;
            if ui
                .add(egui::Slider::new(&mut v, 0..=1000).show_value(false))
                .changed()
            {
                volume = Some(v);
            }
            ui.label(
                RichText::new(format!("{}", v / 10))
                    .size(11.0)
                    .color(theme::TEXT_SUB),
            );
        });

        ui.add_space(8.0);
        // 进度条:拖动就 seek
        let mut p = pos as f32;
        let maxv = if len > 0 { len as f32 } else { 1.0 };
        if ui
            .add(
                egui::Slider::new(&mut p, 0.0..=maxv)
                    .show_value(false)
                    .text(format!(
                        "{} / {}",
                        nfarms_core::music::Music::fmt_ms(pos),
                        nfarms_core::music::Music::fmt_ms(len)
                    )),
            )
            .drag_stopped()
        {
            seek_to = Some(p as i64);
        }

        ui.add_space(6.0);
        match &cx.state.media.music.path {
            // 长路径会把卡片撑破 —— 走会自动省略的那条通道
            Some(p) => {
                ui::kv_one_line(ui, "当前", p);
            }
            None => {
                ui.label(
                    RichText::new("还没有打开音频 —— 用「选择音频…」,或点下面的播放列表。")
                        .size(12.0)
                        .color(theme::TEXT_SUB),
                );
            }
        }
        ui::kv(
            ui,
            "状态",
            match state {
                PlayState::Playing => "播放中",
                PlayState::Paused => "已暂停",
                PlayState::Stopped => "已停止",
            },
        );
        if let Some(m) = &cx.state.media.message {
            ui.add_space(4.0);
            ui::ok_hint(ui, m);
        }
    });

    cx.ui.add_space(12.0);

    // ---- 播放列表 ----
    ui::card(cx.ui, |ui| {
        ui::section(
            ui,
            &format!("播放列表({} 首)", cx.state.media.playlist.len()),
            "扫描一个目录,或者用「选择音频…」自动带上它所在目录。",
        );
        ui.add_space(6.0);
        if cx.state.media.playlist.is_empty() {
            ui.label(
                RichText::new("(空)—— 填一个目录再点「扫描目录」")
                    .size(12.0)
                    .color(theme::TEXT_SUB),
            );
        }
        for (i, p) in cx.state.media.playlist.iter().enumerate() {
            let name = p
                .file_name()
                .map(|n| n.to_string_lossy().to_string())
                .unwrap_or_default();
            let cur = i == cx.state.media.track;
            // 文件名可能很长(音乐站下载的文件名动辄上百字),不省略会把卡片撑破
            let max_chars = ((ui.available_width() / 8.0).floor() as usize).max(16);
            let text = RichText::new(ui::ellipsize(&name, max_chars))
                .size(12.0)
                .color(if cur { theme::ACCENT } else { theme::TEXT_MAIN });
            if ui
                .selectable_label(cur, text)
                .on_hover_text(&name)
                .clicked()
            {
                play_idx = Some(i);
            }
        }
    });

    // ---- 执行动作(UI 借用结束后再做,免得改 self 时还借着)----
    if let Some(v) = volume {
        cx.state.media.music.set_volume(v);
    }
    if let Some(ms) = seek_to {
        if let Err(e) = cx.state.media.music.seek_ms(ms) {
            cx.state.media.message = Some(e);
        }
    }
    if let Some(i) = play_idx {
        cx.state.media.track = i;
        let p = cx.state.media.playlist[i].clone();
        match cx.state.media.music.open(&p) {
            Ok(()) => {
                let _ = cx.state.media.music.play();
                cx.state.media.message = Some(format!("正在播放 {}", ui::ellipsize(&p.display().to_string(), 72)));
            }
            Err(e) => cx.state.media.message = Some(e),
        }
    }
    if let Some(c) = cmd {
        let pl = cx.state.media.playlist.clone();
        let n = pl.len();
        let mut goto: Option<usize> = None;
        let r = match c {
            "play" => cx.state.media.music.play(),
            "pause" => cx.state.media.music.pause(),
            "stop" => cx.state.media.music.stop(),
            "next" if n > 0 => {
                goto = Some((cx.state.media.track + 1) % n);
                Ok(())
            }
            "prev" if n > 0 => {
                goto = Some((cx.state.media.track + n - 1) % n);
                Ok(())
            }
            _ => Ok(()),
        };
        if let Err(e) = r {
            cx.state.media.message = Some(e);
        }
        if let Some(i) = goto {
            cx.state.media.track = i;
            let p = pl[i].clone();
            if let Err(e) = cx.state.media.music.open(&p) {
                cx.state.media.message = Some(e);
            } else {
                let _ = cx.state.media.music.play();
            }
        }
    }
}

// ===========================================================================
// Player → 文本阅读器
// ===========================================================================

/// 轻量 Markdown 渲染。
///
/// 只处理真正影响阅读的四种:`#` 标题、``` 代码块、`- ` 列表、`**粗体**`。
/// 表格、脚注、HTML 内嵌这些一律按原文显示 —— 半吊子的富文本比老老实实显示原文更难用。
///
/// **现在没有调用点**:唯一的用户是「文本阅读器」那一页,而新菜单里没有它了。
/// 留着不删的理由:这段逻辑本身是对的、也不依赖任何已删的东西,
/// 下一个要显示 Markdown 的页面(DS-Harness 的控制台、文档阅读)直接用。
#[allow(dead_code)]
fn render_markdown(ui: &mut egui::Ui, text: &str, mono: bool) {
    let mut in_code = false;
    for line in text.lines() {
        let trimmed = line.trim_end();
        if trimmed.starts_with("```") {
            in_code = !in_code;
            ui.add_space(2.0);
            continue;
        }
        if in_code {
            ui.label(
                RichText::new(trimmed)
                    .size(11.5)
                    .monospace()
                    .color(theme::TEXT_MAIN),
            );
            continue;
        }
        if trimmed.is_empty() {
            ui.add_space(6.0);
            continue;
        }
        if let Some(rest) = trimmed.strip_prefix("### ") {
            ui.add_space(4.0);
            ui.label(RichText::new(rest).size(14.0).color(theme::TEXT_MAIN));
            continue;
        }
        if let Some(rest) = trimmed.strip_prefix("## ") {
            ui.add_space(6.0);
            ui.label(RichText::new(rest).size(16.0).color(theme::TEXT_MAIN));
            continue;
        }
        if let Some(rest) = trimmed.strip_prefix("# ") {
            ui.add_space(8.0);
            ui.label(RichText::new(rest).size(19.0).color(theme::ACCENT_DARK));
            continue;
        }
        if let Some(rest) = trimmed
            .strip_prefix("- ")
            .or_else(|| trimmed.strip_prefix("* "))
        {
            ui.horizontal(|ui| {
                ui.add_space(12.0);
                ui.label(RichText::new("•").size(12.0).color(theme::TEXT_SUB));
                ui.label(RichText::new(bold_stripped(rest)).size(12.5).color(theme::TEXT_MAIN));
            });
            continue;
        }
        let size = if mono { 11.5 } else { 12.5 };
        let r = RichText::new(bold_stripped(trimmed)).size(size).color(theme::TEXT_MAIN);
        if mono {
            ui.label(r.monospace());
        } else {
            ui.label(r);
        }
    }
}

/// 去掉 `**` 标记(egui 的 RichText 不认 Markdown,留着就是两个星号)。
/// 同 [`render_markdown`]:暂时没有调用点。
#[allow(dead_code)]
fn bold_stripped(s: &str) -> String {
    s.replace("**", "")
}

// ===========================================================================
// Browser → 书签
// ===========================================================================

pub fn bookmarks(cx: &mut PageCtx<'_>) {
    let mut add = false;
    let mut remove: Option<String> = None;
    let mut open_url: Option<String> = None;

    let list = cx.state.store.bookmarks();

    ui::card(cx.ui, |ui| {
        ui::section(
            ui,
            "书签",
            "存成 src\\store.json —— 人能直接打开看、能手工修,不引 SQLite",
        );
        ui.add_space(6.0);
        ui.label(
            RichText::new(ui::plain(
                "C# 版的 `Store.cs` 是一套「优先 SQLite、找不到就回退 JSON」的**反射加载**方案\
                 (`Assembly.LoadFrom` 找 `System.Data.SQLite.dll`),那是 .NET 特有的绕路。\
                 Rust 这边真要 SQLite 得引 `rusqlite`(带 C 源码编译,构建代价几十 MB),\
                 而书签这点数据用 JSON 完全够,而且能手工修。",
            ))
            .size(11.5)
            .color(theme::TEXT_SUB),
        );
        ui.add_space(10.0);
        ui.horizontal(|ui| {
            ui.label(RichText::new("地址").size(12.0).color(theme::TEXT_SUB));
            ui.add_sized(
                [420.0, 26.0],
                egui::TextEdit::singleline(&mut cx.state.bookmark_url)
                    .hint_text("https://nfarms.cn/"),
            );
        });
        ui.add_space(4.0);
        ui.horizontal(|ui| {
            ui.label(RichText::new("标题").size(12.0).color(theme::TEXT_SUB));
            ui.add_sized(
                [200.0, 26.0],
                egui::TextEdit::singleline(&mut cx.state.bookmark_title).hint_text("留空就用地址"),
            );
            if ui
                .add_enabled(
                    !cx.state.bookmark_url.trim().is_empty(),
                    egui::Button::new("加入书签").min_size(egui::vec2(100.0, 28.0)),
                )
                .clicked()
            {
                add = true;
            }
        });
    });

    if add {
        let url = cx.state.bookmark_url.trim().to_string();
        let title = cx.state.bookmark_title.trim().to_string();
        let now = nfarms_core::time::local_now();
        if cx.state.store.add_bookmark(&title, &url, &now) {
            match cx.state.store.save() {
                Ok(()) => {
                    cx.state.bookmark_url.clear();
                    cx.state.bookmark_title.clear();
                    cx.state
                        .log
                        .write("bookmarks", &format!("已加入 {url}"));
                }
                Err(e) => cx.state.media.message = Some(e),
            }
        } else {
            cx.state.media.message = Some("这个地址已经在书签里了".to_string());
        }
    }

    cx.ui.add_space(12.0);

    ui::card(cx.ui, |ui| {
        ui::section(ui, &format!("已存书签({} 条)", list.len()), "");
        ui.add_space(6.0);
        if list.is_empty() {
            ui.label(
                RichText::new("(空)—— 上面填个地址加一条")
                    .size(12.0)
                    .color(theme::TEXT_SUB),
            );
        }
        for b in &list {
            ui.horizontal(|ui| {
                if ui.button("打开").clicked() {
                    open_url = Some(b.url.clone());
                }
                if ui.button("删除").clicked() {
                    remove = Some(b.url.clone());
                }
                ui.add_sized(
                    [260.0, 18.0],
                    egui::Label::new(RichText::new(&b.title).size(12.5).color(theme::TEXT_MAIN)),
                );
                ui.label(RichText::new(&b.url).size(11.0).color(theme::TEXT_SUB));
                if !b.added.is_empty() {
                    ui.label(RichText::new(&b.added).size(10.5).color(theme::TEXT_SUB));
                }
            });
        }
    });

    if let Some(url) = remove {
        cx.state.store.remove_bookmark(&url);
        if let Err(e) = cx.state.store.save() {
            cx.state.media.message = Some(e);
        }
    }
    if let Some(url) = open_url {
        // 用系统默认浏览器打开 —— 内嵌浏览器是 Browser 组那几页的事
        #[cfg(windows)]
        {
            let _ = std::process::Command::new("cmd")
                .args(["/c", "start", "", &url])
                .spawn();
        }
        cx.state.log.write("bookmarks", &format!("打开 {url}"));
    }
    if let Some(m) = &cx.state.media.message {
        cx.ui.add_space(10.0);
        ui::ok_hint(cx.ui, m);
    }
}

// ===========================================================================
// Browser → 引擎 / 内核 / grafana
// ===========================================================================

/// 首次进入这两页时自动探测一次引擎。
fn ensure_engines(cx: &mut PageCtx<'_>) {
    if cx.state.probe.engines.result().is_none() && !cx.state.probe.engines.is_running() {
        let paths = cx.state.paths.clone();
        cx.state
            .probe
            .engines
            .start(cx.egui, move || nfarms_core::browser::detect_engines(&paths));
    }
}

pub fn browser_engine(cx: &mut PageCtx<'_>) {
    ensure_engines(cx);
    let mut rescan = false;
    let mut open_home = false;
    let home = cx.state.settings.browser_home.clone();

    ui::card(cx.ui, |ui| {
        ui::section(
            ui,
            "浏览器 · 引擎",
            "Chromium(WebView2)/ 兼容(IE)/ Servo(实验)",
        );
        ui.add_space(6.0);
        ui.label(
            RichText::new(ui::plain(
                "这一页**只探测、不内嵌**。内嵌 WebView2 要引 `webview2-com` + `windows` crate\
                 (目前唯一需要重依赖的地方)。但在那之前,「这台机器上有哪些引擎、各是什么版本」\
                 本身就是有用信息 —— WebView2 运行时没装的话,内嵌方案根本跑不起来,先看见比事后调试强。",
            ))
            .size(11.5)
            .color(theme::TEXT_SUB),
        );
        ui.add_space(10.0);
        ui.horizontal(|ui| {
            if ui.button("重新探测").clicked() {
                rescan = true;
            }
            if ui.button("用系统浏览器打开首页").clicked() {
                open_home = true;
            }
            ui.add_sized(
                [360.0, 26.0],
                egui::TextEdit::singleline(&mut cx.state.settings.browser_home)
                    .hint_text("https://nfarms.cn/"),
            );
            if cx.state.probe.engines.is_running() {
                ui.spinner();
            }
        });
    });

    if rescan {
        let paths = cx.state.paths.clone();
        cx.state
            .probe
            .engines
            .start(cx.egui, move || nfarms_core::browser::detect_engines(&paths));
    }
    if open_home {
        let url = if home.trim().is_empty() {
            "https://nfarms.cn/".to_string()
        } else {
            home.clone()
        };
        match nfarms_core::browser::open_external(&url) {
            Ok(()) => cx.state.probe.message = Some(format!("已交给系统浏览器: {url}")),
            Err(e) => cx.state.probe.message = Some(e),
        }
    }

    cx.ui.add_space(12.0);
    engines_card(cx, false);

    cx.ui.add_space(12.0);
    ui::card(cx.ui, |ui| {
        ui::section(ui, "三内核切换的口径", "原版给的三档,现在的落地情况:");
        ui.add_space(6.0);
        for (name, state, note) in [
            ("Chromium(WebView2)", "可落地", "下一步引 webview2-com 就能内嵌;运行时版本见上面"),
            ("兼容(IE)", "待定", "只在极老的政务站点上还有意义;Win11 上通常已经没有 IE"),
            ("Servo(实验)", "未做", "原版留的实验项。把 servo.exe 放到 libs\\servo\\ 才会被探测到"),
        ] {
            ui.horizontal(|ui| {
                ui.add_sized(
                    [160.0, 18.0],
                    egui::Label::new(RichText::new(name).size(12.0).color(theme::TEXT_MAIN)),
                );
                ui.add_sized(
                    [60.0, 18.0],
                    egui::Label::new(RichText::new(state).size(11.5).color(theme::TEXT_SUB)),
                );
                ui.label(RichText::new(note).size(11.0).color(theme::TEXT_SUB));
            });
        }
    });
}

/// 引擎清单卡片。`kernel_view` 为 true 时多显示可执行文件路径(内核页关心这个)。
fn engines_card(cx: &mut PageCtx<'_>, kernel_view: bool) {
    ui::card(cx.ui, |ui| {
        match cx.state.probe.engines.result() {
            None => {
                ui.label(RichText::new("探测中…").size(12.0).color(theme::TEXT_SUB));
            }
            Some(list) => {
                let ok = list.iter().filter(|e| e.available()).count();
                ui::section(ui, &format!("引擎({ok} / {} 个可用)", list.len()), "");
                ui.add_space(8.0);
                for e in list {
                    ui.horizontal(|ui| {
                        let (mark, color) = if e.available() {
                            ("可用", theme::ACCENT_DARK)
                        } else {
                            ("缺失", theme::DANGER)
                        };
                        ui.add_sized(
                            [210.0, 18.0],
                            egui::Label::new(
                                RichText::new(&e.name).size(12.5).color(theme::TEXT_MAIN),
                            ),
                        );
                        ui.add_sized(
                            [58.0, 18.0],
                            egui::Label::new(RichText::new(mark).size(11.5).color(color)),
                        );
                        ui.label(
                            RichText::new(e.summary())
                                .size(11.0)
                                .monospace()
                                .color(theme::TEXT_SUB),
                        );
                    });
                    if kernel_view && let Some(p) = &e.exe {
                        ui.horizontal(|ui| {
                            ui.add_space(210.0);
                            ui.label(
                                RichText::new(p.display().to_string())
                                    .size(10.5)
                                    .color(theme::TEXT_SUB),
                            );
                        });
                    }
                    ui.horizontal(|ui| {
                        ui.add_space(210.0);
                        ui.label(RichText::new(&e.note).size(11.0).color(theme::TEXT_SUB));
                    });
                    ui.add_space(4.0);
                }
            }
        }
    });
}

// ---------------------------------------------------------------------------
// 可观测性三件套(VictoriaMetrics / Loki / Grafana)
// ---------------------------------------------------------------------------

/// 三件套在 `obs_proc` / `obs_state` / `obs_ports` 里的下标。
///
/// 这三个数组是**按位置配对**的,所以下标只此一份常量,调用点一律用名字:
/// 写死字面量时,一旦有人插了一行,症状是「点了 Loki 的启动,VM 的灯绿了」——
/// 那看起来不像代码问题,像服务自己乱了。
const OBS_METRICS: usize = 0;
const OBS_LOGS: usize = 1;
const OBS_DASHBOARD: usize = 2;

/// 界面顺序 = 数据流向:指标 → 日志 → 看板。
const OBS_ORDER: [usize; 3] = [OBS_METRICS, OBS_LOGS, OBS_DASHBOARD];

/// 「启动中」的黄。
///
/// theme 里只有绿(`ACCENT_DARK`)/灰(`TEXT_SUB`)/红(`DANGER`)三档,没有
/// 「进行中」这一档,而这一档**必须能区分**:Loki 实测要 17 秒,「在起」和
/// 「没起」都画灰的话,用户会以为点了没反应、于是再点一次(那时按钮已经因为
/// 启动中而禁用,更懵)。所以这一色在本文件里定,不去动 `theme.rs`。
const OBS_BUSY: Color32 = Color32::from_rgb(198, 142, 42);

/// 一个服务的状态在界面上长什么样:文案 + 颜色 + 要不要转圈。
struct ObsBadge {
    text: &'static str,
    color: Color32,
    /// 还在起。要转圈,而且启动按钮得禁用 —— 同一件服务点两次会起两个进程
    /// 抢同一个端口,后起的那个 bind 失败退出,而界面上写的是「已就绪」。
    busy: bool,
}

/// 状态码(0 未启动 / 1 启动中 / 2 已就绪 / 3 失败)→ 灯的样子。
///
/// # 为什么抽成纯函数
///
/// 这一页要真的起进程才画得出来,而「失败是红的」这种断言靠点界面验不出来:
/// 得先备齐三件套 exe,再制造一次启动失败。所以把映射单独拿出来,
/// 四条分支在测试里直接钉住(见 `obs_state_maps_to_four_distinct_badges`)。
fn obs_badge(state: u8) -> ObsBadge {
    match state {
        1 => ObsBadge {
            text: "启动中…",
            color: OBS_BUSY,
            busy: true,
        },
        2 => ObsBadge {
            text: "已就绪",
            color: theme::ACCENT_DARK,
            busy: false,
        },
        3 => ObsBadge {
            text: "失败",
            color: theme::DANGER,
            busy: false,
        },
        // 0 以及**将来任何新加的状态值**都落这里:漏一档的后果是这行没有灯
        // (或者 panic),而「未启动」是唯一安全的兜底解释。
        _ => ObsBadge {
            text: "未启动",
            color: theme::TEXT_SUB,
            busy: false,
        },
    }
}

/// 下标 → 服务。和 [`OBS_METRICS`] 那一组常量配对。
fn obs_service(idx: usize) -> Service {
    match idx {
        OBS_LOGS => Service::Logs,
        OBS_DASHBOARD => Service::Dashboard,
        _ => Service::Metrics,
    }
}

/// 服务名。和 `observability::missing()` 返回的是**同一批字符串**
/// (VictoriaMetrics / Loki / Grafana)—— 不一致的话,「未安装:Loki」
/// 和下面那一行的名字会对不上,读起来像两样东西。
fn obs_name(svc: Service) -> &'static str {
    match svc {
        Service::Metrics => "VictoriaMetrics",
        Service::Logs => "Loki",
        Service::Dashboard => "Grafana",
    }
}

/// 端口输入框里的一段文字 → 端口号。`None` = 不是合法端口。
///
/// **0 不算端口**:Windows 上 `:0` 是「让系统随便挑一个」,于是配置文件里
/// 写着 0、服务实际听着另一个端口,而界面上的地址永远连不上 ——
/// 那比一个明确的「不合法」难查得多。上界交给 `u16` 自己判。
fn parse_port(text: &str) -> Option<u16> {
    match text.trim().parse::<u16>() {
        Ok(p) if p > 0 => Some(p),
        _ => None,
    }
}

/// 界面上那三个端口和 `store.json` 对齐 —— store 是唯一真值。
///
/// `AppState` 构造时已经按 store 填过一次(lib.rs 的 `obs_ports_from_store`),
/// 这里再对齐一次是因为**端口能在本程序里改**(Proxy and Port 页):改完立刻存回
/// store,而这一页显示的是 `obs_ports` 那个缓冲 —— 只靠构造时那一次,改完端口
/// 界面还会显示旧值,直到下次开程序(症状是"我改过了但它还写着老的")。
///
/// 复用 lib.rs 那个函数而不是在这里再拼一遍:**默认端口(9091/9092/9093)
/// 只允许有一份定义**,两份的话改一处忘一处就变成「界面显示一个端口、
/// 服务听另一个」。
///
/// 代价是输入框里留不住非法或半截的内容(清空 → 解析不了 → 下一帧退回上个值)。
/// 这是**有意**的:端口是数字,框里留着 `909x` 只会让人以为改过了,而服务听的
/// 是 store 里那个 —— 与「改了没生效」是同一种难查。
fn sync_obs_ports(cx: &mut PageCtx<'_>) {
    let want = crate::obs_ports_from_store(&cx.state.store);
    for idx in OBS_ORDER {
        if cx.state.misc.obs_ports[idx] != want[idx] {
            cx.state.misc.obs_ports[idx] = want[idx].clone();
        }
    }
}

/// 三个端口**一起**写回 store.json,返回一句话给界面。
///
/// 一起存是因为它们是配对项:Grafana 的 provision 文件里,VM/Loki 的地址
/// 就是这两个端口。只存被改的那一个,下次启动时另外两个还是旧值,症状是
/// 数据源指向没人监听的端口 —— 从界面上看不出这跟「没存」有关。
fn save_obs_ports(state: &mut AppState) -> Result<String, String> {
    let mut vals = [
        state.store.vm_port(),
        state.store.loki_port(),
        state.store.grafana_port(),
    ];
    let mut bad: Vec<&'static str> = Vec::new();
    for idx in OBS_ORDER {
        match parse_port(&state.misc.obs_ports[idx]) {
            Some(p) => vals[idx] = p,
            // 非法的那个**不落盘**,退回上一次的真值(下一帧的 sync 会把框里
            // 那串退回去)。留着它不动的话,框里看着像改了、store 里却是旧值,
            // 下次开程序用户会发现「改的东西没了」。
            None => bad.push(obs_name(obs_service(idx))),
        }
    }
    state.store.set_vm_port(vals[OBS_METRICS]);
    state.store.set_loki_port(vals[OBS_LOGS]);
    state.store.set_grafana_port(vals[OBS_DASHBOARD]);
    state.store.save()?;
    if bad.is_empty() {
        Ok(format!(
            "端口已记忆:VictoriaMetrics={} Loki={} Grafana={}。三个是配对项 —— \
             VM/Loki 的地址会写进 Grafana 的 provision 文件,所以改完要三件一起重启。",
            vals[OBS_METRICS], vals[OBS_LOGS], vals[OBS_DASHBOARD]
        ))
    } else {
        Ok(format!(
            "{} 的端口不是合法端口号(1~65535 的数字),那几个已经存了、这个退回上一次的值",
            bad.join("、")
        ))
    }
}

pub fn grafana(cx: &mut PageCtx<'_>) {
    // 端口先和 store.json 对齐(理由见 `sync_obs_ports`)。
    sync_obs_ports(cx);

    let mut start_idx: Option<usize> = None;
    let mut stop_idx: Option<usize> = None;
    let mut stop_all = false;
    let mut open = false;

    // 这里**不碰** `obs_job` / `obs_proc`:`AppState::poll_observability`(lib.rs)
    // 每帧都在跑,那三个灯的唯一来源就是它 —— 收启动结果、看进程还在不在、
    // 置 2/3、写日志都是它的活。页面再轮询一次 `obs_job` 的话,
    // 同一个结果会被两处抢着消费(`Job::poll` 取走接收端,第二个 poll 拿到的是
    // 通道断开),表现成「灯随机不变」,而两边单看都对。

    ui::card(cx.ui, |ui| {
        ui::section(
            ui,
            "可观测性三件套",
            "指标 VictoriaMetrics + 日志 Loki + 看板 Grafana;起停和端口都在这一页",
        );
        ui.add_space(6.0);
        ui.label(
            RichText::new(ui::md(
                "三件套是**一套东西**,不是一个面板:VictoriaMetrics 存指标、Loki 存日志、\
                 Grafana 把这两样画出来。所以这里管的是**起停** —— 和那个独立进程的\
                 sd/llama 一样,不归 local_model 管。点「启动」之后程序在后台做四步\
                 (补配置 → 写 Grafana 数据源 → 起进程 → 等就绪),**等就绪最长几十秒**\
                 (Loki 实测 17 秒),所以灯会先黄一会儿,那不是卡住。\
                 内嵌面板那一步和「引擎」页一样要等 webview2-com,现在用系统浏览器打开。",
            ))
            .size(11.5)
            .color(theme::TEXT_SUB),
        );
        ui.add_space(8.0);

        // ---- 缺件检测 ----
        //
        // 缺 exe 时**把启动按钮禁掉**:按下去只会 spawn 失败,而那句
        // 「系统找不到指定的文件」指不到「该去放哪个文件」。
        let missing = observability::missing(&cx.state.paths.libs());
        ui.horizontal(|ui| {
            if missing.is_empty() {
                ui::status_pill(ui, true, "三件套齐全");
            } else {
                ui::status_pill(ui, false, &format!("未安装:{}", missing.join("、")));
            }
        });
        if !missing.is_empty() {
            ui.label(
                RichText::new(ui::md(&format!(
                    "把 {} 的可执行文件放进 {} 之后再回这一页(取件脚本:\
                     libs\\scripts\\fetch-libs.ps1)。装齐之前启动按钮是禁用的。",
                    missing.join("、"),
                    cx.state.paths.libs().display()
                )))
                .size(11.0)
                .color(theme::DANGER),
            );
        }

        // ---- 三行:名字 + 状态灯 + 端口 + 启动/停止 ----
        ui.add_space(8.0);
        let job_running = cx.state.misc.obs_job.is_running();
        // 端口用 lib.rs 那个换算函数(解析不了就退回该服务的默认端口),
        // 而不是在这里再判一次:页面上写的数字必须**就是服务实际听的那个**,
        // 两套回退规则迟早会分叉。
        let ports = crate::obs_ports_u16(&cx.state.misc.obs_ports);
        for idx in OBS_ORDER {
            let svc = obs_service(idx);
            let code = cx.state.misc.obs_state[idx];
            let badge = obs_badge(code);
            let running = cx.state.misc.obs_proc[idx].is_some();
            ui.horizontal(|ui| {
                ui.add_sized(
                    [116.0, 20.0],
                    egui::Label::new(
                        RichText::new(obs_name(svc)).size(12.5).color(theme::TEXT_MAIN),
                    ),
                );
                // 状态灯自己画点:`ui::status_pill` 只有「就绪/未就绪」两色
                // (bool 参数),表达不了这里的四种状态,而四色是这一页的要点 ——
                // 要它支持四色得改 plugins/ui.rs(不在本次改动范围)。
                ui.label(RichText::new("●").size(11.0).color(badge.color));
                ui.label(RichText::new(badge.text).size(12.0).color(badge.color));
                if badge.busy {
                    ui.spinner();
                }
                ui.label(
                    RichText::new(format!("端口 {}", ports[idx]))
                        .size(11.0)
                        .monospace()
                        .color(theme::TEXT_SUB),
                );
                // 能不能启动:**句柄不在**才算没在跑(`obs_state` 是说给人听的,
                // `obs_proc` 才是事实)。用 `obs_proc` 判还有个好处 ——
                // 「失败」那件能从这一行重试,而"已经在跑"的不会被再起一个
                // (再起一个会因为端口被占而 bind 失败,界面上却是"已就绪")。
                let can_start = missing.is_empty() && !job_running && !running;
                if ui
                    .add_enabled(
                        can_start,
                        egui::Button::new("启动").min_size(egui::vec2(58.0, 24.0)),
                    )
                    .clicked()
                {
                    start_idx = Some(idx);
                }
                // 「停止」按单个服务给:三件套常常只需要停一件(比如 Loki 吃满磁盘)。
                // **启动中不给停**:那时 Job 还在等就绪,停了之后它的结果回来
                // 会把这一件置成「已就绪」,而进程已经没了 —— 灯是绿的、点开却连不上。
                let can_stop = running && !badge.busy;
                if ui
                    .add_enabled(
                        can_stop,
                        egui::Button::new("停止").min_size(egui::vec2(58.0, 24.0)),
                    )
                    .clicked()
                {
                    stop_idx = Some(idx);
                }
                if !can_start && !running && !badge.busy {
                    let why = if !missing.is_empty() {
                        "缺可执行文件"
                    } else {
                        // 一次只等一件:等就绪最长 36 秒(Loki),而 Job 只有一个 ——
                        // 让第二下点进去的话,`Job::start` 会静默丢掉,看起来就是
                        // 「点了没反应」。
                        "等上一件就绪中"
                    };
                    ui.label(RichText::new(why).size(10.5).color(theme::TEXT_SUB));
                } else if running && badge.busy {
                    ui.label(
                        RichText::new("启动中:等它就绪再动(最长几十秒)")
                            .size(10.5)
                            .color(theme::TEXT_SUB),
                    );
                }
            });
        }

        // ---- 停止全部 + 打开 ----
        ui.add_space(8.0);
        ui.horizontal(|ui| {
            // kill 这一步交给 `AppState::obs_stop`(lib.rs):那是全项目**唯一**
            // 一处 kill + wait。页面自己也来一遍的话,同一件事有两份实现,
            // 而漏掉 wait 的那份会留下占着端口的孤儿进程(Windows 上还漏句柄)。
            // 注意这三件的子进程**不归 local_model 管**(那是 llama/sd 的),
            // 所以也不能借它的 stop_all。
            //
            // 等就绪期间不给点「停止全部」:见上面单件停止的理由 ——
            // 停了之后 Job 的结果回来会把灯重新点绿。
            let any_proc = OBS_ORDER
                .into_iter()
                .any(|i| cx.state.misc.obs_proc[i].is_some());
            if ui
                .add_enabled(
                    any_proc && !job_running,
                    egui::Button::new("停止全部").min_size(egui::vec2(84.0, 26.0)),
                )
                .clicked()
            {
                stop_all = true;
            }
            // 「已就绪」以灯为准,但**还要句柄在**:灯和事实差一帧的时候宁可不给点。
            let dash_ready = cx.state.misc.obs_state[OBS_DASHBOARD] == 2
                && cx.state.misc.obs_proc[OBS_DASHBOARD].is_some();
            if ui
                .add_enabled(
                    dash_ready,
                    egui::Button::new("打开 Grafana").min_size(egui::vec2(110.0, 26.0)),
                )
                .clicked()
            {
                open = true;
            }
            if dash_ready {
                let url = format!("http://127.0.0.1:{}", ports[OBS_DASHBOARD]);
                ui.label(RichText::new(url).size(10.5).color(theme::TEXT_SUB));
            } else {
                ui.label(
                    RichText::new("Grafana 还没就绪,现在打开只会是「无法连接」")
                        .size(10.5)
                        .color(theme::TEXT_SUB),
                );
            }
        });

        // ---- 端口说明 ----
        ui.add_space(8.0);
        ui.label(
            RichText::new(ui::md(
                "三个端口是**配对项**:Grafana 的 provision 文件里,VictoriaMetrics 和 Loki \
                 的地址写的就是这两个端口(每次点启动,程序都按当前端口把它重写一遍)。\
                 所以**改完端口要三件一起重启** —— 已经在跑的进程还听着旧端口,\
                 只重启 Grafana 的话数据源会指着没人监听的地方,症状是面板上「No data」,\
                 从那句话里看不出是端口问题。端口在 Setting › Proxy and Port 页改。",
            ))
            .size(11.0)
            .color(theme::TEXT_SUB),
        );
        ui.add_space(4.0);
        ui::kv_one_line(
            ui,
            "数据源文件",
            &cx.state
                .paths
                .root()
                .join("data")
                .join("grafana")
                .join("provisioning")
                .join("datasources")
                .join("nfarms.yml")
                .display()
                .to_string(),
        );
        if let Some(m) = &cx.state.misc.obs_msg {
            ui.add_space(6.0);
            ui.label(RichText::new(m.as_str()).size(11.5).color(theme::TEXT_MAIN));
        }
    });

    // ---- 启动一件 ----
    //
    // 页面只做「点一下 → 交给 `AppState::obs_start`」:补配置、写 Grafana 数据源、
    // 起进程、等就绪那四步全在它那个后台 Job 里(lib.rs),等就绪是阻塞的、
    // Loki 最长 36 秒 —— 在这儿自己起进程就会把窗口冻住几十秒,
    // 而且那四步的顺序(先配置、后进程、同一组端口)是本页最不该重新实现的东西。
    if let Some(idx) = start_idx {
        cx.state.obs_start(idx, cx.egui);
    }

    // ---- 停止 ----
    //
    // 同样只转一手:`obs_stop` 是唯一一处 kill + wait(见上面那段说明)。
    if let Some(idx) = stop_idx {
        cx.state.obs_stop(idx);
    }
    if stop_all {
        // 停之前先数一下有几件在跑 —— `obs_stop` 对没在跑的那件会写一句
        // 「本来就没在运行」,三件连起来最后那句会盖掉真正停掉的那几件,
        // 所以这里用一句话覆盖掉它。
        let n = OBS_ORDER
            .into_iter()
            .filter(|i| cx.state.misc.obs_proc[*i].is_some())
            .count();
        for idx in OBS_ORDER {
            cx.state.obs_stop(idx);
        }
        cx.state.misc.obs_msg = Some(format!("已停止 {n} 个服务(没在跑的不计)"));
    }

    // ---- 打开 Grafana ----
    if open {
        let ports = crate::obs_ports_u16(&cx.state.misc.obs_ports);
        let url = format!("http://127.0.0.1:{}", ports[OBS_DASHBOARD]);
        // 打开之前把数据源文件按**当前**端口重写一次:这个按钮只有 Grafana
        // 就绪时才点得到,而 Grafana 的 provisioning 是热加载的 —— 刚改过端口、
        // 只重启了 Grafana 的情况下,这一步能让它立刻指向现在的 VM/Loki,
        // 而不是等下一个人去启动任何一件时才被顺手重写。
        // **失败只记日志**:盘满/只读时服务本身是好的,不该因为一个配置文件
        // 把「打开看板」也一起挡掉(那看起来像 Grafana 坏了,方向就查偏了)。
        match observability::provision_datasources(
            &cx.state.paths.root().join("data"),
            ports[OBS_METRICS],
            ports[OBS_LOGS],
        ) {
            Ok(p) => cx
                .state
                .log
                .write("grafana", &format!("打开前核对数据源:{}", p.display())),
            Err(e) => cx
                .state
                .log
                .write("grafana", &format!("核对数据源失败(不影响打开):{e}")),
        }
        // 和书签页那一处一样走 `cmd /c start`:用系统默认浏览器,
        // 不在 GUI 里自己实现一个 —— 内嵌是 Browser 组那几页的事。
        #[cfg(windows)]
        {
            let _ = std::process::Command::new("cmd")
                .args(["/c", "start", "", &url])
                .spawn();
        }
        #[cfg(not(windows))]
        {
            let _ = nfarms_core::browser::open_external(&url);
        }
        cx.state.misc.obs_msg = Some(format!("已交给系统浏览器:{url}"));
        cx.state.log.write("grafana", &format!("打开 {url}"));
    }
}

// ===========================================================================
// Slack off → 数字雨
// ===========================================================================

// ===========================================================================
// Zero Trust VPN → Packet capture(Wireshark)
// ===========================================================================

pub fn packet_capture(cx: &mut PageCtx<'_>) {
    use nfarms_core::wireshark;

    // 首次进页面:探测 Wireshark 并枚举网卡(枚举要起 dumpcap,放后台)
    // 探测一次(用 probed 标志,不用 is_none —— 理由见 DshState::probed)
    if !cx.state.capture.probed {
        cx.state.capture.probed = true;
        let ws = wireshark::Wireshark::detect();
        if ws.available() {
            let ws2 = ws.clone();
            cx.state
                .capture
                .iface_job
                .start(cx.egui, move || ws2.interfaces());
        }
        cx.state.capture.ws = Some(ws);
    }

    let mut rescan = false;
    let mut start = false;
    let mut stop = false;
    let mut open_ws = false;
    let capturing = cx.state.capture.capturing();
    let ws_available = cx
        .state
        .capture
        .ws
        .as_ref()
        .map(|w| w.available())
        .unwrap_or(false);

    ui::card(cx.ui, |ui| {
        ui::section(
            ui,
            "Packet capture",
            "集成 Wireshark / dumpcap:列网卡、抓包落盘、交给 Wireshark 打开",
        );
        ui.add_space(6.0);
        ui.label(
            RichText::new(ui::plain(
                "**不自己实现抓包** —— dumpcap 已经把权限(Npcap 驱动)、环形缓冲、\
                 多接口这些脏活做完了,重写一遍没有意义。这里只负责:找它、列网卡、\
                 把选中的网卡落到 pcapng、交给 Wireshark 打开。",
            ))
            .size(11.5)
            .color(theme::TEXT_SUB),
        );
        ui.add_space(8.0);
        if let Some(ws) = &cx.state.capture.ws {
            ui.label(
                RichText::new(ws.status_text())
                    .size(11.5)
                    .monospace()
                    .color(theme::TEXT_MAIN),
            );
        }
        ui.add_space(8.0);
        ui.horizontal(|ui| {
            if ui
                .add_enabled(ws_available && !capturing, egui::Button::new("重新枚举网卡"))
                .clicked()
            {
                rescan = true;
            }
            if ui
                .add_enabled(capturing, egui::Button::new("停止抓包"))
                .clicked()
            {
                stop = true;
            }
            if cx.state.capture.iface_job.is_running() {
                ui.spinner();
            }
        });
    });

    if rescan
        && let Some(ws) = cx.state.capture.ws.clone()
    {
        cx.state
            .capture
            .iface_job
            .start(cx.egui, move || ws.interfaces());
    }
    if stop {
        let ok = cx.state.capture.stop();
        cx.state.capture.message = Some(if ok {
            "已停止抓包(dumpcap 会把已抓的包 flush 到文件)".to_string()
        } else {
            "当前没有在抓包".to_string()
        });
    }

    cx.ui.add_space(12.0);

    // ---- 网卡列表 ----
    ui::card(cx.ui, |ui| {
        ui::section(ui, "网卡", "dumpcap -D 的原样列表。");
        ui.add_space(6.0);
        match cx.state.capture.iface_job.result() {
            None => {
                ui.label(
                    RichText::new(if ws_available { "正在枚举…" } else { "先装好 Wireshark 才能枚举网卡。" })
                        .size(12.0)
                        .color(theme::TEXT_SUB),
                );
            }
            Some(Err(e)) => {
                ui::err_hint(ui, "没读到网卡");
                ui.add_space(4.0);
                ui.label(RichText::new(e).size(11.5).color(theme::TEXT_SUB));
            }
            Some(Ok(list)) => {
                for (i, f) in list.iter().enumerate() {
                    let selected = cx.state.capture.iface_index == f.index;
                    if ui
                        .selectable_label(selected, RichText::new(f.label()).size(12.5))
                        .on_hover_text(&f.device)
                        .clicked()
                    {
                        cx.state.capture.iface_index = f.index;
                    }
                    let _ = i;
                }
            }
        }
    });

    cx.ui.add_space(12.0);

    // ---- 抓包参数 ----
    ui::card(cx.ui, |ui| {
        ui::section(ui, "抓包", "过滤表达式是 BPF 语法(和 Wireshark 输入框里的一样)。");
        ui.add_space(8.0);
        ui.horizontal(|ui| {
            ui.label(RichText::new("过滤").size(12.0).color(theme::TEXT_SUB));
            ui.add_sized(
                [420.0, 26.0],
                egui::TextEdit::singleline(&mut cx.state.capture.filter)
                    .hint_text("留空 = 全抓;例:tcp port 443 or host 10.0.0.1"),
            );
        });
        ui.add_space(4.0);
        ui.horizontal(|ui| {
            ui.label(RichText::new("输出").size(12.0).color(theme::TEXT_SUB));
            ui.add_sized(
                [420.0, 26.0],
                egui::TextEdit::singleline(&mut cx.state.capture.out)
                    .hint_text("留空 = shots\\capture-<时间戳>.pcapng"),
            );
        });
        ui.add_space(8.0);
        ui.horizontal(|ui| {
            if ui
                .add_enabled(
                    ws_available && !capturing,
                    egui::Button::new("开始抓包").min_size(egui::vec2(100.0, 30.0)),
                )
                .clicked()
            {
                start = true;
            }
            if ui
                .add_enabled(
                    cx.state.capture.last_pcap.is_some(),
                    egui::Button::new("用 Wireshark 打开").min_size(egui::vec2(150.0, 30.0)),
                )
                .clicked()
            {
                open_ws = true;
            }
            if capturing {
                ui.label(
                    RichText::new("抓包中…")
                        .size(12.0)
                        .color(theme::ACCENT_DARK),
                );
            }
        });
        if let Some(m) = &cx.state.capture.message {
            ui.add_space(6.0);
            ui::ok_hint(ui, m);
        }
        if let Some(p) = &cx.state.capture.last_pcap {
            ui.add_space(4.0);
            ui::kv(ui, "最近文件", &p.display().to_string());
        }
    });

    if start {
        let out = if cx.state.capture.out.trim().is_empty() {
            cx
                .state
                .paths
                .shots()
                .join(format!("capture-{}.pcapng", crate::now_stamp()))
        } else {
            std::path::PathBuf::from(cx.state.capture.out.trim())
        };
        if let Some(parent) = out.parent() {
            let _ = std::fs::create_dir_all(parent);
        }
        let idx = cx.state.capture.iface_index;
        let filter = cx.state.capture.filter.clone();
        match cx.state.capture.ws.as_ref() {
            Some(ws) => match ws.start_capture(idx, &out, &filter) {
                Ok(child) => {
                    cx.state.capture.child = Some(child);
                    cx.state.capture.last_pcap = Some(out.clone());
                    cx.state.capture.message = Some(format!("已开始抓包 → {}", out.display()));
                    cx.state
                        .log
                        .write("capture", &format!("开始抓包 index={idx} -> {}", out.display()));
                }
                Err(e) => cx.state.capture.message = Some(e),
            },
            None => cx.state.capture.message = Some("Wireshark 还没探测到".to_string()),
        }
    }
    if open_ws
        && let (Some(ws), Some(p)) = (
            cx.state.capture.ws.as_ref(),
            cx.state.capture.last_pcap.clone(),
        )
    {
        match ws.open_in_wireshark(&p) {
            Ok(()) => {
                cx.state.capture.message = Some("已交给 Wireshark 打开".to_string());
                cx.state
                    .log
                    .write("capture", &format!("用 Wireshark 打开 {}", p.display()));
            }
            Err(e) => cx.state.capture.message = Some(e),
        }
    }
}

// ===========================================================================
// Zero Trust VPN → SecureCRT / WinSCP(深度集成)
// ===========================================================================

pub fn secure_crt(cx: &mut PageCtx<'_>) {
    use nfarms_core::remote;

    if !cx.state.remote.crt_probed {
        cx.state.remote.crt_probed = true;
        cx.state.remote.crt_exe = remote::find_securecrt();
    }
    let exe = cx.state.remote.crt_exe.clone();
    let dir = remote::securecrt_session_dir();

    let mut reload = false;
    let mut launch: Option<remote::Session> = None;

    ui::card(cx.ui, |ui| {
        ui::section(
            ui,
            "SecureCRT",
            "深度集成:读它的会话目录,按会话名直接拉起",
        );
        ui.add_space(6.0);
        ui.label(
            RichText::new(ui::plain(
                "深度集成 = 真去读 `%APPDATA%\\VanDyke\\Config\\Sessions\\*.ini`,列出全部会话,\
                 点一下用**它自己的会话名**启动(`/S`)。这样隧道、跳板、端口转发、字符编码\
                 这些会话设置全都保留 —— 自己拼 `host:port` 会把它们全丢掉。\
                 **只读不写、不碰密码。**",
            ))
            .size(11.5)
            .color(theme::TEXT_SUB),
        );
        ui.add_space(8.0);
        ui::kv(
            ui,
            "程序",
            &exe
                .as_ref()
                .map(|p| p.display().to_string())
                .unwrap_or_else(|| "(没找到 SecureCRT.exe)".to_string()),
        );
        ui::kv(
            ui,
            "会话目录",
            &dir.as_ref()
                .map(|p| p.display().to_string())
                .unwrap_or_else(|| "(没有 %APPDATA%\\VanDyke\\Config\\Sessions)".to_string()),
        );
        ui.add_space(8.0);
        ui.horizontal(|ui| {
            if ui
                .add_enabled(exe.is_some(), egui::Button::new("重新读会话"))
                .clicked()
            {
                reload = true;
            }
            if cx.state.remote.crt_sessions.is_running() {
                ui.spinner();
            }
        });
    });

    if cx.state.remote.crt_sessions.result().is_none() && !cx.state.remote.crt_sessions.is_running() {
        reload = true;
    }
    if reload {
        cx.state.remote.crt_sessions.start(cx.egui, || {
            nfarms_core::remote::securecrt_sessions()
        });
    }

    cx.ui.add_space(12.0);
    ui::card(cx.ui, |ui| {
        let n = cx
            .state
            .remote
            .crt_sessions
            .result()
            .map(|v| v.len())
            .unwrap_or(0);
        ui::section(ui, &format!("会话({n} 个)"), "点会话名直接打开。");
        ui.add_space(6.0);
        match cx.state.remote.crt_sessions.result() {
            None => {
                ui.label(RichText::new("读取中…").size(12.0).color(theme::TEXT_SUB));
            }
            Some(list) if list.is_empty() => {
                ui.label(
                    RichText::new(
                        "没有读到会话。要么 SecureCRT 还没建过会话,要么它把配置放在别处\
                         (便携模式会放在程序目录下的 Config\\)。",
                    )
                    .size(11.5)
                    .color(theme::TEXT_SUB),
                );
            }
            Some(list) => {
                for s in list {
                    ui.horizontal(|ui| {
                        if ui
                            .add_enabled(exe.is_some(), egui::Button::new("打开"))
                            .clicked()
                        {
                            launch = Some(s.clone());
                        }
                        ui.add_sized(
                            [280.0, 18.0],
                            egui::Label::new(
                                RichText::new(&s.name).size(12.5).color(theme::TEXT_MAIN),
                            ),
                        );
                        ui.label(
                            RichText::new(s.summary())
                                .size(11.5)
                                .color(theme::TEXT_SUB),
                        );
                    });
                }
            }
        }
    });

    if let (Some(s), Some(e)) = (launch, cx.state.remote.crt_exe.clone()) {
        match remote::launch_securecrt(&e, &s) {
            Ok(()) => {
                cx.state.remote.message = Some(format!("已打开会话 {}", s.name));
                cx.state
                    .log
                    .write("securecrt", &format!("打开会话 {}", s.name));
            }
            Err(err) => cx.state.remote.message = Some(err),
        }
    }
    if let Some(m) = &cx.state.remote.message {
        cx.ui.add_space(10.0);
        ui::ok_hint(cx.ui, m);
    }
}

// ===========================================================================
// Environment → DSH-Starter
// ===========================================================================

/// `dsh_page`「控制台输出」卡片的跨帧缓存。
///
/// `dsh::tail()` 现在的实现是**先把整个日志读进内存**再取尾部 40 行
/// (`log.rs` 那句 `std::fs::read_to_string`),而这页开着的时候卡片**每帧**
/// 都会调一次;`dsh-web.log` 又没有任何轮转,跑上一天之后每帧一次全量读
/// 就会直接吃掉帧率。
///
/// 缓存键是(路径、长度、修改时间、行数)四元组:四个都没变就复用上一帧的
/// 字符串,一个变了就重读。**拿不到修改时间时不用缓存** —— 宁可每帧重读,
/// 也不能在日志变了之后还显示上一次的内容。
struct DshTailCache {
    path: PathBuf,
    len: u64,
    mtime: Option<std::time::SystemTime>,
    lines: usize,
    text: String,
}

thread_local! {
    static DSH_TAIL: RefCell<Option<DshTailCache>> = const { RefCell::new(None) };
}

// 给测试用的「真的读了几次盘」计数器。界面看不出命中与否(结果一样),
// 所以只能在这里看;生产构建里这个 static 根本不存在。
// (`//` 而非 `///`:理由同上面 `MGR_PROBE`。)
#[cfg(test)]
thread_local! {
    static TAIL_READS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
}

/// 带跨帧缓存的 `dsh::tail()`(理由见 [`DshTailCache`])。
fn tail_cached(log: &Path, lines: usize) -> String {
    let meta = std::fs::metadata(log).ok();
    let len = meta.as_ref().map(|m| m.len()).unwrap_or(0);
    let mtime = meta.as_ref().and_then(|m| m.modified().ok());
    DSH_TAIL.with(|cache| {
        let mut cache = cache.borrow_mut();
        if let Some(c) = cache.as_ref()
            && mtime.is_some()
            && c.path == log
            && c.len == len
            && c.mtime == mtime
            && c.lines == lines
        {
            return c.text.clone();
        }
        let text = nfarms_core::dsh::tail(log, lines);
        #[cfg(test)]
        TAIL_READS.with(|n| n.set(n.get() + 1));
        *cache = Some(DshTailCache {
            path: log.to_path_buf(),
            len,
            mtime,
            lines,
            text: text.clone(),
        });
        text
    })
}

pub fn dsh_page(cx: &mut PageCtx<'_>) {
    use nfarms_core::dsh;

    // **只探测一次**,而且要用 `probed` 而不是 `exe.is_none()` 当标志 ——
    // 「没装 dsh」是个合法结果,用 is_none() 当守卫会变成每帧跑一次
    // `where dsh`(每次起一个 cmd.exe),屏幕上就是疯狂闪控制台。
    if !cx.state.dsh.probed {
        cx.state.dsh.exe = dsh::find();
        cx.state.dsh.probed = true;
        // 写一行日志:一是诊断用,二是**这条只该出现一次** ——
        // 它要是刷屏,就说明 `probed` 守卫坏了(以前正是每帧跑一次,
        // 屏幕上疯狂闪控制台)。
        let msg = cx
            .state
            .dsh
            .exe
            .clone()
            .unwrap_or_else(|| "(PATH 上没有)".to_string());
        cx.state.log.write("dsh", &format!("探测 dsh: {msg}"));
    }

    let mut start = false;
    let mut stop = false;
    let mut open_url = false;
    let running = cx.state.dsh.child.is_some();

    ui::card(cx.ui, |ui| {
        ui::section(
            ui,
            "DSH-Starter",
            "一键 dsh web --port <端口>,控制台输出集成在界面里",
        );
        ui.add_space(6.0);
        ui.label(
            RichText::new(ui::plain(
                "原版踩过的坑:**`dsh web` 打印的根地址带一次性 token**,\
                 不带 token 打开只会 401。所以地址要从它自己的 stdout 里**抓**,\
                 不能自己拼 `http://127.0.0.1:<端口>/`。",
            ))
            .size(11.5)
            .color(theme::TEXT_SUB),
        );
        ui.add_space(8.0);
        ui::kv(
            ui,
            "dsh",
            &cx.state
                .dsh
                .exe
                .clone()
                .unwrap_or_else(|| "(PATH 上没有 dsh —— 它通常由 npm 全局装)".to_string()),
        );
        ui::kv(
            ui,
            "日志",
            &cx.state.paths.show(&dsh::log_path(&cx.state.paths)),
        );
        ui.add_space(8.0);
        ui.horizontal(|ui| {
            // 端口已集中到 Setting › Proxy and Port —— 那里是唯一改端口的地方。
            // 端口只在 Setting › Proxy and Port 改。这里显示当前值 ——
            // 一眼能看到"它跑在哪个端口上",但改不了,避免两处都能改。
            ui.label(
                RichText::new(format!("端口 {}", cx.state.dsh.port))
                    .size(11.5)
                    .color(theme::TEXT_SUB),
            )
            .on_hover_text("要改端口请到 Setting › Proxy and Port");
            if ui
                .add_enabled(
                    !running && cx.state.dsh.exe.is_some(),
                    egui::Button::new("启动").min_size(egui::vec2(80.0, 28.0)),
                )
                .clicked()
            {
                start = true;
            }
            if ui
                .add_enabled(running, egui::Button::new("停止").min_size(egui::vec2(80.0, 28.0)))
                .clicked()
            {
                stop = true;
            }
            if ui
                .add_enabled(
                    cx.state.dsh.url.is_some(),
                    egui::Button::new("用浏览器打开(带 token)").min_size(egui::vec2(180.0, 28.0)),
                )
                .clicked()
            {
                open_url = true;
            }
            if running {
                ui.spinner();
            }
        });
        if let Some(u) = &cx.state.dsh.url {
            ui.add_space(4.0);
            ui::kv(ui, "根地址", u);
        } else if running {
            ui.add_space(4.0);
            ui.label(
                RichText::new("还在等 dsh 打印带 token 的地址…(它启动要几百毫秒)")
                    .size(11.5)
                    .color(theme::TEXT_SUB),
            );
        }
        if let Some(m) = &cx.state.dsh.message {
            ui.add_space(4.0);
            ui::ok_hint(ui, m);
        }
    });

    if start {
        let log = dsh::log_path(&cx.state.paths);
        match dsh::start(cx.state.dsh.port, &log) {
            Ok(child) => {
                cx.state.dsh.child = Some(child);
                cx.state.dsh.url = None;
                cx.state.dsh.message = Some(format!("已启动,监听 :{}", cx.state.dsh.port));
                cx.state
                    .log
                    .write("dsh", &format!("启动 dsh web --port {}", cx.state.dsh.port));
            }
            Err(e) => cx.state.dsh.message = Some(e),
        }
    }
    if stop {
        if let Some(mut c) = cx.state.dsh.child.take() {
            let _ = c.kill();
            let _ = c.wait();
        }
        cx.state.dsh.url = None;
        cx.state.dsh.message = Some("已停止".to_string());
        cx.state.log.write("dsh", "停止 dsh");
    }
    if open_url
        && let Some(u) = cx.state.dsh.url.clone()
    {
        match nfarms_core::browser::open_external(&u) {
            Ok(()) => cx.state.dsh.message = Some("已交给系统浏览器".to_string()),
            Err(e) => cx.state.dsh.message = Some(e),
        }
    }

    cx.ui.add_space(12.0);
    let log = dsh::log_path(&cx.state.paths);
    ui::card(cx.ui, |ui| {
        ui::section(ui, "控制台输出(尾部 40 行)", "读的是日志文件,不接管道。");
        ui.add_space(6.0);
        // 走带缓存的版本:这一页每帧都画,文件没变就不该每帧重读一遍。
        let text = tail_cached(&log, 40);
        if text.trim().is_empty() {
            ui.label(RichText::new("(还没有输出)").size(12.0).color(theme::TEXT_SUB));
        } else {
            ui.label(
                RichText::new(text)
                    .size(11.0)
                    .monospace()
                    .color(theme::TEXT_MAIN),
            );
        }
    });
}

// ===========================================================================
// Tools → bilibili-go(直播录制)
// ===========================================================================

/// bilibili-go 与「拉流」两页共用的一块:bililive-go 的运行控制。
///
/// 抽出来是因为原版这两处本来就是同一个东西(`Live.cs`):Tools 里的
/// bilibili-go 是「录制管理」,Player 里的拉流是「解析 + 录制」。
pub fn live_go_card(cx: &mut PageCtx<'_>, show_rooms: bool) {
    use nfarms_core::live;

    if !cx.state.live_go.probed {
        cx.state.live_go.probed = true;
        let l = live::Live::detect(&cx.state.paths);
        cx.state.live_go.rooms = l.rooms();
        cx.state.live_go.live = Some(l);
    }

    let mut start = false;
    let mut stop = false;
    let mut ping = false;
    let mut add = false;
    let mut remove: Option<String> = None;

    let running = cx.state.live_go.child.is_some();
    let available = cx
        .state
        .live_go
        .live
        .as_ref()
        .map(|l| l.available())
        .unwrap_or(false);

    ui::card(cx.ui, |ui| {
        ui::section(
            ui,
            "bililive-go(直播录制)",
            "外部程序:本程序不重写它,只做「找到它、起停它、读写它的 config.yml」",
        );
        ui.add_space(6.0);
        if let Some(l) = &cx.state.live_go.live {
            ui.label(
                RichText::new(l.status_text())
                    .size(11.5)
                    .color(theme::TEXT_MAIN),
            );
            ui::kv(ui, "配置", &cx.state.paths.show(&l.config_path()));
            ui::kv(ui, "日志", &cx.state.paths.show(&l.log_path()));
        }
        ui.add_space(8.0);
        ui.horizontal(|ui| {
            if ui
                .add_enabled(
                    !running && available,
                    egui::Button::new("启动服务").min_size(egui::vec2(100.0, 28.0)),
                )
                .clicked()
            {
                start = true;
            }
            if ui
                .add_enabled(running, egui::Button::new("停止").min_size(egui::vec2(80.0, 28.0)))
                .clicked()
            {
                stop = true;
            }
            if ui.button("查状态").clicked() {
                ping = true;
            }
            if ui
                .add_enabled(
                    available || running,
                    egui::Button::new("打开下载页").min_size(egui::vec2(110.0, 28.0)),
                )
                .clicked()
            {
                let _ = nfarms_core::browser::open_external(live::RELEASES_PAGE);
            }
            if running {
                ui.spinner();
            }
        });
        if let Some(s) = &cx.state.live_go.status {
            ui.add_space(4.0);
            ui::kv(ui, "服务", s);
        }
        if let Some(m) = &cx.state.live_go.message {
            ui.add_space(4.0);
            ui::ok_hint(ui, m);
        }
    });

    if start {
        match cx.state.live_go.live.as_ref().map(|l| l.start()) {
            Some(Ok(child)) => {
                cx.state.live_go.child = Some(child);
                cx.state.live_go.message = Some("已启动(输出在日志文件里)".to_string());
                cx.state.log.write("live", "启动 bililive-go");
            }
            Some(Err(e)) => cx.state.live_go.message = Some(e),
            None => cx.state.live_go.message = Some("还没探测到".to_string()),
        }
    }
    if stop {
        if let Some(mut c) = cx.state.live_go.child.take() {
            let _ = c.kill();
            let _ = c.wait();
        }
        cx.state.live_go.status = None;
        cx.state.live_go.message = Some("已停止".to_string());
    }
    if ping
        && let Some(l) = &cx.state.live_go.live
    {
        match l.ping() {
            Ok(s) => {
                cx.state.live_go.status = Some(s);
                cx.state.live_go.message = Some("服务在跑".to_string());
            }
            Err(e) => {
                cx.state.live_go.status = None;
                cx.state.live_go.message = Some(e);
            }
        }
    }

    if !show_rooms {
        return;
    }

    cx.ui.add_space(12.0);
    ui::card(cx.ui, |ui| {
        ui::section(
            ui,
            &format!("直播间({} 个)", cx.state.live_go.rooms.len()),
            "读写的是 libs\\bililive-go\\config.yml —— 只动 live_rooms 那一段,其它设置原样保留",
        );
        ui.add_space(8.0);
        ui.horizontal(|ui| {
            ui.add_sized(
                [420.0, 26.0],
                egui::TextEdit::singleline(&mut cx.state.live_go.room_input)
                    .hint_text("https://live.bilibili.com/123"),
            );
            if ui
                .add_enabled(
                    !cx.state.live_go.room_input.trim().is_empty(),
                    egui::Button::new("添加"),
                )
                .clicked()
            {
                add = true;
            }
        });
        ui.add_space(6.0);
        if cx.state.live_go.rooms.is_empty() {
            ui.label(
                RichText::new("(还没有直播间)—— 加一个地址,或者直接用 bililive-go 自己的界面加")
                    .size(12.0)
                    .color(theme::TEXT_SUB),
            );
        }
        for (i, r) in cx.state.live_go.rooms.iter().enumerate() {
            ui.horizontal(|ui| {
                if ui.button("删除").clicked() {
                    remove = Some(r.clone());
                }
                ui.label(
                    RichText::new(format!("{i}. {r}"))
                        .size(12.0)
                        .color(theme::TEXT_MAIN),
                );
            });
        }
    });

    if add {
        let url = cx.state.live_go.room_input.trim().to_string();
        match cx.state.live_go.live.as_ref().map(|l| l.add_room(&url)) {
            Some(Ok(true)) => {
                cx.state.live_go.rooms = cx
                    .state
                    .live_go
                    .live
                    .as_ref()
                    .map(|l| l.rooms())
                    .unwrap_or_default();
                cx.state.live_go.room_input.clear();
                cx.state.live_go.message = Some(format!("已加入 {url}"));
                cx.state.log.write("live", &format!("加入直播间 {url}"));
            }
            Some(Ok(false)) => cx.state.live_go.message = Some("这个地址已经有了".to_string()),
            Some(Err(e)) => cx.state.live_go.message = Some(e),
            None => {}
        }
    }
    if let Some(r) = remove {
        match cx.state.live_go.live.as_ref().map(|l| l.remove_room(&r)) {
            Some(Ok(true)) => {
                cx.state.live_go.rooms = cx
                    .state
                    .live_go
                    .live
                    .as_ref()
                    .map(|l| l.rooms())
                    .unwrap_or_default();
                cx.state.live_go.message = Some(format!("已删除 {r}"));
            }
            Some(Ok(false)) => cx.state.live_go.message = Some("配置里没找到这条".to_string()),
            Some(Err(e)) => cx.state.live_go.message = Some(e),
            None => {}
        }
    }
}

pub fn bilibili_go(cx: &mut PageCtx<'_>) {
    live_go_card(cx, true);
}

// ===========================================================================
// Player → 视频拉流与解析
// ===========================================================================

// ===========================================================================
// Tools → Script(脚本下发)
// ===========================================================================

// ===========================================================================
// Player → 视频播放器
// ===========================================================================

pub fn video(cx: &mut PageCtx<'_>) {
    use crate::background::FilePicker as FP;
    let ff = nfarms_core::ffmpeg::Ffmpeg::detect(&cx.state.paths);
    let mut pick = false;
    let mut load = false;
    let mut toggle = false;
    let mut stop = false;
    let mut seek_to: Option<i64> = None;

    let dur = cx.state.misc.video_duration_ms;
    let has = dur > 0;

    ui::card(cx.ui, |ui| {
        ui::section(
            ui,
            "视频播放器",
            "原版嵌 Windows Media Player ActiveX;Rust 这版统一走 ffmpeg 抽帧上纹理",
        );
        ui.add_space(6.0);
        ui.label(
            RichText::new(ui::plain(
                "原版用 ActiveX 嵌 WMP,解码能力受系统限制(装了什么解码器就只能播什么)。\
                 这里改走 ffmpeg:**按时间点抽帧 → 解码 → 上纹理**。\
                 代价是约 2 帧/秒(每次抽帧都要起一次 ffmpeg),好处是格式支持等于 ffmpeg,\
                 而且和「拉流」那页共用同一套地基。要做 30 帧的顺滑播放,得改成\
                 `-f rawvideo` 管道喂帧,那是下一步。",
            ))
            .size(11.5)
            .color(theme::TEXT_SUB),
        );
        ui.add_space(8.0);
        if !ff.available() {
            ui.label(
                RichText::new("ffmpeg 没探测到 —— 先去 Tools › ffmpeg 那一页装好。")
                    .size(12.0)
                    .color(theme::DANGER),
            );
            ui.add_space(6.0);
        }
        ui.horizontal(|ui| {
            if ui.button("打开视频…").clicked() {
                pick = true;
            }
            ui.add_sized(
                [380.0, 26.0],
                egui::TextEdit::singleline(&mut cx.state.misc.video_src)
                    .hint_text(r"C:\video\input.mkv 或 http://…/live.m3u8"),
            );
            if ui
                .add_enabled(ff.available(), egui::Button::new("载入"))
                .clicked()
            {
                load = true;
            }
        });
        ui.add_space(8.0);
        ui.horizontal(|ui| {
            let label = if cx.state.misc.video_playing {
                "暂停"
            } else {
                "播放"
            };
            if ui
                .add_enabled(has, egui::Button::new(label).min_size(egui::vec2(80.0, 30.0)))
                .clicked()
            {
                toggle = true;
            }
            if ui
                .add_enabled(has, egui::Button::new("停止").min_size(egui::vec2(80.0, 30.0)))
                .clicked()
            {
                stop = true;
            }
            ui.label(
                RichText::new(format!(
                    "{} / {}",
                    nfarms_core::music::Music::fmt_ms(cx.state.misc.video_pos_ms),
                    nfarms_core::music::Music::fmt_ms(dur)
                ))
                .size(12.0)
                .color(theme::TEXT_SUB),
            );
            if cx.state.misc.video_frame.is_running() {
                ui.spinner();
            }
        });
        let mut p = cx.state.misc.video_pos_ms as f32;
        if ui
            .add(
                egui::Slider::new(&mut p, 0.0..=(if has { dur as f32 } else { 1.0 }))
                    .show_value(false),
            )
            .drag_stopped()
        {
            seek_to = Some(p as i64);
        }
        if !cx.state.misc.video_info.is_empty() {
            ui.add_space(6.0);
            ui.label(
                RichText::new(&cx.state.misc.video_info)
                    .size(11.0)
                    .monospace()
                    .color(theme::TEXT_SUB),
            );
        }
        if let Some(m) = &cx.state.misc.message {
            ui.add_space(4.0);
            ui::ok_hint(ui, m);
        }
    });

    if pick {
        // 异步:结果从 `picker.poll()` 回来,由 `poll_background_drop`
        // 写进 `video_src` 并置 `video_want_load`。
        cx.state.picker.pick_file(
            cx.egui,
            crate::background::PickPurpose::Video,
            "打开视频",
            FP::VIDEO_FILTER,
        );
    }
    // 上一帧(或别的页面)请求载入 —— 走下面同一条载入路径,不重写一份
    if std::mem::take(&mut cx.state.misc.video_want_load) {
        load = true;
    }
    if load {
        let src = cx.state.misc.video_src.trim().to_string();
        if src.is_empty() {
            cx.state.misc.message = Some("源是空的".to_string());
        } else if ff.available() && let Some(ff2) = Some(ff.clone()) {
            let p = std::path::PathBuf::from(&src);
            let info = ff2.media_info(&p);
            let d = ff2.duration_ms(&p);
            cx.state.misc.video_info = info;
            cx.state.misc.video_duration_ms = d.max(0);
            cx.state.misc.video_pos_ms = 0;
            cx.state.misc.video_last_shot = -1;
            cx.state.misc.video_tex = None;
            cx.state.misc.message = Some(if d > 0 {
                format!("载入成功,时长 {}", nfarms_core::music::Music::fmt_ms(d))
            } else {
                "读到文件但拿不到时长(远程流常常如此);播放仍可尝试".to_string()
            });
            // **载入完直接播。** 选了文件就是要看,不该再点一次「播放」。
            cx.state.misc.video_playing = true;
            cx.state.misc.video_last_shot = -1;
            cx.state.log.write("video", &format!("载入 {src}({d} ms),开始播放"));
        }
    }
    if stop {
        cx.state.misc.video_playing = false;
        cx.state.misc.video_pos_ms = 0;
    }
    if toggle {
        cx.state.misc.video_playing = !cx.state.misc.video_playing;
        cx.state.misc.video_last_shot = -1;
    }
    if let Some(ms) = seek_to {
        cx.state.misc.video_pos_ms = ms;
        cx.state.misc.video_last_shot = -1;
    }

    // ---- 抽帧驱动 ----
    // 完成上一帧 → 上纹理 → 位置前进 500 ms(约 2 帧/秒)
    if cx.state.misc.video_frame.poll()
        && let Some(r) = cx.state.misc.video_frame.result().cloned()
    {
        match r {
            Ok((img, (w, h))) => {
                cx.state.misc.video_tex = Some(cx.egui.load_texture(
                    "nfarms-video-frame",
                    img,
                    egui::TextureOptions::LINEAR,
                ));
                let _ = (w, h);
                if cx.state.misc.video_playing {
                    cx.state.misc.video_pos_ms += 500;
                    let dur = cx.state.misc.video_duration_ms;
                    if dur > 0 && cx.state.misc.video_pos_ms >= dur {
                        cx.state.misc.video_pos_ms = dur;
                        cx.state.misc.video_playing = false;
                        cx.state.misc.message = Some("播放到结尾".to_string());
                    }
                }
            }
            Err(e) => {
                cx.state.misc.video_playing = false;
                cx.state.misc.message = Some(format!("抽帧失败: {e}"));
            }
        }
    }
    // 需要新帧就起一个任务
    let need_frame = ff.available()
        && !cx.state.misc.video_src.trim().is_empty()
        && !cx.state.misc.video_frame.is_running()
        && (cx.state.misc.video_playing || cx.state.misc.video_last_shot != cx.state.misc.video_pos_ms);
    if need_frame {
        let at = cx.state.misc.video_pos_ms;
        cx.state.misc.video_last_shot = at;
        let src = cx.state.misc.video_src.trim().to_string();
        let ff2 = ff.clone();
        let tmp = std::env::temp_dir().join(format!("nfarms-vframe-{}.png", at));
        let tmp2 = tmp.clone();
        cx.state.misc.video_frame.start(cx.egui, move || {
            ff2.snapshot(std::path::Path::new(&src), &tmp2, at as f64 / 1000.0)?;
            let r = crate::background::decode_for_viewer(&tmp2);
            let _ = std::fs::remove_file(&tmp2);
            r
        });
        cx.egui
            .request_repaint_after(std::time::Duration::from_millis(120));
    }

    cx.ui.add_space(12.0);
    let max_h = (cx.egui.screen_rect().height() - 460.0).max(200.0);
    // 纹理的 id / 尺寸先拷出来:`ui::card` 的闭包已经把 `cx` 借走了,
    // 闭包里再碰 `cx.state` 就是二次借用。
    let tex_info = cx
        .state
        .misc
        .video_tex
        .as_ref()
        .map(|t| (t.id(), t.size_vec2()));
    ui::card(cx.ui, |ui| match tex_info {
        Some((id, sz)) => {
            let (w, h) = (sz.x.max(1.0), sz.y.max(1.0));
            let scale = (ui.available_width() / w).min(max_h / h).min(2.0);
            ui.add(egui::Image::new((id, egui::vec2(w * scale, h * scale))));
        }
        None => {
            ui.label(
                RichText::new("(还没有画面)—— 选一个视频再点「载入」")
                    .size(12.5)
                    .color(theme::TEXT_SUB),
            );
        }
    });
}

// ===========================================================================
// Setting → 监控并同步 / 打印代理
// ===========================================================================
//
// 「启动动图」原来就在这一节里(一个独立的 `boot_anim` 绘制函数)。它的卡片已经
// 并进 Setting › 背景图片 那一页(见 `boot_card`),原来的 `Page::SettingsBootAnim`
// 变体、`PAGES` 表里的条目和 `plugins/mod.rs` 的映射由另一处改动同步删掉 ——
// 这里只保证绘制函数不再存在。

pub fn sync(cx: &mut PageCtx<'_>) {
    let mut report = false;
    // 报文在闭包外算好:`ui::card` 的闭包已经把 `cx` 借走了
    let payload = sync_payload(cx);

    ui::card(cx.ui, |ui| {
        ui::section(ui, "监控并同步", "把本机状态上报到平台(需要先选择开启)");
        ui.add_space(6.0);
        ui.label(
            RichText::new(ui::plain(
                "**默认关闭,而且绝不偷偷打开**。上报内容只有下面这几项,不含任何路径之外的\
                 个人信息;上报前会把报文原样列出来给你看。\n\
                 原版这里是「实时监控 + 定时同步」,但没说明白了发什么 —— 这一版把它改清楚了。",
            ))
            .size(11.5)
            .color(theme::TEXT_SUB),
        );
        ui.add_space(10.0);
        ui.horizontal(|ui| {
            ui.checkbox(&mut cx.state.misc.sync_enabled, RichText::new("开启上报").size(12.5));
            ui.label(RichText::new("目标").size(12.0).color(theme::TEXT_SUB));
            ui.add_sized(
                [260.0, 26.0],
                egui::TextEdit::singleline(&mut cx.state.misc.sync_target),
            );
            if ui
                .add_enabled(
                    cx.state.misc.sync_enabled,
                    egui::Button::new("立即上报一次").min_size(egui::vec2(120.0, 28.0)),
                )
                .clicked()
            {
                report = true;
            }
        });
        ui.add_space(8.0);
        ui::section(ui, "将要发送的内容", "就是这些,没有别的。");
        ui.add_space(4.0);
        ui.label(
            RichText::new(payload)
                .size(11.0)
                .monospace()
                .color(theme::TEXT_MAIN),
        );
        if let Some(m) = &cx.state.misc.message {
            ui.add_space(6.0);
            ui::ok_hint(ui, m);
        }
    });

    if report {
        cx.state
            .log
            .write("sync", &format!("上报一次(目标 {})", cx.state.misc.sync_target));
        cx.state.misc.message = Some(
            "已记录本次上报。真正的上传端点还没定(平台侧接口未提供),所以现在只写日志、不发网络请求 —— \
             宁可如实说没发,也不要假装发成功了。"
                .to_string(),
        );
    }
}

/// 上报报文(给用户看的,和真正会发的一致)。
fn sync_payload(cx: &PageCtx<'_>) -> String {
    format!(
        "{{\n  \"host\": \"{}\",\n  \"version\": \"{}\",\n  \"pages\": {},\n  \"log_dir\": \"{}\"\n}}",
        std::env::var("COMPUTERNAME").unwrap_or_else(|_| "(未知)".to_string()),
        env!("CARGO_PKG_VERSION"),
        crate::pages::PAGES.len(),
        cx.state.paths.logs().display()
    )
}

pub fn printer(cx: &mut PageCtx<'_>) {
    // 这一页还兼着「端口总表」的职责,其中包括可观测性三件套 ——
    // 先把那三个框和 store.json 对齐,免得显示的是上一次的值。
    sync_obs_ports(cx);
    let mut detect = false;
    let mut start = false;
    let mut stop = false;
    let mut open = false;

    if !cx.state.misc.printer_probed {
        cx.state.misc.printer_probed = true;
        // 只探测一次
        let p = cx
            .state
            .paths
            .lib("nfos-printer")
            .join("nfos-printer.exe");
        cx.state.misc.printer_exe = p.is_file().then_some(p);
    }
    let running = cx.state.misc.printer_child.is_some();
    let url = format!("http://127.0.0.1:{}/", cx.state.misc.printer_port);

    ui::card(cx.ui, |ui| {
        ui::section(ui, "打印代理", "本地 HTTP 网关 + 打印:把小票/标签打印对接到平台");
        ui.add_space(6.0);
        ui.label(
            RichText::new(ui::plain(
                "**不重写网关**。原版 `PrintServer.cs` 是个本地 HTTP 服务\
                 (收 POST → 拼指令 → 送打印机),这套东西的复杂度在**打印机指令集**\
                 (ESC/POS、TSPL、ZPL)上,不在 HTTP 上。所以这一页做的是:\
                 探测并起停 `libs\\nfos-printer\\nfos-printer.exe`,把端口和地址管好。",
            ))
            .size(11.5)
            .color(theme::TEXT_SUB),
        );
        ui.add_space(8.0);
        ui::kv(
            ui,
            "网关程序",
            &cx.state
                .misc
                .printer_exe
                .as_ref()
                .map(|p| p.display().to_string())
                .unwrap_or_else(|| {
                    format!(
                        "(没找到)放到 {} 即启用",
                        cx.state.paths.lib("nfos-printer").display()
                    )
                }),
        );
        ui::kv(ui, "监听地址", &url);
        ui.add_space(8.0);
        ui.horizontal(|ui| {
            ui.label(RichText::new("端口").size(12.0).color(theme::TEXT_SUB));
            ui.add_sized(
                [90.0, 26.0],
                egui::DragValue::new(&mut cx.state.misc.printer_port).range(1024..=65535),
            );
            if ui.button("重新探测").clicked() {
                detect = true;
            }
            let has = cx.state.misc.printer_exe.is_some();
            if ui
                .add_enabled(!running && has, egui::Button::new("启动网关").min_size(egui::vec2(100.0, 28.0)))
                .clicked()
            {
                start = true;
            }
            if ui
                .add_enabled(running, egui::Button::new("停止").min_size(egui::vec2(80.0, 28.0)))
                .clicked()
            {
                stop = true;
            }
            if ui
                .add_enabled(running, egui::Button::new("打开接口页"))
                .clicked()
            {
                open = true;
            }
            if running {
                ui.spinner();
            }
        });

            // ===================== 端口总表(唯一改端口的地方)=====================
            // 原来散在三页:DSH 端口在 DSH-Starter 页、llama.cpp 端口在 llama.cpp 页、
            // sd.cpp 端口在 DSH 页。接入 DSH 时这几个**必须配对**(provider 的 baseURL
            // 指向它们),分散开就会出现「改了一个忘了另一个」,而症状是
            // connection refused —— 从这句话里看不出是端口问题。
            //
            // 所以:其他地方一律改成**只读显示**,改端口只在这里。
            // ---- 端口 + 上下文:每个都是**唯一真值** ----
            //
            // 这几项必须**配对**(DSH 侧的 baseURL / contextWindow 指向它们),
            // 分散开就会出现「改了一个忘了另一个」,而症状是 connection
            // refused 或 400 —— 从报错里看不出是端口/上下文问题。
            // 所以:其他地方一律**只读显示**,能改的只有这里,而且只有一处。
            let mut port_msg: Option<String> = None;

            ui.label(RichText::new("llama.cpp 端口").size(12.0).color(theme::TEXT_SUB));
            let llama_resp = ui
                .add_sized(
                    [90.0, 26.0],
                    egui::TextEdit::singleline(&mut cx.state.local_model.llama_port),
                )
                .on_hover_text("llama-server 端口;DSH 里 llama-cpp-local 的 baseURL 用它");
            ui.label(RichText::new("sd.cpp 端口").size(12.0).color(theme::TEXT_SUB));
            let sd_resp = ui
                .add_sized(
                    [90.0, 26.0],
                    egui::TextEdit::singleline(&mut cx.state.local_model.sd_port),
                )
                .on_hover_text("sd-server 端口;DSH 里 sd-cpp-local 的 baseURL 用它");
            ui.label(RichText::new("DSH 端口").size(12.0).color(theme::TEXT_SUB));
            let dsh_resp = ui
                .add_sized(
                    [90.0, 26.0],
                    egui::DragValue::new(&mut cx.state.dsh.port).range(1..=65535),
                )
                .on_hover_text("dsh web 监听端口;改了要重启 DSH");
            ui.label(RichText::new("上下文").size(12.0).color(theme::TEXT_SUB));
            let ctx_resp = ui
                .add_sized(
                    [90.0, 26.0],
                    egui::TextEdit::singleline(&mut cx.state.local_model.llama_ctx),
                )
                .on_hover_text(
                    "llama-server 的 -c/--ctx-size;DSH 里 llama-cpp-local 的 \
                     contextWindow 必须是同一个数。改完要重启 llama-server 才生效。",
                );

            // ---- 可观测性三件套的端口(Grafana 页那三个服务)----
            //
            // 和上面几行同样是**真值**不是草稿,而且比它们更要紧:这三个端口
            // 同时出现在三个地方 —— Grafana 的 provision 文件(VM/Loki 的地址)、
            // Loki 自己的 loki-config.yaml、各自的启动参数。所以它们是
            // **配对项**:三个要一起改、一起重启(理由写在 Grafana 页那行说明里)。
            ui.label(
                RichText::new("VictoriaMetrics 端口")
                    .size(12.0)
                    .color(theme::TEXT_SUB),
            );
            let obs_vm_resp = ui
                .add_sized(
                    [90.0, 26.0],
                    egui::TextEdit::singleline(&mut cx.state.misc.obs_ports[OBS_METRICS]),
                )
                .on_hover_text("时序库的监听端口;Grafana 里那两条数据源之一指向它");
            ui.label(
                RichText::new("Loki 端口")
                    .size(12.0)
                    .color(theme::TEXT_SUB),
            );
            let obs_loki_resp = ui
                .add_sized(
                    [90.0, 26.0],
                    egui::TextEdit::singleline(&mut cx.state.misc.obs_ports[OBS_LOGS]),
                )
                .on_hover_text(
                    "日志库的 HTTP 端口;它的 gRPC 端口是 +3(写进 loki-config.yaml)",
                );
            ui.label(
                RichText::new("Grafana 端口")
                    .size(12.0)
                    .color(theme::TEXT_SUB),
            );
            let obs_graf_resp = ui
                .add_sized(
                    [90.0, 26.0],
                    egui::TextEdit::singleline(&mut cx.state.misc.obs_ports[OBS_DASHBOARD]),
                )
                .on_hover_text("看板端口;改了要重启 Grafana 才生效");

            // 改了**立刻存回**。端口和上下文是**真值**不是草稿:
            // 不存的话下次启动回到旧值,而症状是「我明明改过了」——
            // 与「保存了却从没生效」一样难查,只是方向相反。
            if llama_resp.changed() || sd_resp.changed() {
                match cx.state.local_model.save_ports(&mut cx.state.store) {
                    Ok(()) => {
                        port_msg = Some(format!(
                            "端口已记忆: llama.cpp={} sd.cpp={}",
                            cx.state.local_model.llama_port.trim(),
                            cx.state.local_model.sd_port.trim()
                        ));
                    }
                    Err(e) => port_msg = Some(format!("端口保存失败: {e}")),
                }
            }
            if ctx_resp.changed() {
                match cx.state.local_model.save_ctx(&mut cx.state.store) {
                    Ok(()) => {
                        port_msg = Some(format!(
                            "上下文长度已记忆:-c {}。记得把 DSH 的 contextWindow 也改成 {},\
                             否则超限时只会得到一个 400",
                            cx.state.local_model.llama_ctx.trim(),
                            cx.state.local_model.llama_ctx.trim()
                        ));
                    }
                    Err(e) => port_msg = Some(format!("上下文长度保存失败: {e}")),
                }
            }
            if dsh_resp.changed() {
                cx.state.settings.dsh_port = cx.state.dsh.port;
                cx.state.save_settings();
            }
            // 三件套的端口**三个一起存**:它们是配对项(见上面那段说明),
            // 只存被改的那一个,下次启动时另外两个还是旧值,症状是数据源
            // 指向没人监听的端口 —— 从界面上看不出这跟"没存"有关。
            if obs_vm_resp.changed() || obs_loki_resp.changed() || obs_graf_resp.changed() {
                match save_obs_ports(cx.state) {
                    Ok(m) => port_msg = Some(m),
                    Err(e) => port_msg = Some(format!("可观测性端口保存失败: {e}")),
                }
            }

            // ---- 服务控制:**启动和停止挨着** ----
            //
            // 原来「启动服务」散在 llama.cpp 页、「停止」在这个页 ——
            // 同一个服务的两个动作分在两个地方,这是「不唯一」的直接来源。
            // 而且同一时刻只允许一个本地服务在跑(`running_name`),
            // 所以合成**一组**才是它的真实形态。
            ui.add_space(10.0);
            ui.horizontal(|ui| {
                ui.label(RichText::new("服务").size(12.0).color(theme::TEXT_SUB));
                let running = cx.state.local_model.running_name();
                match running {
                    Some(n) => {
                        ui::status_pill(ui, true, &format!("{n} 运行中"));
                    }
                    None => {
                        ui::status_pill(ui, false, "没有本地服务在跑");
                    }
                }
            });
            ui.horizontal(|ui| {
                // 同一时刻只跑一个:已经有别的在跑时,启动按钮直接禁用,
                // 而不是让它点了之后报一个 bind 失败 —— 那看不出"前面那个还活着"。
                let busy = cx.state.local_model.running_name().is_some();
                if ui
                    .add_enabled(!busy, egui::Button::new("启动 llama.cpp"))
                    .clicked()
                {
                    cx.state.local_model.start_llama(&cx.state.paths, &cx.state.log);
                }
                if ui
                    .add_enabled(!busy, egui::Button::new("启动 sd.cpp"))
                    .clicked()
                {
                    cx.state.local_model.start_sd(&cx.state.paths, &cx.state.log);
                }
                if ui
                    .add_enabled(busy, egui::Button::new("停止全部"))
                    .clicked()
                {
                    cx.state.local_model.stop_all();
                    cx.state.log.write("gui", "本地服务已全部停止");
                }
            });

            // ---- 按端口强制终止 ----
            //
            // **和上面那个「停止」不是一回事,所以两个都留:**
            // 本程序的停止只能停它**自己拉起的子进程**;如果 llama-server 是
            // 别处启动的(手动跑、上次没退干净、另一个实例),程序显示「未运行」
            // 但端口实际被占。这里**按端口**找进程并强杀,不关心是谁拉起的。
            //
            // 两个按钮组看起来像重复,其实处理的是**两种不同情况** ——
            // 上面那个是"我起的,我停",这个是"不是我起的,也得能停"。
            ui.add_space(10.0);
            ui.label(
                RichText::new("按端口强制终止(停掉不是本程序拉起的那个)")
                    .size(12.0)
                    .color(theme::TEXT_SUB),
            );
            ui.horizontal(|ui| {
                // netstat + taskkill 约 1~2 秒同步等待。这是**手动动作**,
                // 短暂卡顿可以接受;放后台反而要处理「点两次」的并发。
                // 三件套的端口用 lib.rs 那个换算函数:框里万一留着半截数字,
                // 这里拿去 kill 的必须是**服务实际在听的那个端口**,不是那串文字。
                let obs_ports = crate::obs_ports_u16(&cx.state.misc.obs_ports);
                let ports = [
                    ("llama.cpp", cx.state.local_model.llama_port.clone()),
                    ("sd.cpp", cx.state.local_model.sd_port.clone()),
                    ("DSH", cx.state.dsh.port.to_string()),
                    // 三件套也列在这里:本程序自己的「停止」在 Grafana 页,
                    // 而这一行管的是**不是本程序拉起的**那些(手动跑的、
                    // 上次没退干净的)—— 两种情况的处理方式不同,所以两组都在。
                    ("VictoriaMetrics", obs_ports[OBS_METRICS].to_string()),
                    ("Loki", obs_ports[OBS_LOGS].to_string()),
                    ("Grafana", obs_ports[OBS_DASHBOARD].to_string()),
                ];
                for (label, port_s) in ports {
                    if !ui.button(format!("■ {label}")).clicked() {
                        continue;
                    }
                    let msg = match port_s.trim().parse::<u16>() {
                        Ok(p) => nfarms_core::tools::kill_by_port(p).unwrap_or_else(|e| e),
                        Err(_) => format!("{label} 的端口「{port_s}」不是合法端口号,没执行"),
                    };
                    cx.state.log.write("gui", &msg);
                    cx.state.misc.port_message = Some(msg);
                }
            });
            if let Some(m) = port_msg {
                cx.state.log.write("gui", &m);
                cx.state.misc.port_message = Some(m);
            }
            if let Some(m) = &cx.state.misc.port_message {
                ui.label(RichText::new(m).size(11.0).color(theme::TEXT_MAIN));
            }
        ui.add_space(6.0);
        ui.label(
            RichText::new(
                "网关协议(原版定下来的):`POST /print` 带 JSON,\
                 `GET /health` 返回存活。想知道接到哪台打印机,看网关自己的日志。",
            )
            .size(11.0)
            .color(theme::TEXT_SUB),
        );
        if let Some(m) = &cx.state.misc.message {
            ui.add_space(6.0);
            ui::ok_hint(ui, m);
        }
    });

    if detect {
        let p = cx
            .state
            .paths
            .lib("nfos-printer")
            .join("nfos-printer.exe");
        cx.state.misc.printer_exe = p.is_file().then_some(p);
        cx.state.misc.message = Some("已重新探测".to_string());
    }
    if start
        && let Some(exe) = cx.state.misc.printer_exe.clone()
    {
        let port = cx.state.misc.printer_port;
        let mut cmd = std::process::Command::new(&exe);
        cmd.arg("--port")
            .arg(port.to_string())
            .stdin(std::process::Stdio::null())
            .stdout(std::process::Stdio::null())
            .stderr(std::process::Stdio::null());
        #[cfg(windows)]
        {
            use std::os::windows::process::CommandExt;
            cmd.creation_flags(0x0800_0000);
        }
        match cmd.spawn() {
            Ok(c) => {
                cx.state.misc.printer_child = Some(c);
                cx.state.misc.message = Some(format!("已启动,监听 :{port}"));
                cx.state.log.write("printer", &format!("启动网关 :{port}"));
            }
            Err(e) => cx.state.misc.message = Some(format!("启动失败: {e}")),
        }
    }
    if stop {
        if let Some(mut c) = cx.state.misc.printer_child.take() {
            let _ = c.kill();
            let _ = c.wait();
        }
        cx.state.misc.message = Some("已停止".to_string());
    }
    if open {
        let _ = nfarms_core::browser::open_external(&url);
    }
}

// ===========================================================================
// Slack off → Pet(桌宠)
// ===========================================================================

/// Model › Tokens:本地模型的 token 用量。
///
/// # 两个视角,不是一个
///
/// * **桌宠对话的累计** —— 这是本进程自己记的,所以**分得清是谁用的**;
///   代价是只覆盖桌宠那一处,DSH 那边的消耗看不到。
/// * **llama-server 的 /metrics** —— 服务端进程的累计,覆盖**所有**调用方
///   (包括 DSH),但分不清是谁用的。
///
/// 两个都留着,因为它们回答的是不同问题。想凑成一个"总数"反而两头不讨好。
///
/// # 指标名不做解析
///
/// 我没核实过这个版本 llama.cpp 的指标命名。猜一个 `llamacpp:tokens_*` 出来,
/// 在改名后会显示成 0 —— 而那看起来像「没用过 token」,是个会骗人的错。
/// 所以把**含 token 的原始行**照显,名字对不对一眼就能看见。
pub fn tokens(cx: &mut PageCtx<'_>) {
    use nfarms_core::chat;

    if cx.state.misc.tok_metrics_job.poll()
        && let Some(r) = cx.state.misc.tok_metrics_job.result().cloned()
    {
        match r {
            Ok(v) => {
                cx.state.misc.tok_metrics = v;
                cx.state.misc.tok_metrics_err = None;
            }
            Err(e) => cx.state.misc.tok_metrics_err = Some(e),
        }
    }

    // 进页面自动拉一次。**失败后不再自动重试** —— 服务没起时每帧重试
    // 会把界面拖住,而用户手动点「重试」就行。
    if cx.state.misc.tok_metrics.is_empty()
        && cx.state.misc.tok_metrics_err.is_none()
        && !cx.state.misc.tok_metrics_job.is_running()
    {
        #[cfg(windows)]
        let port = cx.state.local_model.llama_port.trim().to_string();
        #[cfg(not(windows))]
        let port = "9094".to_string();
        cx.state.misc.tok_metrics_job.start(cx.egui, move || {
            chat::fetch_metrics(&format!("http://127.0.0.1:{port}/v1"))
        });
    }

    let m = &cx.state.misc;
    let total = m.tok_prompt + m.tok_completion;

    // ---- 桌宠对话的累计 ----
    ui::card(cx.ui, |ui| {
        ui::section(ui, "桌宠对话的累计", "本进程记的,分得清是谁用的");
        ui.add_space(6.0);
        ui::kv(ui, "请求次数", &m.tok_calls.to_string());
        ui::kv(ui, "输入 token(累计)", &m.tok_prompt.to_string());
        ui::kv(ui, "输出 token(累计)", &m.tok_completion.to_string());
        ui::kv(ui, "合计", &total.to_string());
        // `checked_div` 而不是 `if calls > 0 { total / calls }`:
        // 除零的判断和除法离得越近越好,分开写很容易在改动时漏掉一半。
        ui::kv(
            ui,
            "平均每次",
            &total
                .checked_div(m.tok_calls)
                .map_or_else(|| "—".to_string(), |v| v.to_string()),
        );
        ui.add_space(4.0);
        ui.label(
            RichText::new(
                ui::md(
                    "**只统计桌宠对话框**。DSH 用的是同一个 llama-server,\
但它是另一个进程,这里看不到它的消耗 —— 下面那张卡片覆盖它。",
                ),
            )
            .size(11.5)
            .color(theme::TEXT_SUB),
        );
    });

    cx.ui.add_space(12.0);

    // ---- llama-server 的原始指标 ----
    // 「停掉端口上那个并重启」标记。**不能在卡片闭包里直接起进程** ——
    // 那里正借着 `cx.state`,而启动要 `&mut cx.state.local_model`。
    let mut restart_srv = false;
    ui::card(cx.ui, |ui| {
        ui::section(
            ui,
            "llama-server 指标",
            "覆盖所有调用方(含 DSH),但分不清是谁",
        );
        ui.add_space(6.0);
        // **把实际查的端口写出来。** 这一页用的是 Setting › Proxy and Port
        // 里那个端口(和对话、和启动服务用的是**同一个真值**)。写出来是为了
        // 让"端口同步"这件事可验证 —— 看到 501 就知道端口对、是接口没开;
        // 看到"连不上"才知道端口不对。两种情况长得像,处理方式完全不同。
        #[cfg(windows)]
        let shown_port = cx.state.local_model.llama_port.trim().to_string();
        #[cfg(not(windows))]
        let shown_port = "9094".to_string();
        ui::kv(ui, "查询端口", &shown_port);
        ui.add_space(4.0);
        let running = cx.state.misc.tok_metrics_job.is_running();
        ui.horizontal(|ui| {
            if ui
                .add_enabled(!running, egui::Button::new("重新读取").min_size(egui::vec2(84.0, 26.0)))
                .clicked()
            {
                cx.state.misc.tok_metrics.clear();
                cx.state.misc.tok_metrics_err = None;
            }
            if running {
                ui.spinner();
            }
        });
        ui.add_space(6.0);
        if let Some(e) = &cx.state.misc.tok_metrics_err {
            ui::err_hint(ui, e);
            // 501 = 服务在跑,但没开指标接口。**这是唯一一种一键能修好的情况**:
            // 停掉端口上那个,再用本程序启动(本程序的参数带 --metrics)。
            //
            // 其他错误不给这个按钮 —— 「连不上」该去启动服务,
            // 而那个按钮在第一张卡片外面已经有了。
            if e.contains("501") {
                ui.add_space(4.0);
                if ui
                    .button("停掉占用端口的进程并用本程序重启")
                    .clicked()
                {
                    restart_srv = true;
                }
                ui.label(
                    RichText::new(ui::md(
                        "会先终止占用该端口的进程(**不管是不是本程序拉起的**),\
再用本程序启动 —— 本程序的启动参数里带 --metrics。",
                    ))
                    .size(11.0)
                    .color(theme::TEXT_SUB),
                );
            }
        }
        else if cx.state.misc.tok_metrics.is_empty() {
            ui.label(
                RichText::new("还没有数据。服务没在跑时读不到指标 —— 去 Model › llama.cpp 启动服务。")
                    .size(12.0)
                    .color(theme::TEXT_SUB),
            );
        }
        else {
            // 原样照显:名字对不对,用户一眼就能看见
            for l in &cx.state.misc.tok_metrics {
                ui.label(RichText::new(l.as_str()).size(11.5).color(theme::TEXT_MAIN));
            }
        }
    });

    if restart_srv {
        #[cfg(windows)]
        let port = cx.state.local_model.llama_port.trim().to_string();
        #[cfg(not(windows))]
        let port = "9094".to_string();
        let msg = match port.parse::<u16>() {
            Ok(p) => {
                // 阻塞 1~2 秒(netstat + taskkill)。这是**手动动作**,
                // 短暂卡顿可以接受;放后台反而要处理点两次的并发。
                let r = nfarms_core::tools::kill_by_port(p);
                std::thread::sleep(std::time::Duration::from_millis(900));
                cx.state.local_model.start_llama(&cx.state.paths, &cx.state.log);
                match r {
                    Ok(m) => format!("{m};已用本程序重启服务(带 --metrics)"),
                    Err(e) => format!("{e};仍然尝试用本程序启动"),
                }
            }
            Err(_) => format!("端口「{port}」不是合法数字,没执行"),
        };
        cx.state.log.write("today", &msg);
        // 重新读一次指标
        cx.state.misc.tok_metrics.clear();
        cx.state.misc.tok_metrics_err = None;
    }
}
/// Viewer › Today's Info:今天有什么。
///
/// # 自动刷新
///
/// 进页面拉一次,之后**每 10 分钟**自动再拉。计时器记在 `today_at` 上,
/// 而它在**成功和失败时都会更新** —— 只在成功时更新的写法会让失败变成
/// 「每帧重试」,把网络和界面一起拖死(这类 bug 很难看出来,因为界面只是在转圈)。
///
/// # 天气为什么直接画在这一页上
///
/// 早先天气有自己的一页,这里只放一张通往那一页的卡片(「两处解析就会有两处
/// 不一致」)。菜单改版把那两页合成了一页(见 `pages.rs`),于是这里直接调
/// [`weather`] —— 解析仍然只有 `nfarms-core::weather` 一份,界面也只有一份。
pub fn today_info(cx: &mut PageCtx<'_>) {
    use nfarms_core::today;
    use std::time::Duration;

    /// 自动刷新的间隔。资讯这东西十分钟足够新了 —— 再密只是白耗流量。
    const REFRESH: Duration = Duration::from_secs(600);

    // ---- 抓取与刷新 ----
    if cx.state.misc.today_job.poll()
        && let Some(r) = cx.state.misc.today_job.result().cloned()
    {
        // **无论成败都记时间**:失败后也要退避,否则会每帧重试
        cx.state.misc.today_at = Some(std::time::Instant::now());
        match r {
            Ok(v) => {
                cx.state.log.write("today", &format!("拉到 {} 条资讯", v.len()));
                cx.state.misc.today_items = v;
                cx.state.misc.today_err = None;
            }
            Err(e) => {
                cx.state.log.write("today", &format!("抓取失败: {e}"));
                cx.state.misc.today_err = Some(e);
            }
        }
    }
    let due = cx
        .state
        .misc
        .today_at
        .is_none_or(|t| t.elapsed() >= REFRESH);
    if due && !cx.state.misc.today_job.is_running() {
        cx.state.misc.today_job.start(cx.egui, today::fetch_all);
    }
    // 让上面那个 10 分钟的判断真的会被执行到 —— 不请求重绘的话界面闲着不动,
    // 计时器永远不会到点。
    cx.egui.request_repaint_after(Duration::from_secs(30));

    let running = cx.state.misc.today_job.is_running();

    // ---- 页头 ----
    cx.ui.horizontal(|ui| {
        ui.label(
            RichText::new(match &cx.state.misc.today_at {
                Some(t) => format!(
                    "更新于 {} 秒前",
                    t.elapsed().as_secs()
                ),
                None => "正在取第一次…".to_string(),
            })
            .size(12.0)
            .color(theme::TEXT_SUB),
        );
        if ui
            .add_enabled(!running, egui::Button::new("刷新").min_size(egui::vec2(64.0, 26.0)))
            .clicked()
        {
            cx.state.misc.today_at = None;
        }
        if running {
            ui.spinner();
        }
    });

    if let Some(e) = &cx.state.misc.today_err {
        cx.ui.add_space(6.0);
        ui::err_hint(cx.ui, &format!("抓取失败:{e}"));
    }
    cx.ui.add_space(10.0);

    // ---- 资讯 ----
    // 点链接**不能当场起进程**:那要 `&mut cx`,而循环里正借着 `cx.state`。
    // 记下来,画完再开(和书签页一个套路)。
    let mut open: Option<String> = None;
    ui::card(cx.ui, |ui| {
        ui::section(
            ui,
            &format!("资讯({} 条)", cx.state.misc.today_items.len()),
            "少数派 RSS · 百度热搜",
        );
        ui.add_space(6.0);
        if cx.state.misc.today_items.is_empty() && !running {
            ui.label(
                RichText::new("还没有内容。点上面的「刷新」再试一次。")
                    .size(12.0)
                    .color(theme::TEXT_SUB),
            );
            return;
        }
        let mut last_src = "";
        for it in &cx.state.misc.today_items {
            // 来源变了就插一个小标题 —— 不然十几条混在一起看不出哪条来自哪
            if it.source != last_src {
                last_src = it.source;
                ui.add_space(4.0);
                ui.label(RichText::new(it.source).size(11.5).color(theme::ACCENT));
            }
            ui.horizontal_wrapped(|ui| {
                ui.spacing_mut().item_spacing.x = 6.0;
                ui.label(RichText::new("·").size(12.0).color(theme::TEXT_SUB));
                if it.link.is_empty() {
                    ui.label(RichText::new(it.title.as_str()).size(12.5).color(theme::TEXT_MAIN));
                }
                else if ui
                    .link(RichText::new(it.title.as_str()).size(12.5))
                    .on_hover_text(it.link.as_str())
                    .clicked()
                {
                    open = Some(it.link.clone());
                }
                if !it.time.is_empty() {
                    ui.label(
                        RichText::new(short_time(&it.time))
                            .size(10.5)
                            .color(theme::TEXT_SUB),
                    );
                }
            });
        }
    });

    if let Some(u) = open {
        // `start` 后面那个空 `""` 是**标题占位** —— 少了它,带空格的 URL
        // 会被当成标题,浏览器根本不会打开。书签页那边也是这个写法。
        #[cfg(windows)]
        {
            let _ = std::process::Command::new("cmd")
                .args(["/c", "start", "", &u])
                .spawn();
        }
        cx.state.log.write("today", &format!("打开 {u}"));
    }

    cx.ui.add_space(12.0);

    // ---- 油价:如实说明没接入 ----
    ui::card(cx.ui, |ui| {
        ui::section(ui, "油价", "未接入");
        ui.add_space(6.0);
        ui.label(
            RichText::new(ui::md(nfarms_core::today::FUEL_NOTE))
                .size(12.0)
                .color(theme::TEXT_MAIN),
        );
    });

    cx.ui.add_space(12.0);

    // ---- 天气:直接画在首页上 ----
    //
    // 这里原来是一张「去天气页」的跳转卡片,理由是「两处解析会有两处不一致」。
    // 菜单改版把天气页并进了首页(见 pages.rs 里那条说明),那个理由就反过来了:
    // **只有这一处**了,所以直接把 [`weather`] 那一段调进来 —— 解析仍然只有
    // 一份(nfarms-core::weather),界面也只有一份。
    weather(cx);
}

/// 把 RSS 的 RFC822 时间压短一点:`Tue, 13 Oct 2026 17:00:00 +0800` → `13 Oct 2026 17:00`。
///
/// 不引时间库:这里**只是显示**,不需要真的解析成时间戳,
/// 而为了显示一个字串引一个 chrono 不划算。
fn short_time(s: &str) -> String {
    let p: Vec<&str> = s.split_whitespace().collect();
    if p.len() >= 5 {
        let hhmm = &p[4][..p[4].len().min(5)];
        return format!("{} {} {} {}", p[1], p[2], p[3], hhmm);
    }
    s.to_string()
}
pub fn pet(cx: &mut PageCtx<'_>) {
    let mut toggle = false;
    let mut reset = false;

    ui::card(cx.ui, |ui| {
        ui::section(ui, "Pet 桌面宠物", "一只浮在桌面上的小生物(无边框置顶小窗)");
        ui.add_space(6.0);
        ui.label(
            RichText::new(ui::plain(
                "实现走 **egui 多视口**:一个 `decorations = false` + \
                 `window_level = AlwaysOnTop` + 不上任务栏的小窗,在它身上按住左键 = \
                 让系统开始拖窗口。\n\
                 **不做真透明**,而且这是试过之后的结论不是偷懒:`eframe::App::clear_color` \
                 是**整帧一次**的、不按视口分 —— 让它清零,桌宠那个窗口透明了,\
                 **主窗口也一起透明,整窗白屏**。改用 `set_visuals` 标记视口也不生效。\
                 要真透明得让那个窗口有自己的 GL 上下文和 clear color,立即视口做不到。\n\
                 **不做全局键鼠钩子**:那要 `SetWindowsHookEx` 注入到所有进程,\
                 杀毒软件一定会报,而收益只是「它会跟着你的鼠标看」—— 不值得。",
            ))
            .size(11.5)
            .color(theme::TEXT_SUB),
        );
        ui.add_space(10.0);
        ui.horizontal(|ui| {
            if ui
                .checkbox(
                    &mut cx.state.misc.pet_visible,
                    RichText::new("显示桌宠").size(12.5),
                )
                .changed()
            {
                toggle = true;
            }
            if ui.button("重置状态").clicked() {
                reset = true;
            }
        });
        ui.add_space(8.0);
        if cx.state.misc.pet_visible {
            ui.label(
                RichText::new("它已经在桌面上了 —— 在那个小窗口上按住左键可以拖着走。")
                    .size(12.0)
                    .color(theme::ACCENT_DARK),
            );
        } else {
            ui.label(
                RichText::new("现在没有显示。勾上「显示桌宠」它就会出现。")
                    .size(12.0)
                    .color(theme::TEXT_SUB),
            );
        }
        if let Some(m) = &cx.state.misc.message {
            ui.add_space(4.0);
            ui::ok_hint(ui, m);
        }
    });

    if toggle {
        let shown = cx.state.misc.pet_visible;
        // 持久化:关掉程序再开,桌宠该还在(或者该还是不在)
        cx.state
            .store
            .set_str("pet_visible", if shown { "1" } else { "0" });
        let _ = cx.state.store.save();
        cx.state
            .log
            .write("pet", if shown { "桌宠出现" } else { "桌宠收起" });
    }
    if reset {
        cx.state.misc.pet_phase = 0.0;
        cx.state.misc.message = Some("状态已重置".to_string());
    }
}

// ===========================================================================
// Setting → 日志路径 / 缓存路径 / 存储路径
// ===========================================================================

/// 三个「路径」页共用的一块:显示路径 + 是否可写 + 占了多少 + 打开按钮。
fn path_card(cx: &mut PageCtx<'_>, title: &str, subtitle: &str, dir: &std::path::Path, note: &str) {
    let exists = dir.is_dir();
    let writable = exists && probe_writable(dir);
    let size = if exists { dir_size(dir) } else { 0 };

    ui::card(cx.ui, |ui| {
        ui::section(ui, title, subtitle);
        ui.add_space(8.0);
        ui::kv(ui, "路径", &cx.state.paths.show(dir));
        ui::kv(
            ui,
            "状态",
            match (exists, writable) {
                (false, _) => "目录不存在(首次写入时自动创建)",
                (true, true) => "存在且可写",
                (true, false) => "存在但**不可写** —— 程序会自动降级",
            },
        );
        ui::kv(ui, "占用", &ui::human_bytes(size));
        ui.add_space(8.0);
        ui.horizontal(|ui| {
            if ui
                .add_enabled(exists, egui::Button::new("打开文件夹"))
                .clicked()
            {
                // 用系统资源管理器打开 —— 不自己实现文件管理器
                #[cfg(windows)]
                {
                    let _ = std::process::Command::new("explorer.exe")
                        .arg(dir)
                        .spawn();
                }
            }
            if ui
                .add_enabled(!exists, egui::Button::new("创建目录"))
                .clicked()
                && let Err(e) = std::fs::create_dir_all(dir)
            {
                cx.state
                    .log
                    .write("paths", &format!("创建 {} 失败: {e}", dir.display()));
            }
        });
        ui.add_space(6.0);
        ui.label(RichText::new(ui::plain(note)).size(11.5).color(theme::TEXT_SUB));
    });
}

/// 真的写一个探针文件再删掉 —— 只看 ACL 位是不够的(这台机器上就遇到过
/// 「权限位说可写、实际被拒」的情况)。
fn probe_writable(dir: &std::path::Path) -> bool {
    let probe = dir.join(format!(".write-probe-{:06x}", rand_hex()));
    match std::fs::write(&probe, b"probe") {
        Ok(()) => {
            let _ = std::fs::remove_file(&probe);
            true
        }
        Err(_) => false,
    }
}

fn rand_hex() -> u32 {
    use std::time::{SystemTime, UNIX_EPOCH};

    SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .map(|d| d.subsec_nanos())
        .unwrap_or(0)
}

/// 递归算目录大小(深度上限 6,免得误指到 C:\ 时把界面卡死)。
fn dir_size(dir: &std::path::Path) -> u64 {
    fn walk(dir: &std::path::Path, depth: usize) -> u64 {
        if depth == 0 {
            return 0;
        }
        let Ok(entries) = std::fs::read_dir(dir) else {
            return 0;
        };
        let mut total = 0;
        for e in entries.flatten() {
            let p = e.path();
            match e.metadata() {
                Ok(m) if m.is_file() => total += m.len(),
                Ok(m) if m.is_dir() => total += walk(&p, depth - 1),
                _ => {}
            }
        }
        total
    }
    walk(dir, 6)
}

pub fn log_path(cx: &mut PageCtx<'_>) {
    let dir = cx.state.log.dir().to_path_buf();
    path_card(
        cx,
        "日志路径",
        "程序根写不进去时自动落到 %TEMP%",
        &dir,
        "落盘格式 `[时间] [来源] 消息\\r\\n` 与 C# 版逐字节一致。\
         日志目录不可写必须**自动降级**到临时目录,而不是启动就崩 —— \
         这一点在 Paths/Log 两个模块里都是显式处理的。",
    );
    cx.ui.add_space(12.0);
    ui::kv(cx.ui, "主日志", &cx.state.paths.show(&cx.state.log.app_path()));
    ui::kv(
        cx.ui,
        "代理日志",
        &cx.state.paths.show(&cx.state.log.printer_path()),
    );
    if cx.state.log.using_temp_dir() {
        cx.ui.add_space(8.0);
        ui::card(cx.ui, |ui| {
            ui::err_hint(
                ui,
                "当前用的是临时目录 —— 说明程序根不可写。装到 Program Files 下会出现这种情况。",
            );
        });
    }
    cx.ui.add_space(16.0);
    log_viewer_card(cx);
}

/// 日志查看器:分页看日志文件。
///
/// 为什么必须分页:程序日志跑一天就几万行,一次性塞进 `ScrollArea` 会让
/// egui 每帧都去布局几万个 galley —— 又卡又费内存。分页之后每帧只画一页。
fn log_viewer_card(cx: &mut PageCtx<'_>) {
    let dir = cx.state.log.dir().to_path_buf();
    cx.state.log_view.ensure_file(&dir);
    // 变了才重读(按 长度 + 修改时间 判断)
    let reloaded = cx.state.log_view.refresh();
    if reloaded && cx.state.log_view.follow {
        cx.egui
            .request_repaint_after(std::time::Duration::from_millis(500));
    }

    let mut pick_file: Option<std::path::PathBuf> = None;
    let mut want_refresh = false;
    let mut goto: Option<usize> = None;
    let mut set_per: Option<usize> = None;

    ui::card(cx.ui, |ui| {
        ui::section(ui, "日志查看", "分页翻,不一次性画几万行");
        ui.add_space(6.0);

        let files = list_logs(&dir);
        let cur = cx.state.log_view.file.clone();
        ui.horizontal(|ui| {
            ui.label(RichText::new("文件").size(12.0).color(theme::TEXT_SUB));
            egui::ComboBox::from_id_salt("log-file")
                .width(340.0)
                .selected_text(
                    cur.as_ref()
                        .and_then(|p| p.file_name())
                        .map(|n| n.to_string_lossy().to_string())
                        .unwrap_or_else(|| "(没有日志文件)".to_string()),
                )
                .show_ui(ui, |ui| {
                    for f in &files {
                        let name = f
                            .file_name()
                            .map(|n| n.to_string_lossy().to_string())
                            .unwrap_or_default();
                        let sel = cur.as_ref() == Some(f);
                        if ui.selectable_label(sel, name).clicked() {
                            pick_file = Some(f.clone());
                        }
                    }
                });
            if ui.button("刷新").clicked() {
                want_refresh = true;
            }
            if ui
                .checkbox(
                    &mut cx.state.log_view.follow,
                    RichText::new("跟随最新").size(12.0),
                )
                .on_hover_text("有新内容时自动跳到最后一页")
                .changed()
                && cx.state.log_view.follow
            {
                goto = Some(cx.state.log_view.page_count().saturating_sub(1));
            }
            if ui.button("打开目录").clicked() {
                nfarms_core::ffmpeg::open_dir(&dir);
            }
        });

        ui.add_space(6.0);
        let total_lines = cx.state.log_view.line_count();
        let pages = cx.state.log_view.page_count();
        let page = cx.state.log_view.page;

        ui.horizontal(|ui| {
            ui.label(RichText::new("每页").size(12.0).color(theme::TEXT_SUB));
            for n in crate::PER_PAGE_CHOICES {
                if ui
                    .selectable_label(cx.state.log_view.per_page == n, format!("{n}"))
                    .clicked()
                {
                    set_per = Some(n);
                }
            }
            ui.separator();
            if ui
                .add_enabled(page > 0, egui::Button::new("首页"))
                .clicked()
            {
                goto = Some(0);
            }
            if ui
                .add_enabled(page > 0, egui::Button::new("上一页"))
                .clicked()
            {
                goto = Some(page - 1);
            }
            ui.label(
                RichText::new(format!("第 {} / {} 页", page + 1, pages))
                    .size(12.0)
                    .color(theme::ACCENT_DARK),
            );
            if ui
                .add_enabled(page + 1 < pages, egui::Button::new("下一页"))
                .clicked()
            {
                goto = Some(page + 1);
            }
            if ui
                .add_enabled(page + 1 < pages, egui::Button::new("末页"))
                .clicked()
            {
                goto = Some(pages.saturating_sub(1));
            }
            ui.separator();
            ui.label(RichText::new("跳到").size(12.0).color(theme::TEXT_SUB));
            ui.add_sized(
                [52.0, 24.0],
                egui::TextEdit::singleline(&mut cx.state.log_view.goto).hint_text("页"),
            );
            if ui.button("Go").clicked()
                && let Ok(p) = cx.state.log_view.goto.trim().parse::<usize>()
            {
                // 用户按 1 起数,内部从 0 起
                goto = Some(p.saturating_sub(1));
            }
            ui.separator();
            ui.label(
                RichText::new(format!("共 {total_lines} 行"))
                    .size(11.5)
                    .color(theme::TEXT_SUB),
            );
        });

        if let Some(e) = &cx.state.log_view.error {
            ui.add_space(6.0);
            ui::err_hint(ui, e);
        } else if total_lines == 0 {
            ui.add_space(6.0);
            ui.label(
                RichText::new("(空文件,或者还没写过日志)")
                    .size(12.0)
                    .color(theme::TEXT_SUB),
            );
        }
    });

    if let Some(f) = pick_file {
        cx.state.log_view.file = Some(f);
        cx.state.log_view.stamp = None; // 强制重读
        cx.state.log_view.page = 0;
        cx.state.log_view.goto.clear();
    }
    if want_refresh {
        cx.state.log_view.stamp = None;
    }
    if let Some(n) = set_per {
        cx.state.log_view.per_page = n;
        cx.state.log_view.page = 0;
    }
    if let Some(p) = goto {
        cx.state.log_view.goto_page(p);
    }

    // ---- 正文 ----
    let (start, end) = cx.state.log_view.page_range();
    // 先拷贝出要显示的那几行:下面 `ui::card` 会把 cx 借走
    let rows: Vec<(usize, String)> = cx
        .state
        .log_view
        .lines()
        .into_iter()
        .enumerate()
        .skip(start)
        .take(end.saturating_sub(start))
        .map(|(i, l)| (i + 1, l.to_string()))
        .collect();

    cx.ui.add_space(12.0);
    // 和阅读器同一个套路:夹到可视高度,不然 ScrollArea 会以为自己有几千像素高
    let max_h = (cx.egui.screen_rect().height() - 430.0).max(220.0);
    ui::card(cx.ui, |ui| {
        egui::ScrollArea::both()
            .auto_shrink([false, false])
            .max_height(max_h)
            .show(ui, |ui| {
                for (no, line) in &rows {
                    ui.horizontal(|ui| {
                        // 行号右对齐,方便和日志里的报错行号对
                        ui.add_sized(
                            [52.0, 16.0],
                            egui::Label::new(
                                RichText::new(format!("{no}"))
                                    .size(10.5)
                                    .monospace()
                                    .color(theme::TEXT_SUB),
                            ),
                        );
                        ui.label(
                            RichText::new(line)
                                .size(11.0)
                                .monospace()
                                .color(log_line_color(line)),
                        );
                    });
                }
            });
    });
}

/// 日志行按级别上色:出问题的那几行要能一眼挑出来。
fn log_line_color(line: &str) -> egui::Color32 {
    let l = line.to_ascii_lowercase();
    if l.contains("失败") || l.contains("error") || l.contains("错误") || l.contains("fail") {
        theme::DANGER
    } else if l.contains("warn") || l.contains("警告") {
        theme::TEXT_MAIN
    } else {
        theme::TEXT_SUB
    }
}

/// 日志目录里的 `.log` 文件。程序日志永远排第一(最常看的那个)。
fn list_logs(dir: &std::path::Path) -> Vec<std::path::PathBuf> {
    let mut v: Vec<std::path::PathBuf> = std::fs::read_dir(dir)
        .map(|it| {
            it.flatten()
                .map(|e| e.path())
                .filter(|p| {
                    p.extension()
                        .and_then(|x| x.to_str())
                        .map(|x| x.eq_ignore_ascii_case("log"))
                        .unwrap_or(false)
                })
                .collect()
        })
        .unwrap_or_default();
    v.sort_by(|a, b| {
        let an = a
            .file_name()
            .map(|x| x.to_string_lossy().to_string())
            .unwrap_or_default();
        let bn = b
            .file_name()
            .map(|x| x.to_string_lossy().to_string())
            .unwrap_or_default();
        let a_first = an == nfarms_core::log::APP_LOG;
        let b_first = bn == nfarms_core::log::APP_LOG;
        b_first.cmp(&a_first).then_with(|| an.cmp(&bn))
    });
    v
}

pub fn cache_path(cx: &mut PageCtx<'_>) {
    let dir = cx.state.paths.root().join("cache");
    path_card(
        cx,
        "缓存路径",
        "解析结果 / 缩略图 / 下载分片的落脚点",
        &dir,
        "缓存要有上限和清理入口(不然用户磁盘会被悄悄吃掉)。\
         默认放程序根下的 cache\\,可以整个指到别的盘 —— 但**改路径要等缓存实现出来**,\
         现在这一页只做现状展示。",
    );
    cx.ui.add_space(12.0);
    ui::card(cx.ui, |ui| {
        ui::section(ui, "当前谁在往这儿写", "还没实现 —— 这一页先如实说明。");
        ui.add_space(6.0);
        ui.label(
            RichText::new(
                "现在真正会用磁盘的是三处:libs\\model(模型权重,最大)、\
                 libs\\llama.cpp 与 libs\\stable-diffusion.cpp(运行库)、\
                 logs\\(日志)。它们的位置是跟程序根走的,不受这一页控制。",
            )
            .size(11.5)
            .color(theme::TEXT_SUB),
        );
    });
}

pub fn storage_path(cx: &mut PageCtx<'_>) {
    let root = cx.state.paths.root().to_path_buf();
    path_card(
        cx,
        "存储路径",
        "录制 / 转码产物 / 模型权重的存放位置",
        &root,
        "模型权重默认在 libs\\model,允许改到别的盘 —— 28 GB 放在系统盘上很快就满了。\
         **改盘这件事还没做**:现在模型目录是写死在程序根下的。",
    );
    cx.ui.add_space(12.0);

    // 把最大的几个目录列出来,让「哪儿吃了磁盘」一眼可见
    ui::card(cx.ui, |ui| {
        ui::section(ui, "磁盘占用", "程序根下各子目录的大小(深度 6 层)。");
        ui.add_space(8.0);
        let mut rows: Vec<(String, u64)> = std::fs::read_dir(&root)
            .map(|entries| {
                entries
                    .flatten()
                    .filter(|e| e.path().is_dir())
                    .map(|e| {
                        let name = e.file_name().to_string_lossy().to_string();
                        (name, dir_size(&e.path()))
                    })
                    .collect()
            })
            .unwrap_or_default();
        // 按大小**降序**:`Reverse` 比手写 `b.1.cmp(&a.1)` 更直白,
        // 也不会有人把它误读成升序
        rows.sort_by_key(|r| std::cmp::Reverse(r.1));
        for (name, size) in rows.iter().take(12) {
            ui.horizontal(|ui| {
                ui.add_sized(
                    [120.0, 18.0],
                    egui::Label::new(RichText::new(name).size(12.0).color(theme::TEXT_MAIN)),
                );
                ui.label(
                    RichText::new(ui::human_bytes(*size))
                        .size(12.0)
                        .color(theme::TEXT_SUB),
                );
            });
        }
    });
}

// ===========================================================================
// Zero Trust VPN → TOPO 2FA(拓扑验证器)
// ===========================================================================

/// 三态徽标的颜色。warn 的橙是照 C# `TopoValidator.StateColor` 抄的
/// (Color.FromArgb(198,138,22))。
fn topo_state_color(state: &str) -> Color32 {
    match state {
        "pass" => theme::ACCENT,
        "warn" => Color32::from_rgb(198, 138, 22),
        "fail" => theme::DANGER,
        _ => theme::TEXT_SUB,
    }
}

pub fn topo(cx: &mut PageCtx<'_>) {
    use nfarms_core::topo;

    let server = topo::server_edges();
    let summary = topo::summary_line(&cx.state.topo.edges, server.len());
    let running = cx.state.topo.running;

    // ---- 汇总 + 操作条 ----
    let mut start = false;
    // 首次进页面自动跑一轮(原版 C# 的 Topo 页在构造时就把表填好了)
    if !cx.state.topo_auto {
        cx.state.topo_auto = true;
        start = true;
    }
    let mut copy_result = false;
    let mut copy_cmds = false;
    ui::card(cx.ui, |ui| {
        ui.horizontal(|ui| {
            ui.label(RichText::new(&summary).size(14.0).color(theme::TEXT_MAIN));
            if running {
                ui.spinner();
                ui.label(RichText::new("探测中…").size(12.0).color(theme::TEXT_SUB));
            }
        });
        ui.add_space(4.0);
        ui.label(
            RichText::new(
                "26 条边 = 14 条本机实测 + 12 条需服务端探测。口径与网页版 topo.html 完全一致;\
                 内网端口从外网不可达是设计如此,一律标注「需服务器本机验证」,绝不判成故障。",
            )
            .size(11.5)
            .color(theme::TEXT_SUB),
        );
        ui.add_space(10.0);
        ui.horizontal(|ui| {
            if ui
                .add_enabled(
                    !running,
                    egui::Button::new(format!(
                        "重新验证({} 条边)",
                        cx.state.topo.edges.len()
                    ))
                    .min_size(egui::vec2(160.0, 30.0)),
                )
                .clicked()
            {
                start = true;
            }
            if ui
                .add_enabled(!running, egui::Button::new("复制结果").min_size(egui::vec2(90.0, 30.0)))
                .clicked()
            {
                copy_result = true;
            }
            if ui
                .add_enabled(
                    !running,
                    egui::Button::new("复制服务端命令").min_size(egui::vec2(130.0, 30.0)),
                )
                .clicked()
            {
                copy_cmds = true;
            }
            ui.label(RichText::new("单边超时").size(12.0).color(theme::TEXT_SUB));
            ui.add_sized(
                [70.0, 28.0],
                egui::DragValue::new(&mut cx.state.topo.timeout_ms)
                    .range(500..=30000)
                    .suffix(" ms"),
            );
            if let Some(t) = &cx.state.topo.last_run {
                ui::ok_hint(ui, &format!("上次 {t}"));
            }
        });
    });

    if start {
        cx.state.topo.start(cx.egui);
    }
    if copy_result {
        let text = topo_report(&cx.state.topo.edges, &server);
        cx.egui.copy_text(text);
        cx.state.log.write("topo", "已复制验证结果到剪贴板");
    }
    if copy_cmds {
        let mut text = String::new();
        for (title, cmd) in topo::SERVER_CMDS {
            text.push_str(&format!("# {title}\n{cmd}\n\n"));
        }
        cx.egui.copy_text(text);
        cx.state.log.write("topo", "已复制服务端命令到剪贴板");
    }

    cx.ui.add_space(12.0);

    // ---- 14 条本机实测边 ----
    ui::card(cx.ui, |ui| {
        ui::section(ui, "本机实测边", "每条的「期望 / 依据 / 口径」都按网页版口径给出。");
        ui.add_space(8.0);
        for e in &cx.state.topo.edges {
            let color = topo_state_color(e.state);
            ui.horizontal(|ui| {
                ui.add_sized(
                    [34.0, 18.0],
                    egui::Label::new(
                        RichText::new(&e.id).size(11.5).monospace().color(theme::TEXT_SUB),
                    ),
                );
                ui.add_sized(
                    [86.0, 18.0],
                    egui::Label::new(
                        RichText::new(topo::state_badge(e.state)).size(12.0).color(color),
                    ),
                );
                ui.label(RichText::new(&e.label).size(12.5).color(theme::TEXT_MAIN))
                    .on_hover_text(format!(
                        "期望: {}\n地址: {}\n口径: {}",
                        topo::want_text(e),
                        e.url,
                        if e.why.is_empty() { e.note.as_str() } else { e.why.as_str() }
                    ));
            });
            if e.judged {
                ui.horizontal(|ui| {
                    ui.add_space(120.0);
                    ui.label(
                        RichText::new(format!("依据: {}", e.evidence))
                            .size(11.0)
                            .color(theme::TEXT_SUB),
                    );
                });
            }
            ui.add_space(4.0);
        }
    });

    cx.ui.add_space(12.0);

    // ---- 需服务端探测的 12 条边 ----
    ui::card(cx.ui, |ui| {
        egui::CollapsingHeader::new(format!(
            "需服务端探测的边({} 条,不计入通过数)",
            server.len()
        ))
        .default_open(false)
        .show(ui, |ui| {
            for e in &server {
                ui.label(
                    RichText::new(format!("{} {} → {}", e.id, e.label, e.how))
                        .size(11.5)
                        .color(theme::TEXT_MAIN),
                );
                ui.add_space(4.0);
            }
        });
    });

    cx.ui.add_space(12.0);

    // ---- 内网端口清单 ----
    ui::card(cx.ui, |ui| {
        egui::CollapsingHeader::new(format!(
            "内网端口清单({} 个,公网不可达是设计如此)",
            topo::PORTS.len()
        ))
        .default_open(false)
        .show(ui, |ui| {
            for p in topo::PORTS {
                ui.label(RichText::new(p).size(11.0).color(theme::TEXT_SUB));
                ui.add_space(2.0);
            }
        });
    });

    cx.ui.add_space(12.0);

    // ---- 服务端深度探测命令 ----
    ui::card(cx.ui, |ui| {
        egui::CollapsingHeader::new(format!(
            "服务端深度探测命令({} 组)",
            topo::SERVER_CMDS.len()
        ))
        .default_open(false)
        .show(ui, |ui| {
            for (title, cmd) in topo::SERVER_CMDS {
                ui.label(RichText::new(title).size(12.0).color(theme::TEXT_MAIN));
                ui.label(
                    RichText::new(cmd)
                        .size(11.0)
                        .monospace()
                        .color(theme::TEXT_SUB),
                );
                ui.add_space(6.0);
            }
        });
    });

    cx.ui.add_space(12.0);
    ui::card(cx.ui, |ui| {
        ui.label(
            RichText::new(
                "Rust 版比 C# 原版少一项:TLS 叶子证书的签发者与到期日暂未提取(需要 X.509 解析)。\
                 其余(状态码、响应体业务字段、TCP 连接被拒 vs 超时)口径完全一致 —— \
                 --topo-test --offline 的输出已与 C# 原版逐字节相同。",
            )
            .size(11.5)
            .color(theme::TEXT_SUB),
        );
    });
}

/// 「复制结果」用的纯文本报告。
fn topo_report(edges: &[nfarms_core::topo::TopoEdge], server: &[nfarms_core::topo::TopoEdge]) -> String {
    use nfarms_core::topo;
    let mut s = String::new();
    s.push_str(&topo::summary_line(edges, server.len()));
    s.push_str("\r\n\r\n");
    for e in edges {
        s.push_str(&format!(
            "{} [{}] {}\r\n    期望: {}\r\n    依据: {}\r\n    口径: {}\r\n",
            e.id,
            topo::state_badge(e.state),
            e.label,
            topo::want_text(e),
            e.evidence,
            e.why
        ));
    }
    s
}

// ===========================================================================
// Setting → 插件(骨架的可视化)
// ===========================================================================

/// Libs 页顶部的「libs\ 磁盘清单」。
///
/// 放在装载表**前面**:先看见磁盘上有什么(28 GB 权重、444 MB 后端),
/// 再看插件注册表 —— 前者是「东西在不在」,后者是「页面挂没挂」,
/// 出问题时先查前者。
fn libs_panel(cx: &mut PageCtx<'_>) {
    let dirs = nfarms_core::libs::scan_dirs(&cx.state.paths);
    let total: u64 = dirs.iter().map(|d| d.bytes).sum();
    ui::card(cx.ui, |ui| {
        ui::section(
            ui,
            &format!(
                "libs\\ 目录({} 项 / {:.2} GB)",
                dirs.len(),
                total as f64 / 1_073_741_824.0
            ),
            "这些不进安装包,由 .\\libs\\scripts\\fetch-libs.ps1 按需下载",
        );
        ui.add_space(6.0);
        if dirs.is_empty() {
            ui.label(
                RichText::new("libs\\ 下没有子目录 —— 还没下过任何包")
                    .size(12.0)
                    .color(theme::TEXT_SUB),
            );
            return;
        }
        for d in &dirs {
            ui.horizontal(|ui| {
                ui.add_sized(
                    [200.0, 17.0],
                    egui::Label::new(RichText::new(&d.name).size(12.0).color(theme::TEXT_MAIN)),
                );
                // 空目录要标出来 —— 「建了目录但没下内容」和「没建」是两回事
                let empty = d.files == 0;
                ui.add_sized(
                    [90.0, 17.0],
                    egui::Label::new(
                        RichText::new(if empty { "空".to_string() } else { d.human() })
                            .size(11.5)
                            .color(if empty { theme::TEXT_SUB } else { theme::ACCENT_DARK }),
                    ),
                );
                ui.label(
                    RichText::new(format!("{}  ·  {} 个文件", d.note, d.files))
                        .size(11.0)
                        .color(theme::TEXT_SUB),
                );
            });
        }
        ui.add_space(6.0);
        ui.label(
            RichText::new("「空」= 只有目录没内容;「(未登记)」= 你手动放进来的。")
                .size(10.5)
                .color(theme::TEXT_SUB),
        );
    });
}
pub fn manager(cx: &mut PageCtx<'_>) {
    libs_panel(cx);
    cx.ui.add_space(12.0);
    let records = cx.kernel.records();
    let services = cx.kernel.context().visible_service_names();
    // 注册表取一次,后面「几张卡片」和装载表都用它 —— 两个数字必须来自同一张表,
    // 否则会出现「标题说 52 张、底下列了 40 行」这种自相矛盾。
    let registry = cx
        .kernel
        .context()
        .get::<super::PageRegistry>(SERVICE_PAGES);
    let card_count = registry.as_ref().map(|r| r.len()).unwrap_or(0);

    let platform = if cfg!(target_os = "android") {
        "安卓(骨架:只装了一小撮插件)"
    } else {
        "Windows 桌面(整棵功能树)"
    };

    ui::card(cx.ui, |ui| {
        ui::section(ui, "插件内核", "cordis 风格:服务容器 + 事件总线 + 生命周期。");
        ui.add_space(6.0);
        ui::kv(ui, "平台", platform);
        ui::kv(ui, "内核版本", env!("CARGO_PKG_VERSION"));
        ui::kv(
            ui,
            "插件",
            &format!("{} 个,其中 {} 个已装载", records.len(), cx.kernel.active_count()),
        );
        ui::kv(ui, "页面", &format!("{card_count} 张卡片"));
        ui::kv(ui, "服务", &services.join(", "));
    });

    cx.ui.add_space(12.0);

    // ---- 装载表 ----
    //
    // 按**三级树**画(和侧栏同一棵树):一级目录 → 二级目录 → 页面。
    // 早先这里是「按注册顺序平铺、遇到换组就插一行组名」,三级化之后那样画
    // 就分不清「这一页挂在哪一级目录下」了 —— 而这一页正是用来回答那个问题的。
    let tree = registry
        .as_ref()
        .map(|r| r.grouped_tree())
        .unwrap_or_default();
    ui::card(cx.ui, |ui| {
        ui::section(ui, "装载表", "按功能树三级列出;侧栏就是照着这张注册表画的。");
        ui.add_space(8.0);

        for node in &tree {
            ui.add_space(6.0);
            ui.label(
                RichText::new(node.group.nav())
                    .size(12.5)
                    .color(theme::ACCENT_DARK),
            );
            for item in &node.items {
                let kids: &[super::PageSpec] = match item {
                    super::NavItem::Leaf(s) => std::slice::from_ref(s),
                    super::NavItem::Subgroup(sub, kids) => {
                        ui.label(
                            RichText::new(format!("    {}", sub.nav()))
                                .size(11.5)
                                .color(theme::TEXT_SUB),
                        );
                        kids
                    }
                };
                for spec in kids {
                    let rec = records.iter().find(|r| r.name == spec.info.id);
                    let (state, color) = match rec.map(|r| &r.state) {
                        Some(nfarms_plugin::PluginState::Active) => ("已装载", theme::TEXT_MAIN),
                        Some(nfarms_plugin::PluginState::Pending) => ("等依赖", theme::TEXT_SUB),
                        Some(nfarms_plugin::PluginState::Failed(e)) => (e.as_str(), theme::DANGER),
                        _ => ("没记录", theme::TEXT_SUB),
                    };
                    ui.horizontal(|ui| {
                        let (rect, _) =
                            ui.allocate_exact_size(egui::vec2(18.0, 18.0), egui::Sense::hover());
                        icons::draw(ui.painter(), rect, spec.info.icon, theme::ACCENT);
                        ui.add_sized(
                            [130.0, 18.0],
                            egui::Label::new(
                                RichText::new(spec.info.nav).size(12.5).color(theme::TEXT_MAIN),
                            ),
                        );
                        ui.add_sized(
                            [70.0, 18.0],
                            egui::Label::new(RichText::new(state).size(11.5).color(color)),
                        );
                        ui.label(
                            RichText::new(spec.info.id)
                                .size(11.0)
                                .monospace()
                                .color(theme::TEXT_SUB),
                        );
                    });
                }
            }
        }
    });

    cx.ui.add_space(12.0);

    // ---- 事件 ----
    let events: Vec<String> = cx
        .state
        .event_log
        .lock()
        .map(|v| v.clone())
        .unwrap_or_default();
    ui::card(cx.ui, |ui| {
        ui::section(ui, "内核事件", "插件装载 / 服务提供 / 切页都会在这里留一条。");
        ui.add_space(6.0);
        if events.is_empty() {
            ui.label(
                RichText::new("(还没有事件)")
                    .size(12.0)
                    .color(theme::TEXT_SUB),
            );
        } else {
            for line in events.iter().rev().take(12) {
                ui.label(
                    RichText::new(line)
                        .size(11.5)
                        .monospace()
                        .color(theme::TEXT_SUB),
                );
            }
        }
    });

    cx.ui.add_space(12.0);

    // ---- 怎么加一个功能 ----
    ui::card(cx.ui, |ui| {
        ui::section(ui, "怎么加一个新功能", "三步,不用改别人的代码。");
        ui.add_space(6.0);
        ui.label(
            RichText::new(
                "1. 在 pages.rs 的 PAGES 里加一条(分组 / id / 图标 / 标题 / 要点)\n\
                 2. 在 plugins/mod.rs 的 renderer_for() 里把 Page → 绘制函数接上\n\
                 3. 安卓版想让它在 APK 里出现,就往 plugins::mobile() 的列表里加一个 Page",
            )
            .size(12.0)
            .monospace()
            .color(theme::TEXT_MAIN),
        );
        ui.add_space(8.0);
        ui.label(
            RichText::new(
                "功能比「一页」更重的时候,再写一个真正的 Plugin:\n\
                 ctx.provide(\"我的服务\", …) 把自己的能力登记出去,\n\
                 inject(&[\"别人的服务\"]) 声明依赖 —— 依赖没到就自动挂起,到了自动装上。",
            )
            .size(12.0)
            .color(theme::TEXT_SUB),
        );
    });

    cx.ui.add_space(12.0);
    ui::kv(cx.ui, "程序根", &cx.state.paths.root().display().to_string());
    ui::kv(cx.ui, "日志", &cx.state.log.dir().display().to_string());
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::sync::atomic::{AtomicUsize, Ordering};
    use std::time::{Duration, Instant};

    /// 一条独立的临时目录(按测试名分开,免得同进程里几条测试互相踩)。
    fn temp_dir(tag: &str) -> PathBuf {
        let dir = std::env::temp_dir().join(format!("nfarms-core-pages-{tag}-{}", std::process::id()));
        let _ = std::fs::remove_dir_all(&dir);
        std::fs::create_dir_all(&dir).expect("建临时目录");
        dir
    }

    /// 界面线程只负责「发起」和「收结果」,`where` 必须在后台跑。
    ///
    /// 这条测试守的就是回归成同步调用:替身故意睡 600 ms,而 `pump_mgr_probe`
    /// 必须在 300 ms 内返回 —— 一旦有人把 `ep::package_managers()` 直接写回
    /// `environment()`,这里就再也守不住了(那种改动只能靠 code review 拦)。
    /// 留 300 ms 余量是因为跑测试的机器上可能同时有十几份 cargo 在编译。
    #[test]
    fn package_manager_probe_does_not_block_the_ui_thread() {
        static CALLS: AtomicUsize = AtomicUsize::new(0);

        let fake: fn() -> Vec<PkgMgr> = || {
            CALLS.fetch_add(1, Ordering::SeqCst);
            std::thread::sleep(Duration::from_millis(600));
            vec![PkgMgr {
                key: "winget",
                exe: "winget",
                path: Some(PathBuf::from("C:\\fake\\winget.exe")),
            }]
        };

        let ctx = egui::Context::default();
        let mut job: Job<Vec<PkgMgr>> = Job::default();
        let mut done = false;
        let mut out: Vec<PkgMgr> = Vec::new();

        let t0 = Instant::now();
        let got = pump_mgr_probe(&mut job, &ctx, &mut done, &mut out, fake);
        let elapsed = t0.elapsed();
        assert!(!got, "刚发起不可能已经有结果");
        assert!(!done, "结果没回来就不该把探测标成完成");
        assert!(out.is_empty());
        assert!(
            elapsed < Duration::from_millis(300),
            "发起这一步在界面线程上等了 {elapsed:?}"
        );

        // 界面每帧做的就是这件事:抽一次水,拿不到就继续画。
        let deadline = Instant::now() + Duration::from_secs(10);
        let mut finished = false;
        while Instant::now() < deadline {
            if pump_mgr_probe(&mut job, &ctx, &mut done, &mut out, fake) {
                finished = true;
                break;
            }
            std::thread::sleep(Duration::from_millis(10));
        }
        assert!(finished, "后台线程没在 10 秒内回结果");
        assert!(done);
        assert_eq!(out, vec![PkgMgr {
            key: "winget",
            exe: "winget",
            path: Some(PathBuf::from("C:\\fake\\winget.exe")),
        }]);

        // 探测完成之后**不能再发起**:每帧重开一个线程比原来那次同步卡顿更糟。
        out.clear();
        assert!(!pump_mgr_probe(&mut job, &ctx, &mut done, &mut out, fake));
        assert!(out.is_empty(), "探测完了还在重新发起");
        assert_eq!(CALLS.load(Ordering::SeqCst), 1, "替身被跑了不止一次");
    }

    /// dsh 控制台卡片:文件没变时不许每帧重读,变了就必须立刻反映出来。
    #[test]
    fn dsh_tail_is_cached_across_frames() {
        use std::io::Write;

        let dir = temp_dir("dsh-tail");
        let log = dir.join("dsh-web.log");
        std::fs::write(&log, "第一行\n第二行\n").expect("写日志");

        let base = TAIL_READS.with(|n| n.get());
        let reads = || TAIL_READS.with(|n| n.get()) - base;

        let first = tail_cached(&log, 40);
        assert!(first.contains("第二行"), "尾部内容读错了: {first:?}");
        assert_eq!(reads(), 1, "第一次必然要读盘");

        // 同一帧序列里的第二次:文件没动,必须命中缓存。
        let second = tail_cached(&log, 40);
        assert_eq!(second, first);
        assert_eq!(reads(), 1, "文件没变,每帧还在把整个日志读一遍");

        // 追加 → 长度/修改时间都变了 → 必须重读(否则界面永远停在旧输出)。
        let mut f = std::fs::OpenOptions::new()
            .append(true)
            .open(&log)
            .expect("追加打开");
        f.write_all("第三行\n".as_bytes()).expect("追加一行");
        f.flush().expect("刷盘");
        drop(f);

        let grown = tail_cached(&log, 40);
        assert!(grown.contains("第三行"), "日志长了却还在用旧内容: {grown:?}");
        assert_eq!(reads(), 2);

        // 行数变了也是换了一个请求 → 同一个文件必须重算,并且缓存要跟着换。
        let last = tail_cached(&log, 1);
        assert_eq!(last.trim(), "第三行");
        assert_eq!(reads(), 3);
        let last_again = tail_cached(&log, 1);
        assert_eq!(last_again, last);
        assert_eq!(reads(), 3, "行数没变也要命中");

        let _ = std::fs::remove_dir_all(&dir);
    }

    // ---------------------------------------------------------------- 三件套

    /// 状态码 → 灯。四种状态必须**四种不同的样子**。
    ///
    /// 这条测试是这三个数组(obs_state/obs_proc/obs_ports)和界面之间唯一
    /// 不花钱的验证方式:真要验「失败是红的」得先把三件套 exe 备齐、再制造
    /// 一次启动失败 —— 那种测试在别的机器上根本跑不起来。
    #[test]
    fn obs_state_maps_to_four_distinct_badges() {
        let idle = obs_badge(0);
        let busy = obs_badge(1);
        let ready = obs_badge(2);
        let fail = obs_badge(3);

        assert_eq!(idle.text, "未启动");
        assert_eq!(busy.text, "启动中…");
        assert_eq!(ready.text, "已就绪");
        assert_eq!(fail.text, "失败");

        // 颜色混掉两个,用户就分不出「在起」和「起不来」——
        // 而这两种情况该做的事完全相反(等 vs 查)。
        let colors = [idle.color, busy.color, ready.color, fail.color];
        for i in 0..colors.len() {
            for j in (i + 1)..colors.len() {
                assert_ne!(colors[i], colors[j], "第 {i} 种和第 {j} 种状态同色");
            }
        }

        // 只有「启动中」转圈、也只有它把启动按钮禁掉:不禁的话点第二下会起
        // 第二个进程抢同一个端口,而界面上写的是「已就绪」。
        assert!(busy.busy);
        assert!(!idle.busy && !ready.busy && !fail.busy);
        assert_eq!(ready.color, theme::ACCENT_DARK, "已就绪必须是绿的");
        assert_eq!(fail.color, theme::DANGER, "失败必须是红的");

        // 未知值(以后加了第五种状态而这里没跟上)按「未启动」兜底,不 panic。
        assert_eq!(obs_badge(9).text, "未启动");
        assert_eq!(obs_badge(255).color, idle.color);
    }

    /// 下标必须**每个各一次**地覆盖 0..3。
    ///
    /// 漏一个 → 有件服务在界面上根本没有行(用户以为"没这个服务");
    /// 重一个 → 那一行被画两遍,点两边的「启动」会起两个进程。
    #[test]
    fn obs_order_covers_every_frozen_index_once() {
        let mut seen = [false; 3];
        for idx in OBS_ORDER {
            assert!(!seen[idx], "下标 {idx} 在 OBS_ORDER 里出现了两次");
            seen[idx] = true;
        }
        assert!(seen.iter().all(|s| *s), "0/1/2 必须都出现且只出现一次");

        assert_eq!(obs_name(obs_service(OBS_METRICS)), "VictoriaMetrics");
        assert_eq!(obs_name(obs_service(OBS_LOGS)), "Loki");
        assert_eq!(obs_name(obs_service(OBS_DASHBOARD)), "Grafana");
    }

    /// 端口输入:只认 1~65535 的纯数字。
    #[test]
    fn port_input_accepts_only_real_ports() {
        assert_eq!(parse_port("9091"), Some(9091));
        assert_eq!(parse_port(" 9092 "), Some(9092), "带空格也得认(输入框常见)");
        assert_eq!(parse_port("65535"), Some(65535));
        assert_eq!(parse_port(""), None, "空串不是端口");
        assert_eq!(parse_port("909x"), None, "带后缀不是端口");
        assert_eq!(parse_port("70000"), None, "超出 u16");
        assert_eq!(parse_port("-1"), None, "负数不是端口");
        // 0 在 Windows 上是"让系统随便挑一个":存下去的话配置文件里写着 0、
        // 服务听着别的端口,而界面上的地址永远连不上。
        assert_eq!(parse_port("0"), None, "0 不算端口");
    }

    /// 页面的 0/1/2 必须和 `MiscState` 里 `obs_proc` / `obs_state` / `obs_ports`
    /// 的下标是同一套。
    ///
    /// 这一条是**跨文件**的约定(lib.rs 定义、这一页读),而且错位的症状特别不像
    /// 代码问题:点 Loki 的启动,亮的是 VictoriaMetrics 的灯;端口框里改的是
    /// Grafana,Loki 却换了端口。查起来会先怀疑服务。
    #[test]
    fn page_indices_agree_with_lib_state_order() {
        let m = crate::MiscState::default();
        assert_eq!(m.obs_ports[OBS_METRICS], "9091");
        assert_eq!(m.obs_ports[OBS_LOGS], "9092");
        assert_eq!(m.obs_ports[OBS_DASHBOARD], "9093");
        // 名字也要和 `observability::missing()` 返回的那组对得上(它按同一顺序)。
        assert_eq!(
            obs_name(obs_service(OBS_METRICS)),
            observability::missing(Path::new("不存在的目录"))[0],
            "下标 0 是 VictoriaMetrics,不是别的"
        );
    }

    // ------------------------------------------------------------ 启动动画

    /// 只有「选了 MP4 **并且**抽到了帧」才播 MP4,其余一律回内置动画。
    ///
    /// 复发场景:把判据写成只看 `boot_video.is_some()`。ffmpeg 没装好 / 文件损坏 /
    /// 抽帧还没跑完时,用户看到的是**一整块黑**,只会以为程序坏了 ——
    /// 这时候退一档播内置动画,至少还能看出"它在启动"。
    #[test]
    fn boot_visual_falls_back_to_builtin() {
        let p = PathBuf::from(r"C:\video\boot.mp4");
        assert_eq!(boot_visual(None, 0), BootVisual::Builtin, "什么也没选");
        assert_eq!(
            boot_visual(None, 30),
            BootVisual::Builtin,
            "有帧表但没路径(清除时漏清了帧)也得回内置"
        );
        assert_eq!(
            boot_visual(Some(&p), 0),
            BootVisual::Builtin,
            "路径在、一帧都没抽到 —— 这时播 MP4 就是黑屏"
        );
        assert_eq!(boot_visual(Some(&p), 1), BootVisual::Video, "抽到一帧就得算数");
        assert_eq!(boot_visual(Some(&p), 240), BootVisual::Video);
    }

    /// 进度文案的分母:内置固定 1400;MP4 用 `lib.rs::boot_video_total_ms` 那一个。
    ///
    /// 复发场景:在本页自己写「最后一帧的时间戳」。少的就是最后一帧那一格
    /// (10 fps → 100 毫秒):文案上写着已经演完,画面其实还差一帧没放出来 ——
    /// 播放那一边正是照「最后一帧要显示到什么时候」判结束的。
    #[test]
    fn boot_total_ms_follows_lib_for_video() {
        assert_eq!(boot_total_ms(BootVisual::Builtin, &[]), BOOT_BUILTIN_MS);
        assert_eq!(
            boot_total_ms(BootVisual::Builtin, &[0, 800]),
            BOOT_BUILTIN_MS,
            "内置动画不看帧表"
        );

        let at = [0_u64, 500, 2600];
        assert_eq!(
            boot_total_ms(BootVisual::Video, &at),
            crate::boot_video_total_ms(&at),
            "本页给 MP4 算的时长必须和播放那一边(lib.rs)是同一个值"
        );
        assert!(
            boot_total_ms(BootVisual::Video, &at) > *at.last().unwrap() as i64,
            "只算到最后一个偏移:最后一帧永远来不及显示"
        );
        // 空帧表:那边返回 0,这里不能把 0 当分母摆到进度文案上
        assert_eq!(boot_total_ms(BootVisual::Video, &[]), BOOT_BUILTIN_MS);
    }

    /// 本页的「用不用 MP4」和 `lib.rs::boot_use_video` 不能给出不同答案。
    ///
    /// 两边各判一次不是冗余:这边要再分出「没选文件」和「选了但一帧都没有」
    /// 两种文案,那边只管到底播哪一路。但答案必须一致 —— 页面写着"有 30 帧、
    /// 在用 MP4",播放那一边却回退成内置遮罩,用户会觉得这段话在骗人。
    #[test]
    fn boot_visual_agrees_with_lib_use_video() {
        let p = PathBuf::from(r"C:\video\boot.mp4");
        for (video, frames) in [
            (None, 0_usize),
            (None, 3),
            (Some(p.as_path()), 0),
            (Some(p.as_path()), 1),
            (Some(p.as_path()), 60),
        ] {
            let at: Vec<u64> = (0..frames as u64).map(|i| i * 100).collect();
            assert_eq!(
                boot_visual(video, frames) == BootVisual::Video,
                crate::boot_use_video(video, &at),
                "{video:?} / {frames} 帧:本页和 lib.rs 的判断不一致"
            );
        }
    }

    /// 预览那句提示必须把三种情况说全,不能只说一句含糊的"按设置播放"。
    ///
    /// 复发场景:三句话里少一句 —— 选了 MP4 但没勾声音的用户会一直等声音,
    /// 以为音频坏了;而内置动画的用户会去翻设置找那个根本没有的音轨开关。
    #[test]
    fn boot_preview_audio_note_covers_all_three_cases() {
        let builtin = boot_preview_audio_note(BootVisual::Builtin, false);
        let video_off = boot_preview_audio_note(BootVisual::Video, false);
        let video_on = boot_preview_audio_note(BootVisual::Video, true);

        assert_ne!(builtin, video_off);
        assert_ne!(video_off, video_on);
        assert_ne!(builtin, video_on, "内置那两句必须分开说");
        assert!(builtin.contains("没有音轨"), "内置动画要说明它没声音:{builtin}");
        assert!(
            video_on.contains("声音一起"),
            "勾了开关要说清预览会出声:{video_on}"
        );
        assert!(
            video_off.contains("只放画面"),
            "没勾开关要说明是静音,别让用户以为音频坏了:{video_off}"
        );
    }

    /// 只显示本卡片自己写的提示。
    ///
    /// 复发场景:像原来那页一样无条件显示 `misc.message`。那个槽是视频页 /
    /// ffmpeg 页 / 拉流页**共用**的,而本卡片现在长在背景图片页上 ——
    /// 用户从视频页回来会看到「载入成功,时长 …」挂在启动动画卡片下面。
    #[test]
    fn boot_message_only_shows_its_own_hints() {
        assert_eq!(
            boot_message("启动动画: 已清除自定义 MP4"),
            Some(" 已清除自定义 MP4"),
            "自己写的那条要能显示(前缀要剥掉,不然界面上会重复一遍「启动动画:」)"
        );
        assert_eq!(boot_message("载入成功,时长 00:12"), None, "视频页的话不该出现");
        assert_eq!(boot_message(""), None);
    }

    /// 卡片上只摆文件名,别把整条路径塞进去。
    ///
    /// 复发场景:直接摆 `p.display()`。egui 不会自动省略长文本,超过卡片宽度
    /// 之后会**画到卡片外面**(音乐页的「当前」就这么炸过),而且这一行的
    /// 关键信息只是"选的是哪个文件"。
    #[test]
    fn boot_file_label_keeps_the_file_name() {
        assert_eq!(boot_file_label(Path::new(r"C:\video\boot.mp4")), "boot.mp4");
        assert_eq!(boot_file_label(Path::new("boot.mp4")), "boot.mp4");
        // 拿不到末段的路径(以 `..` 收尾)退回整条,而不是显示成空串
        assert_eq!(boot_file_label(Path::new(r"C:\video\..")), r"C:\video\..");
    }

    /// `lib.rs` 里那几个字段和本页读的是同一套,默认值也必须是说好的那套。
    ///
    /// 这一条是**跨文件**的约定(lib.rs 定义、这一页读):把 `boot_sound` 的默认值
    /// 改成 `false`,症状是"选了 MP4 却没声音",而用户第一反应会去查音频设备。
    #[test]
    fn boot_state_defaults_match_the_documented_ones() {
        let m = crate::MiscState::default();
        assert!(m.boot_video.is_none(), "默认没有自定义 MP4 → 用内置动画");
        assert!(m.boot_sound, "声音默认开:选了 MP4 的人多半就是要连声音一起看");
        assert!(m.boot_frames.is_empty(), "默认一帧都没抽");
        assert!(m.boot_frame_at.is_empty(), "帧表和时间戳表要一起空");
    }

    /// 本页**自己写**的那个 store 键,必须和 `lib.rs` 启动时读的是同一个字符串。
    ///
    /// 「启动时播放」是唯一一个本页直接写 store 的开关(声音和 MP4 都走
    /// `AppState::boot_sound_set` / `boot_video_set`,键名归那边管)。
    /// 字符串契约编译器管不着:键名错一个字母,勾选框照样点得动、`save()` 也不报错,
    /// 只是**重启就回到默认** —— 日志、界面、别的测试全绿。(同类跨文件约定见
    /// `page_indices_agree_with_lib_state_order`。)
    #[test]
    fn boot_store_keys_match_lib_reads() {
        assert_eq!(KEY_BOOT_ANIM, "boot_anim");
        // `boot_anim` 在 `lib.rs` 里是按 `== Some("1")` 读的:存 `"true"` 等于没存。
        assert_eq!(flag_str(true), "1");
        assert_eq!(flag_str(false), "0");

        let lib = include_str!("../lib.rs");
        assert!(
            lib.contains(&format!("\"{KEY_BOOT_ANIM}\"")),
            "lib.rs 里没有出现 store 键 {KEY_BOOT_ANIM} —— 本页存下去的值没人读,\
             这个开关重启就会回到默认(而界面上看不出任何异常)"
        );

        // 另外两个键:本页不写,但要知道它们确实还是这两个名字
        // (`boot_state_defaults_…` 之外的第二道:改了名 = 用户选过的视频失联)。
        assert_eq!(crate::BOOT_VIDEO_KEY, "boot_video");
        assert_eq!(crate::BOOT_SOUND_KEY, "boot_sound");

        // 提示前缀两边各一份(`lib.rs` 那份是私有的,拿不到),必须同值:
        // 不一样的话,`lib.rs` 在抽帧失败 / 抽完帧之后说的那句提示会被
        // `boot_message` 全部过滤掉 —— 用户点了预览却什么反馈都没有。
        assert!(
            lib.contains(BOOT_MSG_PREFIX),
            "lib.rs 里给启动动画写的提示不再用前缀 {BOOT_MSG_PREFIX:?} —— 那些话会被本页过滤掉"
        );

        // 内置动画的时长也是两边各一份,值必须一致:不一致的症状是进度文案的
        // 分母和真正收遮罩的时刻对不上。
        assert!(
            lib.contains(&format!("BUILTIN_BOOT_TOTAL_MS: i64 = {BOOT_BUILTIN_MS}")),
            "lib.rs 里 BUILTIN_BOOT_TOTAL_MS 不再是 {BOOT_BUILTIN_MS} 毫秒 —— \
             本页的 BOOT_BUILTIN_MS 要跟着改"
        );
    }
}