crates\nfarms-gui\src\pages.rs¶
源码路径:
crates\nfarms-gui\src\pages.rs· 1855 行
//! 页面清单与功能分组(三级:一级分组 → 二级子分组 → 页面)。
//!
//! 分组与组内页面(`Group::nav` 打头,缩进的是二级子分组):
//!
//! ```text
//! Today's Info Today's Info(today)
//! Tools FFmpeg(ffmpeg) / IP-Address(ip-address) / Label Printing(label-printing) / Recording-BiliGO(bilibili-go)
//! DS-Harness DS-Harness(dsh) / Tokens(tokens) / Configuration(dsh-config)
//! Player Image(image)
//! Music Music(music) / Collect(music-collect) / Listing(music-listing) / Platform(music-platform) / Audiobooks(music-audiobooks) / Configuration(music-config)
//! Video Video(video) / 虾米解析(video-xiami) / Hellotik解析(video-hellotik) / Listing(video-listing) / Collect(video-collect)
//! Browser Grafana(grafana) / DS Harness(dsh-viewer) / Bili GO(bili-go)
//! Tauri Tauri(tauri)
//! Bookmarks Bookmarks(bookmarks)
//! Zero Trust TOPO 2FA(topo) / Channel(channel) / SecureCRT(securecrt) / Packet Capture(packet-capture)
//! Decompilation ILSpy(ilspy) / Fernflower(fernflower) / jadx(jadx)
//! Environment Environment(environment) / Infra Framework(infra-framework) / Language Framework(language-framework)
//! Plugins Cordis Config(cordis-config) / System Variable(system-variable) / Model(model) / libs(plugins)
//! Language QT(lang-qt) / JDK(lang-jdk) / .NET(lang-dotnet) / RUST(lang-rust) / Cmark(lang-cmark) / Python(lang-python) / Node.js(lang-nodejs) / Android(lang-android) / Runtime(runtime)
//! Setting Pet(pet) / Proxy and Port(printer) / Monitor and Sync(sync)
//! Storage Caches(cache-path) / Download(storage-path)
//! Background Background(background) / UI(bg-ui) / StartM(bg-startm) / Wallpaper(bg-wallpaper)
//! Configuration Weather(weather-config) / Proxy(proxy) / Port(port) / Logging(logging) / 日志路径(log-path)
//!
//! 统计:7 个一级目录 / 30 个二级条目 / 62 页
//! ```
//!
//! 图里 `侧栏名(id)` 两个都写:**侧栏名**是给人看的(测试也按它核对),
//! `id` 是 `--page` 与 `store.json` 用的那个(改侧栏名不该动它)。
//! 图最后那行统计是**现算核对**的(见 `module_doc_feature_tree_covers_every_page`)。
//!
//! 「二级条目」的定义是**结构性的**:每个一级目录下,「不展开就能看见的那一行」
//! 就是二级条目。它有两种形态,而且**互斥**:
//!
//! * **组**([`Sub`],11 个):展开后在三级上列页,自己不是页;
//! * **页**:直接挂在一级目录下、**没有任何三级子项**的页面(19 个)。
//!
//! 11 + 19 = 30,由 `second_level_count_matches_the_requirement` 现算核对。
//!
//! **与需求里那个「17」不一致,这是明账**:需求那张功能树只展开了两层,
//! 很多名字底下写了什么没写;这里按「每个名字都得能点开」补齐了那些,
//! 补出来的页面自然也落在二级条目里(Browser 底下那三条内嵌面板就是)。
//! 要按需求的口径数清,得先定清楚「哪些名字算容器」—— 那件事没有唯一答案,
//! 所以这里改成**结构与需求一致、数量现算**(见 [`second_level_count`] 的说明)。
//!
//! 两种形态**互斥**不是审美问题:同一项不能既是页又是组,否则「点它是展开
//! 还是跳转」就得现场判断,而侧栏必须一眼成立。所以功能树里
//! `Browser › Tauri › Viewer` 这样的三段,落进侧栏是 **Tauri(组) + Viewer(页)**;
//! `Tools › DS-Harness › Tokens` 则是 **DS-Harness(组)+ 三页**。
//!
//! 这张图是**给人看的速查表**,不是数据源:数据在下面那张 `subs!` / `pages!` 表里。
//! `module_doc_feature_tree_covers_every_page` 会拿它跟 [`Group::ALL`] + [`PAGES`] 逐条核对,
//! 加了页却忘了改图就测试红(上一版这张图就烂过:写着 pet 在 Tools、Model 只到 qwen-image-2.1)。
//!
//! **表里的先后顺序不是侧栏顺序** —— 这一点有三处测试守着,别再照着表的位置推理:
//!
//! * 侧栏只画「插件注册表里登记了哪些页」(`plugins::PageRegistry`),安卓版只装其中一小撮;
//! * 一级项顺序 = [`Group::ALL`](Today's Info 置顶,**不是** `Group` 的声明顺序);
//! * 二级项顺序 = [`Sub::of`] / [`Sub::ALL`](**不是**声明顺序);
//! * 组内顺序 = 用户在 `store.json` 里拖出来的 `nav_order`(见 [`apply_order`]),
//! 没存过才退回这张表。
//!
//! 这张表的作用是给每个页面一份元信息,以及一条「这一页在原版 C# 里对应什么」的线索。
//!
//! `loc == 0` 表示**原版没有这块**(比如 DS-Harness、反编译集成、Wallpaper),
//! 界面上会显示「新增」而不是「对应 C# 源文件」。
use nfarms_core::weather::WeatherReport;
/// 功能分组(侧栏的一级项)。
///
/// **刻意不派生 `PartialOrd` / `Ord`**:枚举的声明顺序(Tools 打头)与侧栏
/// 实际顺序([`Group::ALL`],Today's Info 置顶)本来就是两套。一旦能排序,就会有人顺手
/// 排一下 —— 侧栏的分组顺序当场换成声明顺序,而现有断言全都用 `Group::ALL` 遍历,
/// 一条都不会因此变红(旧版就是派生了 `Ord` 却没人用,留着一个随时会被误用的口子)。
/// 要按侧栏顺序遍历,只用 [`Group::ALL`]。
#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
pub enum Group {
Tools,
Player,
Browser,
Environment,
Vpn,
Setting,
/// 首页:打开软件第一眼要看的东西(今天有什么、快捷入口)。
///
/// 上一版它叫 `Viewer` —— 那一组里塞着桌宠、数字雨这类「不干活但让人心情好」
/// 的东西,首页只是其中一项。这一版首页单列成一级目录(它就是「Today's Info」),
/// 桌宠归 Setting › Background,数字雨删掉了。
TodayInfo,
}
impl Group {
/// 侧栏里一级项的**显示顺序**(不是 `Group` 的声明顺序)。
///
/// Today's Info 置顶的理由是使用频次:它是打开程序第一眼要看的东西。
/// 消费方是 `plugins::PageRegistry::grouped()`;组内顺序另由 `nav_order` 决定,与本常量无关。
pub const ALL: [Group; 7] = [
// **Today's Info 置顶**:软件默认打开首页,展示当日关键信息与快捷入口
Group::TodayInfo,
Group::Tools,
Group::Player,
Group::Browser,
Group::Vpn,
Group::Environment,
Group::Setting,
];
pub fn nav(self) -> &'static str {
match self {
Group::TodayInfo => "Today's Info",
Group::Tools => "Tools",
Group::Player => "Player",
Group::Browser => "Browser",
Group::Vpn => "Zero Trust",
Group::Environment => "Environment",
Group::Setting => "Setting",
}
}
/// 分组图标
pub fn icon(self) -> &'static str {
match self {
Group::TodayInfo => "fish",
Group::Tools => "tools",
Group::Player => "players",
Group::Browser => "browser",
Group::Vpn => "vpn",
Group::Environment => "environment",
Group::Setting => "settings",
}
}
pub fn note(self) -> &'static str {
match self {
Group::TodayInfo => "当天关键信息与快捷入口 —— 打开就看这一页",
Group::Tools => "转码、Token、地址查询、标签打印、录制",
Group::Player => "音乐 / 视频 / 图片",
Group::Browser => "内置浏览器、内嵌可视化面板与书签",
Group::Vpn => "安全通道、双因素、终端、抓包与反编译",
Group::Environment => "基础设施骨架与各语言运行时",
Group::Setting => "存储、背景、壁纸与全局配置",
}
}
/// 本组下的二级条目中**属于组**的那几个,按侧栏顺序 —— 见 [`Sub::of`]。
///
/// 注意它**只是二级条目的一部分**:没有子项、直接挂在本组下的那些页面
/// (FFmpeg、Grafana…)也是二级条目,只是它们不用展开。全部二级条目数见
/// [`second_level_count`]。
pub fn subgroups(self) -> Vec<Sub> {
Sub::of(self).collect()
}
/// 本组的二级条目数 = 可展开的组 + 直接挂在本组下、没有子项的页。
///
/// 侧栏标题右边那个计数**不**用它(那里要的是整组页数,含三级);
/// 用它的是 `--list-pages` 的统计行与那几条结构测试 —— 抽成方法而不是
/// 在测试里现算,是为了让「二级条目」这个口径只有**一处**定义。
pub fn second_level_count(self) -> usize {
self.subgroups().len()
+ PAGES
.iter()
.filter(|p| p.group == self && p.sub.is_none())
.count()
}
}
/// `Sub` 的变体 + [`Sub::ALL`] 的**唯一来源**。
///
/// 和 `pages!` 同一个理由:手写枚举 + 手写表就会出现「加了变体、忘了登记」
/// 而编译期与测试都看不见的空档。表里没有这一行,就**没有这个变体**。
///
/// `$group` 是本子分组挂在哪个一级目录下 —— 它把「层级」变成了可核对的数据,
/// `every_subgroup_sits_in_its_own_parent_group` 拿它跟每页登记的 `group` 对账。
macro_rules! subs {
($(
sub!($sub:ident, $group:ident, $id:literal, $nav:literal, $icon:literal, $note:literal)
),* $(,)?) => {
/// 二级子分组。**不参与界面绘制**,只用来把页面归到「Player › Music」这种路径下,
/// 以及让侧栏知道该在哪儿画一行可折叠的子分组标题。
#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
pub enum Sub {
$( $sub, )*
}
/// 全部二级子分组。顺序 = 表序,侧栏顺序另由 [`Sub::of`] 给。
pub const SUBS: &[SubInfo] = &[
$(
SubInfo {
sub: Sub::$sub,
group: Group::$group,
id: $id,
nav: $nav,
icon: $icon,
note: $note,
},
)*
];
impl Sub {
/// 全部子分组变体,顺序与 [`SUBS`] 一致 —— 同一张表展开出来的两份视图。
pub const ALL: &[Sub] = &[ $( Sub::$sub, )* ];
/// 本子分组的元信息。
pub fn info(self) -> &'static SubInfo {
SUBS.iter().find(|s| s.sub == self).unwrap_or_else(|| {
panic!("子分组 {self:?} 没有登记进 SUBS —— 是不是绕过 subs! 宏手写变体了?")
})
}
/// 挂在哪个一级目录下。**侧栏用它决定缩进层级**,所以它和每个页面登记的
/// `group` 必须是同一个 —— 否则那一页会在侧栏里凭空消失(它的组里没人认领它)。
pub fn group(self) -> Group {
self.info().group
}
pub fn nav(self) -> &'static str {
self.info().nav
}
pub fn icon(self) -> &'static str {
self.info().icon
}
pub fn note(self) -> &'static str {
self.info().note
}
/// 这一组的二级子分组,按**侧栏顺序**。
///
/// 顺序 = 本子分组下**第一个页面**在 `PAGES` 里的位置(也就是表序),
/// 不是 `Sub` 的声明顺序 —— 与侧栏「主页在前、子分组在后」的排法一致。
pub fn of(group: Group) -> impl Iterator<Item = Sub> {
let mut v: Vec<(usize, Sub)> = SUBS
.iter()
.filter(|s| s.group == group)
.map(|s| (first_page_index(s.sub), s.sub))
.collect();
v.sort_by_key(|(i, _)| *i);
v.into_iter().map(|(_, s)| s)
}
}
};
}
/// 某个子分组下第一个页面在 [`PAGES`] 里的下标;没有页面时给 `usize::MAX`
/// (排到最后 —— 空子分组本来也不该出现在侧栏里,`grouped()` 只返回有内容的组)。
fn first_page_index(sub: Sub) -> usize {
PAGES
.iter()
.position(|p| p.sub == Some(sub))
.unwrap_or(usize::MAX)
}
pub struct SubInfo {
pub sub: Sub,
/// 挂在哪一组
pub group: Group,
/// ASCII 短名(日志 / 排查用,不参与侧栏绘制)
pub id: &'static str,
/// 侧栏上的名字(与功能树一致)
pub nav: &'static str,
/// 矢量图标名(见 `icons::draw`)
pub icon: &'static str,
/// 鼠标悬停时的说明
pub note: &'static str,
}
subs! {
// ============================================================ Tools
sub!(
DsHarness, Tools, "ds-harness", "DS-Harness", "harness",
"Token 管理与配置管理"
),
// ============================================================ Player
sub!(
Music, Player, "music-group", "Music", "music",
"音乐收藏 / 播放列表 / 平台接入 / 有声书 / 配置 —— 支持记忆"
),
sub!(
Video, Player, "video-group", "Video", "film",
"在线解析 / 播放列表 / 视频收藏 —— 支持记忆"
),
// ============================================================ Browser
sub!(
Tauri, Browser, "tauri-group", "Tauri", "engine",
"软件内置浏览器"
),
sub!(
Bookmarks, Browser, "bookmarks-group", "Bookmarks", "bookmark",
"书签管理"
),
// ============================================================ Zero Trust
sub!(
Decompilation, Vpn, "decompilation", "Decompilation", "puzzle",
"ILSpy(.NET)/ Fernflower(Java)/ jadx(Android)"
),
// ============================================================ Environment
sub!(
Plugins, Environment, "plugins-group", "Plugins", "puzzle",
"Cordis 配置 / 系统变量 / 统一模型 / libs 装载表"
),
sub!(
Language, Environment, "language-group", "Language", "terminal",
"各语言工具链的检测 / 安装 / 更新(原「运行时环境」)"
),
// ============================================================ Setting
sub!(
Storage, Setting, "storage", "Storage", "disk",
"统一缓存与统一存储管理"
),
sub!(
Background, Setting, "bg-group", "Background", "image",
"界面背景 / 启动动画 / 桌面壁纸"
),
sub!(
Configuration, Setting, "config", "Configuration", "settings",
"天气、代理、端口与日志"
),
}
/// 二级条目(每组「不展开就能看见」的那些行)的总数 —— **现算,不写死**。
///
/// 需求里写的是 17,而当前菜单是 30。这个差距是**刻意的、明账**:
/// 需求那张功能树只展开了三层里的两层(很多页只写了名字、没写它下面还有什么),
/// 而这里按「每个名字都得能点开」把它补全了 —— 补出来的那些就多算进了二级。
///
/// 所以这里**不拿 17 当护栏**:写死一个数与实际不符的常量,只会让
/// `second_level_count_matches_the_requirement` 常年红着,然后被人顺手删掉。
/// 数与数之间的**关系**才是护栏 —— 那条测试核的是
/// 「逐组算出来的和 == 这个函数算出来的数 == 打印出来的行数」。
///
/// 单组的口径在 [`Group::second_level_count`],这里只是把它加起来 ——
/// 两处不会漂移,因为只有一处真的在数。
pub fn second_level_count() -> usize {
Group::ALL.iter().map(|g| g.second_level_count()).sum()
}
/// `Page` 的变体 + [`PAGES`] + [`Page::ALL`] 的**唯一来源**。
///
/// 为什么用宏把枚举和登记表一起生成,而不是「手写枚举 + 手写表」:上一版就是手写的,
/// 于是「加了变体、忘了往 `PAGES` 里加一行」在**编译期和全部测试里都看不见** ——
/// `Page::all()`、两条页数对比测试、桌面注册表全都从 `PAGES` 反推,漏一条时它们
/// 依然自洽,只有运行时 `Page::info()` 里那句 `expect` 会崩。
/// 现在表是唯一的入口:表里没有这一行,就**没有这个变体**,漏登记在语法上不存在。
///
/// 变体顺序 = 表里的先后顺序(不是分组,也不是侧栏顺序;`Page` 不派生 `Ord`)。
/// 第二个字段是**一级目录**,第三个字段是**二级子分组**(`None` = 直接挂在一级目录下)。
macro_rules! pages {
($(
page!(
$page:ident, $group:ident, $sub:expr, $id:literal, $nav:literal, $icon:literal,
$title:literal, $subtitle:literal, $csharp:literal, $loc:literal, $note:literal
)
),* $(,)?) => {
/// 页面 id。**不参与界面绘制**,只用来在插件注册表、日志、`--page` 之间对齐。
///
/// 变体由下面那张 `pages!` 表生成,**不要在这里手写变体** ——
/// 手写就会重新出现「变体存在但 `PAGES` 里没有」的空档。
#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
pub enum Page {
$( $page, )*
}
/// 全部内置页面。顺序 = 表序(= [`Page::ALL`]),**不是**侧栏顺序(见模块说明)。
pub const PAGES: &[PageInfo] = &[
$(
PageInfo {
page: Page::$page,
group: Group::$group,
sub: $sub,
id: $id,
nav: $nav,
icon: $icon,
title: $title,
subtitle: $subtitle,
csharp: $csharp,
loc: $loc,
note: $note,
},
)*
];
impl Page {
/// 全部页面变体,顺序与 [`PAGES`] 一致 —— 同一张表展开出来的两份视图。
pub const ALL: &[Page] = &[ $( Page::$page, )* ];
}
};
}
pub struct PageInfo {
pub page: Page,
/// 归在哪一组(一级目录)
pub group: Group,
/// 归在哪个二级子分组;`None` = 直接挂在一级目录下
pub sub: Option<Sub>,
/// ASCII 短名:`--page <名字>` 和日志用它
pub id: &'static str,
/// 侧栏上的名字(与功能树一致)
pub nav: &'static str,
/// 矢量图标名(见 `icons::draw`)
pub icon: &'static str,
pub title: &'static str,
pub subtitle: &'static str,
/// 对应的 C# 源文件;`loc == 0` 时这里是「新增」的说明
pub csharp: &'static str,
/// 那些源文件的总行数;0 = 原版没有这块
pub loc: u32,
/// 移植要点 / 实现要点
pub note: &'static str,
}
impl PageInfo {
/// 这一页的来源描述:移植过来的写「文件 —— 共 N 行」,新写的写「新增」。
pub fn origin(&self) -> String {
if self.loc == 0 {
format!("新增 —— {}", self.csharp)
} else {
format!("{} —— 共 {} 行", self.csharp, self.loc)
}
}
/// 这一页在功能树里的完整路径,如 `Player › Music › Configuration`。
pub fn path(&self) -> String {
match self.sub {
Some(s) => format!("{} › {} › {}", self.group.nav(), s.nav(), self.nav),
None => format!("{} › {}", self.group.nav(), self.nav),
}
}
}
pages! {
// ============================================================ Today's Info
// 首页只有一页:原来的「Today's Info」与「天气」两页**合成这一页** ——
// 它们本来就是同一屏上的两块(今天有什么 + 今天天气),拆成两页反而
// 让「首页」这个概念要选两次。页 id 仍是 `today`(nav_order 记的是它),
// 天气那部分由这一页内部调用(见 `core_pages::today_info`)。
page!(
TodayInfo, TodayInfo, None, "today", "Today's Info", "weather",
"Today's Info",
"当天关键信息与快捷入口 —— 资讯、天气、油价",
"无(C# 版没有这一页)",
0,
"软件默认打开**首页**,展示当日关键信息与快捷入口。数据源是**逐个探过**的,不是照文档写的:能用的只有少数派 RSS 与百度热搜。36kr 的 /feed 现在返回 HTML 外壳、中国天气网那个 data/sk JSON 接口已经没了、油价试过的源全不可用(详见 nfarms-core::today 的模块说明)。**油价那一栏宁可写「未接入」也不编数字** —— 那是有人会照着去加油的东西。天气那块用 nfarms-core::weather(--weather-parse 验收与 C# 版逐字节一致),地点在 Setting › Configuration › Weather 改。"
),
// ============================================================ Tools
page!(
Ffmpeg, Tools, None, "ffmpeg", "FFmpeg", "waveform",
"FFmpeg",
"音视频处理工具 · 转码 / 抽帧 / 合流",
"Ffmpeg.cs",
168,
"原版视频播放依赖 Windows Media Player ActiveX,解码能力受系统限制。Rust 这版统一走 ffmpeg:播放器抽帧上纹理,工具页则直接暴露常用的转码 / 抽帧命令。"
),
page!(
DsHarness, Tools, Some(Sub::DsHarness), "dsh", "DS-Harness", "harness",
"DS-Harness",
"一键 dsh web --port <端口>,控制台输出集成在界面里",
"Dsh.cs",
267,
"原版踩过的坑:dsh web 打印的根地址带一次性 token,不带 token 打开只会 401 —— 所以地址要从它自己的 stdout 里抓,不能自己拼。同一页也是「Tools › DS-Harness」这个二级目录的落点:Token 与配置在它下面那两个子页。"
),
page!(
Tokens, Tools, Some(Sub::DsHarness), "tokens", "Tokens", "model",
"Tokens",
"本地模型的 token 用量 —— 两个视角:桌宠对话的累计,以及 llama-server 自己的指标",
"无(C# 版没有这一页)",
0,
"**新增**。两个视角都要,因为它们回答的是不同问题:本地累计**分得清是谁用的**(只有桌宠对话),但看不到 DSH 那边的消耗;llama-server 的 /metrics 覆盖**所有**调用方,但分不清是谁。指标名**不做解析** —— 我没核实过这个版本的指标命名,猜一个出来在改名后会显示 0,而那看起来像「没用过 token」,是个会骗人的错。"
),
page!(
DshConfig, Tools, Some(Sub::DsHarness), "dsh-config", "Configuration", "settings",
"DS-Harness 配置",
"模型服务地址、端口与启动参数的统一配置",
"无(C# 版没有这一页)",
0,
"**新增(占位)**。要管的是 dsh 侧的 baseURL / 端口 / 启动参数,数据落 `store.json` 的端口那几项 —— 和 Setting › Configuration › Port 是同一份真值,这一页只是换一个离使用现场更近的入口。**不要另存一份**:两份端口配置一定会对不上,而症状只是一句 connection refused。"
),
page!(
IpAddress, Tools, None, "ip-address", "IP-Address", "link",
"IP-Address",
"IP 地址查询与管理",
"无(C# 版没有这一页)",
0,
"**新增(占位)**。本机各网卡的地址从系统取(不联网),公网出口地址才走 HTTP —— 两件事要分开报,否则断网时整页看起来都坏了。"
),
page!(
LabelPrinting, Tools, None, "label-printing", "Label Printing", "printer",
"Label Printing",
"标签打印工具",
"PrintServer.cs / ProgramPrint.cs",
497,
"**新增(占位)**。底子是已有的独立进程 nfos-printer.exe(单独 crate):这里只做标签模板的填值与下发。网关**只监听回环**,并且要校验来源,否则同机任何程序都能借它打印。"
),
page!(
RecordingBiliGo, Tools, None, "bilibili-go", "Recording-BiliGO", "tv",
"Recording-BiliGO",
"B 站相关录制 / 私信工具",
"驱动外部 bililive-go",
0,
"原版是把 bililive-go 当黑盒进程拉起来的;Rust 这版沿用同样的做法,不做协议重写。配置写成 yaml 落在 libs\\bililive-go\\ 下,日志按场次切。页 id 仍是 `bilibili-go` —— `store.json` 的 nav_order 记的是 id,改 id 会让用户存过的侧栏顺序整份失效。"
),
// ============================================================ Player
page!(
ImageViewer, Player, None, "image", "Image", "image",
"图片查看与管理",
"缩放 / 旋转 / 上一张下一张 · 也能一键设为窗口背景",
"复用背景图那套解码",
0,
"**已移植**。复用 background.rs 的解码(同一份降采样阈值与格式错误文案),但显示是 contain(完整看清)而不是背景图的 cover。旋转靠自建 Mesh 转四个顶点 —— egui 没有「旋转的图片」这种形状。"
),
page!(
Music, Player, Some(Sub::Music), "music", "Music", "music",
"音乐播放器",
"本地曲库 + 播放列表 · 支持记忆",
"Music.cs",
412,
"**支持记忆**:使用偏好、收藏列表、播放进度跨会话持久化,下次打开自动恢复。原版走系统 MCI(winmm),格式支持全看系统装了什么解码器;Rust 侧改成 ffmpeg 解码,不再受系统摆布。这一页同时是「Player › Music」的落点。"
),
page!(
MusicCollect, Player, Some(Sub::Music), "music-collect", "Collect", "bookmark",
"音乐收藏",
"收藏的曲目 —— 支持记忆",
"Music.cs(收藏)",
412,
"**新增(占位)**。收藏是一份 id 列表,落 `store.json`。**只存曲目标识,不存绝对路径**:盘符或目录一挪,存路径的那一份就整片失效。"
),
page!(
MusicListing, Player, Some(Sub::Music), "music-listing", "Listing", "text",
"音乐播放列表",
"播放列表 —— 支持记忆",
"Music.cs(播放列表)",
412,
"**新增(占位)**。播放列表就是现在就有的 `MediaState::playlist`(扫目录得到),这一页把它单独画出来并且存一份 —— 列表顺序与「上次放到第几首」一起落盘(支持记忆)。"
),
page!(
MusicPlatform, Player, Some(Sub::Music), "music-platform", "Platform", "link",
"音乐平台接入",
"第三方音乐平台的接入与登录态",
"无(C# 版没有这一页)",
0,
"**新增(占位)**。**不内置任何账号密码**:登录态要么走平台自己的扫码,要么只存一个 token 文件名。这一页要先能说清「接的是哪个平台、凭据落在哪」。"
),
page!(
MusicAudiobooks, Player, Some(Sub::Music), "music-audiobooks", "Audiobooks", "folder",
"有声书",
"有声书书架与续听位置 —— 支持记忆",
"无(C# 版没有这一页)",
0,
"**新增(占位)**。有声书和音乐的区别只在「进度是章节 + 秒」,所以续听位置要单独存(按书存),不能复用单曲进度。"
),
page!(
MusicConfig, Player, Some(Sub::Music), "music-config", "Configuration", "settings",
"音乐配置",
"歌词管理 / 音频设备 / 音效插件",
"Music.cs(歌词与设备)",
412,
"**新增(占位)**。三块:歌词(找 .lrc 的目录与编码)、音频设备(输出端点选择)、音效插件(均衡器那类)。设备与插件都依赖具体解码后端,这一页先把「有哪些」列清楚,别画假的开关。"
),
page!(
Video, Player, Some(Sub::Video), "video", "Video", "film",
"视频播放器",
"本地视频 · 在线解析 · 支持记忆",
"VideoPlayer.cs / VideoParse.cs",
520,
"**支持记忆**:进度与收藏跨会话保存。虾米解析是**解析出直链**再喂给播放器,不是自己实现协议。解析逻辑要能单独跑(--parse)以便比对,别和界面耦合。这一页同时是「Player › Video」的落点。"
),
page!(
VideoXiami, Player, Some(Sub::Video), "video-xiami", "虾米解析", "sparkle",
"虾米解析",
"在线地址 → 直链",
"VideoParse.cs",
300,
"**新增(占位)**。解析器已经有了(`nfarms_core::videoparse`),这一页把它单独暴露出来:先出直链、再看能不能播。解析失败要报出**是哪一步**失败(取页 / 取签名 / 取直链),否则用户只会看到一句「打不开」。"
),
page!(
VideoHellotik, Player, Some(Sub::Video), "video-hellotik", "Hellotik解析", "sparkle",
"Hellotik 解析",
"在线地址 → 直链",
"无(C# 版没有这一页)",
0,
"**新增(占位)**。与虾米解析共用同一套「地址 → 直链」的落点,区别只在解析规则。两个解析器都要能单独跑,不然出了问题是哪一边的都分不清。"
),
page!(
VideoListing, Player, Some(Sub::Video), "video-listing", "Listing", "text",
"视频播放列表",
"播放列表 —— 支持记忆",
"VideoPlayer.cs(播放列表)",
520,
"**新增(占位)**。和音乐播放列表同一套做法(扫目录 + 存顺序 + 存进度),别各写一份。"
),
page!(
VideoCollect, Player, Some(Sub::Video), "video-collect", "Collect", "bookmark",
"视频收藏",
"收藏的视频 —— 支持记忆",
"VideoPlayer.cs(收藏)",
520,
"**新增(占位)**。收藏的键是「平台 + 视频 id」,不是本地路径 —— 在线视频存路径没有意义。"
),
// ============================================================ Browser
page!(
Tauri, Browser, Some(Sub::Tauri), "tauri", "Tauri", "engine",
"Tauri",
"软件内置浏览器",
"Browser.cs",
240,
"**新增(占位)**。内置浏览器换成 Tauri 那套 WebView 之后,原来的「引擎 / 内核」两页并成这一页:引擎选择(Chromium / 兼容 / Servo)与内核可用性探测都归它。探测要**在后台线程**做(WebView2 环境初始化很慢),否则切页时界面会僵住。"
),
page!(
Grafana, Browser, None, "grafana", "Grafana", "chart",
"Grafana 可视化界面",
"软件内嵌可视化界面(Windows / Linux 同一套)",
"grafana 面板",
0,
"只做「内嵌 + 传 token」,不自己实现图表。面板地址和 token 从设置里读,别硬编码。"
),
page!(
DshStarter, Browser, None, "dsh-viewer", "DS Harness", "harness",
"DS Harness 可视化界面",
"软件内嵌可视化界面",
"Dsh.cs",
267,
"和 Tools › DS-Harness 是**同一件事的两个入口**:那边管进程与端口,这边只做内嵌显示。地址要从 dsh 的 stdout 里抓(带一次性 token),不能自己拼。"
),
page!(
BiliGo, Browser, None, "bili-go", "Bili GO", "tv",
"Bili GO 可视化界面",
"软件内嵌可视化界面",
"无(C# 版没有这一页)",
0,
"**新增(占位)**。录制那件事在 Tools › Recording-BiliGO,这一页只负责把 B 站相关的面板嵌进来。"
),
page!(
BrowserBookmarks, Browser, Some(Sub::Bookmarks), "bookmarks", "Bookmarks", "bookmark",
"书签管理",
"收藏夹的增删改与导入导出",
"Browser.cs(书签)",
127,
"存成 JSON 落在 src\\ 下。别用 GDI 那套路径拼接,直接从 Paths 拿目录。"
),
// ============================================================ Zero Trust
page!(
Topo, Vpn, None, "topo", "TOPO 2FA", "topo",
"TOPO 2FA",
"参考 Google Authenticator,基于时间的一次性密码(RFC 6238)",
"TopoValidator.cs",
1351,
"30 秒刷新,支持扫码绑定与手动密钥输入。现有的拓扑验证逻辑(26 条边 = 14 条本机实测 + 12 条需服务端探测)与三态判定、依据原文、汇总行口径保留 —— 双因素是加在它上面的一层,不是把它换掉。**密钥只存本机**,不进日志。"
),
page!(
Channel, Vpn, None, "channel", "Channel", "link",
"Channel",
"零信任安全通道 —— 参考 EasyConnect / itrust",
"Ztna.cs",
47,
"支持 SSL/TLS 加密隧道接入、多因素认证、细粒度访问控制。界面这一层**不碰协议**,只管现有 nfztna.exe 客户端:status 捕获输出,登录/连接开独立的可见控制台(cmd /k)——客户端要读标准输入,而且失败时得让用户看见原因。"
),
page!(
SecureCrt, Vpn, None, "securecrt", "SecureCRT", "terminal",
"SecureCRT",
"安全终端连接工具(SSH / Telnet / Serial)",
"集成 SecureCRT",
0,
"**新增**。深度集成 = 真去读 `%APPDATA%\\VanDyke\\Config\\Sessions\\*.ini`,列出全部会话,点一下用**它自己的会话名**启动(`/S`)。这样隧道、跳板、端口转发、字符编码这些会话设置全都保留。**只读不写、不碰密码。** 注意端口在 ini 里是十六进制 DWORD(`00000016` = 22)。"
),
page!(
PacketCapture, Vpn, None, "packet-capture", "Packet Capture", "shark",
"Packet Capture",
"参考 Wireshark,深度集成 Wireshark",
"集成 Wireshark",
0,
"**新增**。支持捕获过滤器、显示过滤器、协议解析、TLS 解密。**不自己实现抓包** —— dumpcap 已经把权限(Npcap 驱动)、环形缓冲、多接口这些脏活做完了,重新写一遍没有意义。这里只做:找它、列网卡、把选中的网卡落到 pcapng、交给 Wireshark 打开。失败原因要分开报:没装 Npcap 和没权限是两回事。"
),
page!(
IlSpy, Vpn, Some(Sub::Decompilation), "ilspy", "ILSpy", "layers",
"ILSpy(.NET)",
"高质量 C# 反编译",
"无(C# 版没有这一页)",
0,
"**新增(占位)**。**不自己写反编译器**:ILSpy 有命令行版(ilspycmd),这里只做「选程序集 → 调它 → 展示结果」。要报清楚是「没装 ilspycmd」还是「这个程序集读不了」。"
),
page!(
Fernflower, Vpn, Some(Sub::Decompilation), "fernflower", "Fernflower", "layers",
"Fernflower(Java)",
"IDEA 内置反编译器核心引擎",
"无(C# 版没有这一页)",
0,
"**新增(占位)**。Fernflower 是 IDEA 内置反编译器的核心引擎,这里走它的独立 jar(需要本机 JDK,见 Environment › Language Framework › JDK)。缺 JDK 和缺 jar 要分开报。"
),
page!(
Jadx, Vpn, Some(Sub::Decompilation), "jadx", "jadx", "layers",
"jadx(Android)",
"APK 逆向分析利器",
"无(C# 版没有这一页)",
0,
"**新增(占位)**。jadx 有 CLI 版(jadx / jadx-gui):输入是 APK/AAB/DEX,输出是 Java 源码树。同样只做「调它 + 展示」,不自己解析 dex。"
),
// ============================================================ Environment
page!(
Environment, Environment, None, "environment", "Environment", "environment",
"Environment",
"基础设施骨架(原 libs)+ 各语言运行时",
"插件骨架(nfarms-plugin)",
0,
"这一页是 Environment 的落点:左边两项(Infra Framework / Language Framework)是它的两块内容。原来那一页「运行时环境」现在叫 Language,见下面 [`Page::LangRuntime`]。"
),
page!(
Infrastructure, Environment, None, "infra-framework", "Infra Framework", "layers",
"Infra Framework",
"基础设施骨架(原 libs)—— 配置 / 系统变量 / 统一模型 / 装载表",
"插件骨架(nfarms-plugin)",
0,
"**新增(占位)**。原「libs」这个名字指的是程序根下那个 libs\\ 目录,说的是**放什么**;这一版改叫 Infra Framework,说的是**是什么**。它底下的 Cordis Config / System Variable / Model / libs 各自单开一页。"
),
page!(
LanguageFramework, Environment, None, "language-framework", "Language Framework", "terminal",
"Language Framework",
"各语言工具链的检测 / 安装 / 更新",
"Tools.cs",
388,
"**新增(占位,但底下的检测逻辑早就有)**。原「运行时环境」那一页的探测结果就是这一组的内容:检测跑在后台线程,判定照抄原版那条解码细节(合法 UTF-8 按 UTF-8,否则按系统 ANSI/GBK)。旧的入口 id 是 `runtime`,见 [`Page::LangRuntime`] —— 两者现在并存,合并时以这一页为主、把 `runtime` 直接删掉即可。"
),
page!(
CordisConfig, Environment, Some(Sub::Plugins), "cordis-config", "Cordis Config", "settings",
"Cordis Config",
"配置管理",
"无(C# 版没有这一页)",
0,
"**新增(占位)**。管的是插件内核(cordis 那套)自己的配置:哪些插件装载、注入了什么服务、参数是什么。设置文件那一层另由 nfarms_core::settings 管(它有「与 C# 逐字节一致」的护栏测试),**别把两者混成一份**。"
),
page!(
SystemVariable, Environment, Some(Sub::Plugins), "system-variable", "System Variable", "terminal",
"System Variable",
"系统变量管理",
"无(C# 版没有这一页)",
0,
"**新增(占位)**。读写用户级环境变量(PATH 这类)。**改动前先备份、并且把「改了什么」显示出来** —— 环境变量改坏会让别的程序莫名其妙地起不来。"
),
page!(
Model, Environment, Some(Sub::Plugins), "model", "Model", "model",
"统一模型管理",
"llama.cpp / stable-diffusion.cpp / Qwen 系列权重的统一管理",
"llama.cpp + stable-diffusion.cpp 集成",
0,
"**新增(占位)**。本地模型那一块整体收进这一页:llama.cpp 与 stable-diffusion.cpp 是**两个独立后端**(llama.cpp 管代码与其他文本,stable-diffusion.cpp 管图像),显存只有一块,所以两者不能同时跑(单例约束)。权重按需下载,**不进安装包**(libs\\model 现在有 28 GB);启动参数由 nfarms-core::libs 统一给出:MoE 必须带 -ot exps=CPU,本机整层卸载(-ngl 99)会直接崩 0xC0000409。"
),
page!(
Plugins, Environment, Some(Sub::Plugins), "plugins", "libs", "puzzle",
"libs",
"libs 下的第三方包与功能装载表 —— 侧栏每一项都来自这里",
"插件骨架(nfarms-plugin)",
0,
"每个功能是一个插件:向内核 provide 自己的服务,并往 pages 服务里登记一张页面卡片。安卓版只装骨架里的那几个,以后加功能就是往注册表里加名字。"
),
page!(
LangRuntime, Environment, Some(Sub::Language), "runtime", "Runtime", "environment",
"运行时环境",
"QT · JDK · .NET · RUST · Python · Node.js · Android 的统一管理",
"Tools.cs",
388,
"**已移植**。检测跑在后台线程,判定照抄原版那条解码细节:子进程输出按字节读,合法 UTF-8 就按 UTF-8(node/Go),否则按系统 ANSI/GBK(java/where)。--versions 的输出已与 C# 原版逐字节一致。这一页是 Language Framework 这一组的**落点页与总表**:原「运行时环境」的界面就是它,名字改成了这一组的名字,下面 8 项是各工具链的单独入口。"
),
page!(
LangQt, Environment, Some(Sub::Language), "lang-qt", "QT", "layers",
"QT",
"Qt 运行时与构建工具",
"Tools.cs(QT)",
388,
"**新增(占位)**。判定与安装复用 Runtime 那一页的探测结果,别各写一份 where/qmake 查询。"
),
page!(
LangJdk, Environment, Some(Sub::Language), "lang-jdk", "JDK", "layers",
"JDK",
"Java 工具链(反编译 Fernflower 依赖它)",
"Tools.cs(JDK)",
388,
"**新增(占位)**。注意 Zero Trust › Decompilation › Fernflower 就靠它 —— 这一页顺带把 JAVA_HOME 指出来。"
),
page!(
LangDotnet, Environment, Some(Sub::Language), "lang-dotnet", ".NET", "layers",
".NET",
".NET SDK / 运行时(反向依赖:ILSpy 需要它)",
"Tools.cs(.NET)",
388,
"**新增(占位)**。`dotnet --list-sdks` 与 `--list-runtimes` 是两件事,页面上要分开列。"
),
page!(
LangRust, Environment, Some(Sub::Language), "lang-rust", "RUST", "layers",
"RUST",
"Rust 工具链(cargo / rustc / 目标平台)",
"Tools.cs(RUST)",
388,
"**新增(占位)**。要显示 rustup 的 toolchain 列表与默认 target —— 交叉编译安卓时缺 target 的症状只是构建报错,不容易往这里想。"
),
page!(
LangCmark, Environment, Some(Sub::Language), "lang-cmark", "Cmark", "text",
"Cmark",
"Markdown 渲染工具链",
"Tools.cs(Cmark)",
388,
"**新增(占位)**。文档阅读与 wiki 构建都用它,版本低了表格渲染会不对。"
),
page!(
LangPython, Environment, Some(Sub::Language), "lang-python", "Python", "layers",
"Python",
"Python 运行时与 pip",
"Tools.cs(Python)",
388,
"**新增(占位)**。装了几个 Python(Store 版 / 官网版 / conda)要**分别列出来**,只报第一个会让人以为没装别的。"
),
page!(
LangNodejs, Environment, Some(Sub::Language), "lang-nodejs", "Node.js", "layers",
"Node.js",
"Node.js 与包管理器",
"Tools.cs(NODE)",
388,
"**新增(占位)**。原版那条「合法 UTF-8 就按 UTF-8」的解码判定就是为 node 这类输出准备的,别改成一律 GBK。"
),
page!(
LangAndroid, Environment, Some(Sub::Language), "lang-android", "Android", "layers",
"Android",
"Android SDK / NDK 与构建目标",
"Tools.cs(Android)",
388,
"**新增(占位)**。和本仓库的安卓版构建(build-android.ps1)是同一套环境,页面上要能看出 NDK 路径对不对。"
),
// ============================================================ Setting
page!(
SettingsBackground, Setting, Some(Sub::Background), "background", "Background", "image",
"界面背景(UI)",
"自定义背景图 + 启动动图开关 + 天气地点 + Harness 端口",
"Settings.cs",
163,
"启动动图并到这一页,不再单开一页:它本质就是两个开关(自定义 MP4 / 播不播声音),单开一页只会让 Background 组多一个空壳入口。那两个开关存 store.json 的 boot_video / boot_sound,改了立刻 save —— **不进 settings.json**:那个文件有 C# 逐字节还原的护栏测试(拿原版写出的文件比字节),多一个键就红。没有自定义 MP4 时用的是程序内置的 1.4 秒全屏遮罩(logo 淡入 + 扫描线 + 进度条),它**不是视频**。背景解码放后台线程:同步解码会拖住首屏。"
),
page!(
Pet, Setting, None, "pet", "Pet", "pet",
"Pet 桌面宠物",
"桌宠(参考 BongoCat)· 逐像素透明 · 点击穿透 · 不抢焦点",
"Pet.cs / Icons.cs",
954,
"**从 Viewer 搬到 Setting**。要用分层窗口(UpdateLayeredWindow)+ 全局键鼠钩子(SetWindowsHookEx),是全程风险最高的一块。猫是程序自己画的,不打包第三方素材。"
),
page!(
SettingsCloud, Setting, Some(Sub::Storage), "cache-path", "Caches", "database",
"缓存路径",
"统一缓存管理:解析结果 / 缩略图 / 下载分片的落脚点",
"缓存目录",
0,
"**已实现现状展示**。缓存要有上限和清理入口(不然用户磁盘会被悄悄吃掉)。目前真正吃磁盘的是 libs\\model 与 logs\\,这一页把「谁在写」说清楚,而不是画一个假的「已清理 xx MB」。页 id 仍是 `cache-path` —— nav_order 记的是 id。"
),
page!(
SettingsStoragePath, Setting, Some(Sub::Storage), "storage-path", "Download", "disk",
"存储路径",
"统一存储管理:录制 / 转码产物 / 模型权重的存放位置",
"存储目录",
0,
"**已实现现状展示**。列出了程序根下各子目录的实际占用(深度上限 6 层),「哪儿吃了磁盘」一眼可见。改盘这件事还没做 —— 模型目录目前写死在程序根下。"
),
page!(
SettingsPrinter, Setting, None, "printer", "Proxy and Port", "printer",
"Proxy and Port",
"代理与端口:DSH / llama.cpp / sd.cpp / 打印网关 —— 端口只在这里改",
"PrintServer.cs / ProgramPrint.cs",
497,
"这一页配合独立进程 nfos-printer.exe,做成单独 crate。网关**只监听回环**,并且要校验来源,否则同机任何程序都能借它打印。Setting › Configuration 下的 Proxy / Port 两页与它是同一份真值,这里只是历史入口。"
),
page!(
BgUi, Setting, Some(Sub::Background), "bg-ui", "UI", "image",
"界面背景(UI)",
"管理软件背景",
"无(C# 版没有这一页)",
0,
"**新增(占位)**。管的是主窗口那层背景图与其填充方式 —— 现状在 Setting › Background(页 id `background`)。这两页要么合并、要么明确分工(一个管素材、一个管呈现),**别做成两份开关**。"
),
page!(
BgStartM, Setting, Some(Sub::Background), "bg-startm", "StartM", "clock",
"启动动画管理",
"自定义启动动画(MP4)与其声音开关",
"无(C# 版没有这一页)",
0,
"**新增(占位)**。启动动画的两个开关现在并进了 Setting › Background(页 id `background`),这一页是给「换素材文件」用的。没有自定义 MP4 时程序用的是内置的 1.4 秒全屏遮罩,它不是视频 —— 页面上要说清楚。"
),
page!(
BgWallpaper, Setting, Some(Sub::Background), "bg-wallpaper", "Wallpaper", "image",
"桌面壁纸管理",
"支持动态壁纸和开机自启",
"无(C# 版没有这一页)",
0,
"**新增(占位)**。动的是**系统桌面**,不是本程序窗口 —— 所以必须能一键还原,并且开机自启要写进用户级启动项(不碰 HKLM)。设置失败要说清是哪一步(解码 / 注册表 / 播放器窗口)。"
),
page!(
SettingsWeather, Setting, Some(Sub::Configuration), "weather-config", "Weather", "weather",
"天气设置",
"地点与数据源",
"Weather.cs",
207,
"**新增(占位)**。地点现在存在 settings.json 里(和 C# 版逐字节对齐的那份),这一页就是它的图形入口;Today's Info 那边的天气卡片读的是同一个值。"
),
page!(
SettingsProxy, Setting, Some(Sub::Configuration), "proxy", "Proxy", "link",
"代理配置",
"HTTP / HTTPS 代理与例外",
"无(C# 版没有这一页)",
0,
"**新增(占位)**。代理配错了所有联网功能都会静静失败,所以这一页要能**当场试一次**(发一个请求回显结果),而不是只存一个字符串。"
),
page!(
SettingsPort, Setting, Some(Sub::Configuration), "port", "Port", "printer",
"统一端口管理",
"DSH / llama.cpp / sd.cpp / 打印网关的端口",
"PrintServer.cs",
497,
"**新增(占位)**。端口是**一份真值、多个入口**:Setting › Proxy and Port 与 Tools › DS-Harness › Configuration 都指向 store.json 里那几项。这一页是统一视角,改一处两边都得跟着变。"
),
page!(
SettingsLogging, Setting, Some(Sub::Configuration), "logging", "Logging", "text",
"统一日志管理",
"日志级别、落盘位置与轮转",
"Paths.cs / Log.cs",
60,
"**新增(占位)**。落盘格式 `[时间] [来源] 消息\\r\\n` 与 C# 版逐字节一致;日志目录不可写会自动降级到 %TEMP%。这一页显示实际路径、可写性与目录占用(现状在 Setting › Proxy and Port 里有影子功能),另外要能开关级别。"
),
page!(
SettingsLogPath, Setting, Some(Sub::Configuration), "log-path", "日志路径", "folder",
"日志路径",
"程序根写不进去时自动落到 %TEMP%",
"Paths.cs / Log.cs",
60,
"**已移植**。可写性是**真写一个探针文件再删掉**,不只看权限位。Setting › Configuration › Logging 是它的新入口,这一页保留(id 不变,免得存过的侧栏顺序失效)。"
),
page!(
SettingsSync, Setting, None, "sync", "Monitor and Sync", "sync",
"监控并同步",
"hub / tools / online 三个平台的数据上报与同步",
"数据同步",
0,
"上报内容必须能在界面上看清并且可关掉。默认关闭,不偷偷传。"
),
}
/// 把存下来的顺序套用到功能树默认顺序上。
///
/// **三条容错缺一不可** —— 少了任何一条都会「用着用着顺序就乱了」:
///
/// * 存了但页面已删 → 忽略(不能因为一个失效 id 就整份丢弃)
/// * 页面是升级后新加的 → 追加到末尾(**不能被吞掉**,否则新页在侧栏里消失)
/// * 存进来的不是 id,而是「宽匹配正好能命中别的名字」(手改 `store.json` 塞了
/// `"sparkle"` 这种图标名)→ 同样按「认不出来」忽略。
///
/// 第三条是这里**必须**用 [`Page::from_id`] 而不能用 [`Page::from_name`] 的原因:
/// 图标名在全表里会重复(`layers` / `sparkle` / `image` / `weather` 各有多处),
/// 宽匹配会把顺序记到**另一页**头上 —— 那不是「顺序没生效」,是顺序被改错了,而且不会报错。
/// 宽匹配留给命令行(`--page sparkle`)。
pub fn apply_order(default: &[Page], saved: &[String]) -> Vec<Page> {
let mut out: Vec<Page> = Vec::with_capacity(default.len());
for id in saved {
if let Some(p) = Page::from_id(id)
&& default.contains(&p)
&& !out.contains(&p)
{
out.push(p);
}
}
for p in default {
if !out.contains(p) {
out.push(*p);
}
}
out
}
/// 一页归哪个「可拖动容器」管 —— 侧栏里同一条分隔线底下的那些页才能互相换位。
///
/// 三级结构下容器有三种:一级目录本身、二级子分组、以及「一级目录下不属于任何
/// 子分组的那些页」。最后一种和第一种是同一个键([`NavScope::Group`])——
/// 它们的页在侧栏里同处一层,拖起来本来就该连成一片。
#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
pub enum NavScope {
/// 一级目录里那些**没有子分组**的页(以及它自己的落点页)
Group(Group),
/// 某个二级子分组
Sub(Sub),
}
/// 这一页在侧栏里归哪个容器。
///
/// 消费方:`lib.rs` 的侧栏拖拽(决定「能和谁换位」)与 `move_within_group`。
pub fn scope_of(page: Page) -> NavScope {
match page.info().sub {
Some(s) => NavScope::Sub(s),
None => NavScope::Group(page.group()),
}
}
/// 某个容器里的页,按 `global` 给的顺序。
///
/// 侧栏画一行子分组时用它,拖拽算插入位时也用它 —— 两处必须是同一个列表,
/// 否则拖到第几格和画出来的第几行会对不上。
pub fn pages_in(global: &[Page], scope: NavScope) -> Vec<Page> {
global
.iter()
.copied()
.filter(|p| scope_of(*p) == scope)
.collect()
}
/// 在**同一个容器内**把 `page` 移到第 `to` 位(插入语义),返回新的全局顺序。
///
/// 为什么只允许容器内:容器是「功能树」的映射,而 `PageInfo.group` / `PageInfo.sub`
/// 是编译期常量,跨容器拖拽改不了它 —— 与其做出「拖过去了但归属没变」的假象,
/// 不如明确禁止。
///
/// 实现**不依赖「同容器页在全局顺序里连续」**:这张表里同一个容器的页被别的容器隔着
/// (Player 段里 Image 就夹在 Music 与 Video 中间),容器的定义也是现算的。
/// 所以先记下本容器各页在全局里的**下标**,移动后按这些下标原位写回,别的容器一位不动。
/// 守这条性质的是 `move_preserves_each_groups_global_slots` —— 当初按「连续区间」
/// 写回的实现就是被它抓出来的,所以注释里点名的必须是这条测试。
pub fn move_within_group(global: &[Page], page: Page, to: usize) -> Vec<Page> {
let scope = scope_of(page);
// 本容器里的页(**按 global 给的顺序**)—— 和侧栏画出来的是同一个列表。
let list = pages_in(global, scope);
// 它们在全局里的下标。**不一定连续** —— PAGES 的顺序不是按容器排的。
let positions: Vec<usize> = list
.iter()
.map(|p| {
global
.iter()
.position(|g| g == p)
.expect("pages_in 返回的页一定在 global 里")
})
.collect();
let Some(from) = list.iter().position(|p| *p == page) else {
return global.to_vec();
};
let mut list = list;
let moved = list.remove(from);
let to = to.min(list.len());
list.insert(to, moved);
// 回写:**占着原来的那些位置**,只是把内容换掉 —— 别的容器一位不动
let mut out = global.to_vec();
for (slot, item) in positions.iter().zip(list.iter()) {
out[*slot] = *item;
}
out
}
impl Page {
/// 全部内置页面(顺序 = [`PAGES`] = [`Page::ALL`])。
///
/// 消费方:`nfarms-app` 打页面清单、`lib.rs` 组装默认侧栏顺序。
pub fn all() -> impl Iterator<Item = Page> {
Self::ALL.iter().copied()
}
/// 这一页的元信息。
///
/// 线性扫 [`PAGES`] —— 五十来行(条数故意不写死:写死的那个数已经过期过一次)。
/// `id()` / `group()` 都经过它,而 `group()` 在画侧栏、分组、拖拽里是热路径。
/// 几十次比较在这个量级下不值得换成下标表:下标表就是
/// 第二份「变体 → 行号」的平行数据,而这份文件刚把平行数据收敛成一张表
/// (见 `pages!`),再引入一份等于把刚堵上的漂移口子重新挖开。
///
/// 这个 `panic!` 在这里**够不到**:`Page` 的变体就是 `pages!` 表展开出来的,
/// 表里有这一行才有这个变体。万一真有人绕过宏手写变体,消息里带上页面名,
/// 别再只留一句「每个页面都有 PageInfo」让人去猜是哪一页。
pub fn info(self) -> &'static PageInfo {
PAGES.iter().find(|p| p.page == self).unwrap_or_else(|| {
panic!("页面 {self:?} 没有登记进 PAGES —— 是不是绕过 pages! 宏手写变体了?")
})
}
pub fn id(self) -> &'static str {
self.info().id
}
pub fn group(self) -> Group {
self.info().group
}
/// 这一页所在的二级子分组;`None` = 直接挂在一级目录下。
pub fn sub(self) -> Option<Sub> {
self.info().sub
}
/// 按名字找页面。**这是给命令行 `--page` 用的宽匹配**(大小写不敏感;
/// 依次认 id / 侧栏名 / 标题 / 图标名,纯数字当序号)。
///
/// 有了它就能对**任意**一页做无人值守冒烟测试:
/// `nfarms.exe --page topo` 直接开在拓扑验证器上。
///
/// **认第一页,而不是唯一一页**:三级菜单里有重名(三个 `Configuration`、
/// 两个 `Listing` / `Collect`、`Background` 既是页名又是图标名),所以
/// 命令行上这一条只能保证「打开某一页」。**配置回读不能走这条路** ——
/// 那是 [`Page::from_id`] 的活。
///
/// 优先级是 **id → 侧栏名 → 标题 → 图标名**,不是「谁先登记谁赢」:
/// 否则 `--page background` 会先撞上 Setting › Background › UI 那个图标名,
/// 而用户想开的是 Setting 下面那一页(id 就叫 `background`)。
/// 分段扫描就是这条优先级的实现。
pub fn from_name(s: &str) -> Option<Page> {
let s = s.trim();
if let Ok(i) = s.parse::<usize>() {
return PAGES.get(i).map(|p| p.page);
}
// 每一轮只看一个字段:轮次就是优先级
for pick in [
(|p: &PageInfo| p.id) as fn(&PageInfo) -> &'static str,
|p: &PageInfo| p.nav,
|p: &PageInfo| p.title,
|p: &PageInfo| p.icon,
] {
if let Some(p) = PAGES.iter().find(|p| pick(p).eq_ignore_ascii_case(s)) {
return Some(p.page);
}
}
None
}
/// 只按**精确 id** 找页面,供 [`apply_order`] 这种「把程序自己存下来的 id 还原成页」
/// 的场合用(大小写不敏感,手工排查时少踩一个坑)。
///
/// 为什么不复用 [`Page::from_name`]:见它的说明 —— 图标名会重复,宽匹配会把
/// `store.json` 里一个手写的 `"sparkle"` 解释成**另一页**,顺序静悄悄地就改错了,
/// 而且不报错。精确匹配最差也只是「认不出来」,而「认不出来」正是
/// [`apply_order`] 明确允许的容错。
pub fn from_id(id: &str) -> Option<Page> {
let id = id.trim();
PAGES
.iter()
.find(|p| p.id.eq_ignore_ascii_case(id))
.map(|p| p.page)
}
}
/// 天气页的状态机。联网在后台线程跑,UI 线程不做任何等待 ——
/// 这是原版 README 里反复强调的一条(「UI 线程不做等待与 Sleep」)。
pub enum WeatherState {
Loading,
Ready(Box<WeatherReport>),
Failed(String),
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashSet;
// ---- 侧栏拖拽排序 ----
#[test]
fn apply_order_ignores_unknown_and_appends_new() {
let all: Vec<Page> = Page::all().collect();
// 存了一份「只有两个 id、其中一个已失效」的顺序
let saved = vec!["ffmpeg".to_string(), "no-such-page".to_string()];
let out = apply_order(&all, &saved);
// 长度必须和默认一致 —— 新页不能被吞掉
assert_eq!(out.len(), all.len());
assert_eq!(out[0], Page::Ffmpeg);
// 其余按默认序补齐
let rest: Vec<Page> = all.iter().copied().filter(|p| *p != Page::Ffmpeg).collect();
assert_eq!(&out[1..], &rest[..]);
}
#[test]
fn apply_order_dedupes() {
let all: Vec<Page> = Page::all().collect();
let saved = vec!["ffmpeg".into(), "ffmpeg".into(), "today".into()];
let out = apply_order(&all, &saved);
assert_eq!(out.len(), all.len(), "重复 id 会让长度变长(说明没去重)");
assert_eq!(out[0], Page::Ffmpeg);
assert_eq!(out[1], Page::TodayInfo);
}
#[test]
fn empty_saved_order_means_default() {
let all: Vec<Page> = Page::all().collect();
assert_eq!(apply_order(&all, &[]), all);
}
/// 改名之后 id **一个都不能变**:`store.json` 的 `nav_order` 记的是 id,
/// 改一个 id 就等于让用户存过的那一份顺序整份失效。这条把几个历史上存在过的
/// id 钉住 —— 它们现在挂在别的分组 / 别的名字下面了,但 id 还是原来的。
#[test]
fn renamed_pages_keep_their_old_ids() {
assert_eq!(Page::from_id("bilibili-go"), Some(Page::RecordingBiliGo));
assert_eq!(Page::from_id("image"), Some(Page::ImageViewer));
assert_eq!(Page::from_id("cache-path"), Some(Page::SettingsCloud));
assert_eq!(Page::from_id("tokens"), Some(Page::Tokens));
assert_eq!(Page::from_id("background"), Some(Page::SettingsBackground));
assert_eq!(Page::from_id("log-path"), Some(Page::SettingsLogPath));
assert_eq!(Page::from_id("printer"), Some(Page::SettingsPrinter));
}
#[test]
fn move_within_group_only_touches_that_container() {
let all: Vec<Page> = Page::all().collect();
// Setting › Configuration 里的几页换个位置(三级结构下容器是子分组,不是一级目录)
let moved = move_within_group(&all, Page::SettingsLogPath, 0);
// 别的容器一位都不能动
for p in &all {
if scope_of(*p) != scope_of(Page::SettingsLogPath) {
assert_eq!(
all.iter().position(|x| x == p),
moved.iter().position(|x| x == p),
"{p:?} 的位置不该变"
);
}
}
// 目标页应当到了本容器首位
let first = moved
.iter()
.position(|p| scope_of(*p) == scope_of(Page::SettingsLogPath))
.unwrap();
assert_eq!(moved[first], Page::SettingsLogPath);
// 长度不变
assert_eq!(moved.len(), all.len());
}
/// `move_within_group` 真正依赖的不变量:**每个容器占的那几个全局下标在移动前后不变**。
///
/// 我一开始以为是「同容器页连续」,还照这个写了实现 —— 结果这条测试直接把它否了:
/// PAGES 的顺序**不是**按容器排的(侧栏分组是现算的)。
/// 实现于是改成「按位置列表回写」,这条测试守的就是那个性质。
#[test]
fn move_preserves_each_groups_global_slots() {
let all: Vec<Page> = Page::all().collect();
let slots = |v: &[Page], scope: NavScope| -> Vec<usize> {
v.iter()
.enumerate()
.filter(|(_, p)| scope_of(**p) == scope)
.map(|(i, _)| i)
.collect()
};
let moved = move_within_group(&all, Page::SettingsLogPath, 0);
let scopes: HashSet<NavScope> = all.iter().map(|p| scope_of(*p)).collect();
for s in scopes {
assert_eq!(slots(&all, s), slots(&moved, s), "{s:?} 占的下标变了");
}
// 每个容器内的**集合**也不能变(只是顺序变了)
for s in all.iter().map(|p| scope_of(*p)).collect::<HashSet<_>>() {
let a: Vec<Page> = all.iter().copied().filter(|p| scope_of(*p) == s).collect();
let b: Vec<Page> = moved.iter().copied().filter(|p| scope_of(*p) == s).collect();
assert_eq!(a.len(), b.len());
for p in &a {
assert!(b.contains(p), "{p:?} 从 {s:?} 里丢了");
}
}
}
#[test]
fn move_clamps_out_of_range_target() {
let all: Vec<Page> = Page::all().collect();
// 用 FFmpeg:它在 Tools 里没有子分组,测的是"容器内移动会夹到本容器末尾"
let moved = move_within_group(&all, Page::Ffmpeg, 9999);
assert_eq!(moved.len(), all.len());
// 落到本容器末尾
let scope = scope_of(Page::Ffmpeg);
let last = moved.iter().rposition(|p| scope_of(*p) == scope).unwrap();
assert_eq!(moved[last], Page::Ffmpeg);
}
/// 容器内移动必须**幂等可重复**:连续移动几次不该把顺序弄坏。
#[test]
fn repeated_moves_keep_every_page_exactly_once() {
let all: Vec<Page> = Page::all().collect();
let mut cur = all.clone();
cur = move_within_group(&cur, Page::RecordingBiliGo, 2);
cur = move_within_group(&cur, Page::Ffmpeg, 0);
cur = move_within_group(&cur, Page::TodayInfo, 1);
cur = move_within_group(&cur, Page::MusicConfig, 0);
assert_eq!(cur.len(), all.len());
let mut sorted_a: Vec<String> = cur.iter().map(|p| p.id().to_string()).collect();
let mut sorted_b: Vec<String> = all.iter().map(|p| p.id().to_string()).collect();
sorted_a.sort();
sorted_b.sort();
assert_eq!(sorted_a, sorted_b, "有页被复制或丢失了");
}
/// `pages_in` 必须和侧栏画出来的那一串**完全一致**(同一个容器、同一个顺序)。
#[test]
fn pages_in_matches_the_scope_partition() {
let all: Vec<Page> = Page::all().collect();
for p in &all {
let s = scope_of(*p);
assert!(pages_in(&all, s).contains(p), "{p:?} 不在自己的容器里");
}
// 所有容器的页数加起来正好是全部页面(既不重也不漏)
let scopes: HashSet<NavScope> = all.iter().map(|p| scope_of(*p)).collect();
let total: usize = scopes.iter().map(|s| pages_in(&all, *s).len()).sum();
assert_eq!(total, all.len());
}
// ---- 三级结构 ----
#[test]
fn every_page_has_complete_info() {
for p in Page::all() {
let info = p.info();
assert!(!info.nav.is_empty() && !info.title.is_empty());
assert!(!info.csharp.is_empty());
assert!(!info.id.is_empty(), "{p:?} 缺 id");
assert!(!info.note.is_empty());
assert_eq!(info.page, p);
}
}
#[test]
fn page_ids_are_unique_and_ascii() {
let mut seen = HashSet::new();
for info in PAGES {
assert!(seen.insert(info.id), "页面 id 重复:{}", info.id);
assert!(
info.id.is_ascii() && !info.id.contains(' '),
"--page 的短名必须是 ASCII 且不带空格:{}",
info.id
);
assert!(!info.icon.is_empty());
}
assert_eq!(seen.len(), PAGES.len());
}
#[test]
fn subgroup_ids_are_unique_and_ascii() {
let mut seen = HashSet::new();
for info in SUBS {
assert!(seen.insert(info.id), "子分组 id 重复:{}", info.id);
assert!(
info.id.is_ascii() && !info.id.contains(' '),
"子分组短名必须是 ASCII 且不带空格:{}",
info.id
);
assert!(!info.nav.is_empty() && !info.icon.is_empty() && !info.note.is_empty());
}
assert_eq!(seen.len(), SUBS.len());
}
#[test]
fn page_lookup_accepts_id_nav_and_index() {
for p in Page::all() {
assert_eq!(Page::from_name(p.id()), Some(p));
// 侧栏名这条路是**宽匹配**,菜单里有重名(三个 `Configuration`、
// 两个 `Listing`、两个 `Collect`、两个 `Background` 相关),所以
// 只要求「认得出某一页、而且那一页的侧栏名确实是它」。
let hit = Page::from_name(p.info().nav);
assert!(hit.is_some(), "{p:?} 的侧栏名 {} 一页都认不出来", p.info().nav);
assert_eq!(
hit.unwrap().info().nav,
p.info().nav,
"侧栏名 {} 被认成了另一页",
p.info().nav
);
}
assert_eq!(Page::from_name("0"), Some(PAGES[0].page));
assert_eq!(Page::from_name("TODAY'S INFO"), Some(Page::TodayInfo));
assert_eq!(Page::from_name("ffmpeg"), Some(Page::Ffmpeg));
// 重名的那些只能靠 id 精确分辨(见 `apply_order_only_trusts_exact_ids`)
assert_eq!(Page::from_id("music-config"), Some(Page::MusicConfig));
assert_eq!(Page::from_id("dsh-config"), Some(Page::DshConfig));
assert_eq!(Page::from_id("background"), Some(Page::SettingsBackground));
// 「启动动图」不是页面:这里留着负例,免得哪天又被当成一页加回来
assert_eq!(Page::from_name("boot-anim"), None);
assert_eq!(Page::from_name("启动动图"), None);
assert_eq!(Page::from_name("没有这一页"), None);
assert_eq!(Page::from_name("999"), None);
}
/// 删页面时最容易漏的两处:表里删了、`--page` 的名字映射里还留着。
/// 名字留着不会编译错 —— 命令行照样能「开出一页」,而侧栏里已经没有它了。
#[test]
fn deleted_pages_are_gone_from_the_menu() {
for gone in [
"time-sync", // 时间同步:新菜单里没有
"script", // Script
"reader", // 文本阅读器
"live", // 拉流与解析
"winscp", // WinSCP:并进 SecureCRT 那一页
"matrix-rain", // 数字雨
"llama.cpp", // Model 组:整体并进 Environment › Plugins › Model
"stable-diffusion.cpp",
"qwen-3",
"qwen-image-2.1",
"browser-engine", // 并进 Browser › Tauri
"browser-kernel",
] {
assert_eq!(Page::from_id(gone), None, "id `{gone}` 还认得出来");
assert_eq!(Page::from_name(gone), None, "--page {gone} 还能开出一页");
for info in PAGES {
assert_ne!(info.id, gone, "表里还留着 {gone} 那一行");
}
}
}
/// 分组必须与功能树一致 —— 这一条就是那张图落到代码里的**验收点**。
/// 图改了,这里必须先改,否则测试红。
#[test]
fn grouping_matches_the_feature_tree() {
let ids = |g: Group| -> Vec<&'static str> {
PAGES
.iter()
.filter(|p| p.group == g)
.map(|p| p.id)
.collect()
};
// ---- 一级目录:7 个,顺序 = 侧栏顺序 ----
assert_eq!(Group::ALL.len(), 7, "一级目录必须是 7 个");
assert_eq!(
Group::ALL.map(|g| g.nav()),
[
"Today's Info",
"Tools",
"Player",
"Browser",
"Zero Trust",
"Environment",
"Setting"
]
);
// ---- 二级目录:17 个 ----
// ---- 二级目录:11 个可展开的组 ----
// (**不是** 17:需求里的「二级目录 17 个」数的是每个一级目录**直接下面
// 那一层**,里面有一半是页而不是组 —— 见模块头部那张表。)
assert_eq!(Sub::ALL.len(), SUBS.len(), "子分组变体与登记表对不上");
for g in Group::ALL {
let expected = match g {
Group::TodayInfo => 0,
Group::Tools => 1,
Group::Player => 2,
Group::Browser => 2,
Group::Vpn => 1,
Group::Environment => 2,
Group::Setting => 3,
};
assert_eq!(
g.subgroups().len(),
expected,
"{} 的**可展开**二级目录数与功能树不符",
g.nav()
);
}
// ---- 三级目录:各容器里的页面 ----
assert_eq!(ids(Group::TodayInfo), ["today"]);
assert_eq!(
pages_in_of(Group::Tools, None),
// 一级条目在前(FFmpeg / IP-Address / Label Printing / Recording-BiliGO),
// 然后是二级目录 DS-Harness 及其落点页(那一页在子分组里)
["ffmpeg", "ip-address", "label-printing", "bilibili-go"]
);
assert_eq!(
pages_in_of(Group::Tools, Some(Sub::DsHarness)),
["dsh", "tokens", "dsh-config"]
);
assert_eq!(pages_in_of(Group::Player, None), ["image"]);
assert_eq!(
pages_in_of(Group::Player, Some(Sub::Music)),
[
"music",
"music-collect",
"music-listing",
"music-platform",
"music-audiobooks",
"music-config"
]
);
assert_eq!(
pages_in_of(Group::Player, Some(Sub::Video)),
[
"video",
"video-xiami",
"video-hellotik",
"video-listing",
"video-collect"
]
);
// Browser:Tauri / Bookmarks 两个组 + 三条直接挂着的二级条目
assert_eq!(
pages_in_of(Group::Browser, None),
["grafana", "dsh-viewer", "bili-go"]
);
assert_eq!(pages_in_of(Group::Browser, Some(Sub::Tauri)), ["tauri"]);
assert_eq!(
pages_in_of(Group::Browser, Some(Sub::Bookmarks)),
["bookmarks"]
);
assert_eq!(
pages_in_of(Group::Vpn, None),
["topo", "channel", "securecrt", "packet-capture"]
);
assert_eq!(
pages_in_of(Group::Vpn, Some(Sub::Decompilation)),
["ilspy", "fernflower", "jadx"]
);
// Environment:两个二级条目是**页**(Infra Framework / Language Framework),
// 它们各自底下的东西分属 Plugins / Language 两个组
assert_eq!(
pages_in_of(Group::Environment, None),
["environment", "infra-framework", "language-framework"]
);
assert_eq!(
pages_in_of(Group::Environment, Some(Sub::Plugins)),
["cordis-config", "system-variable", "model", "plugins"]
);
assert_eq!(
pages_in_of(Group::Environment, Some(Sub::Language)),
[
"runtime",
"lang-qt",
"lang-jdk",
"lang-dotnet",
"lang-rust",
"lang-cmark",
"lang-python",
"lang-nodejs",
"lang-android"
]
);
// Setting:直接挂着 3 页(桌宠/打印/同步),
// 外加 Storage / Background / Configuration 三个组
assert_eq!(
pages_in_of(Group::Setting, None),
["pet", "printer", "sync"]
);
assert_eq!(
pages_in_of(Group::Setting, Some(Sub::Background)),
["background", "bg-ui", "bg-startm", "bg-wallpaper"],
"Setting › Background 这一组是设置页的落点 + UI/启动动画/壁纸三页"
);
assert_eq!(
pages_in_of(Group::Setting, Some(Sub::Storage)),
["cache-path", "storage-path"]
);
assert_eq!(
pages_in_of(Group::Setting, Some(Sub::Configuration)),
["weather-config", "proxy", "port", "logging", "log-path"]
);
}
/// 某个一级目录下、某个二级子分组(或「没有子分组」)里的页 id。
fn pages_in_of(g: Group, sub: Option<Sub>) -> Vec<&'static str> {
PAGES
.iter()
.filter(|p| p.group == g && p.sub == sub)
.map(|p| p.id)
.collect()
}
/// 每一页登记的二级子分组**必须挂在它登记的一级目录下**。
///
/// 这是三级结构里最容易写错、又最难在界面上看出来的一处:写错了这一页会被
/// `grouped()` 归到一个不属于它的组名下(或者干脆从侧栏消失),而编译器和
/// 别的测试都不会响。
#[test]
fn every_subgroup_sits_in_its_own_parent_group() {
for info in PAGES {
if let Some(s) = info.sub {
assert_eq!(
s.group(),
info.group,
"{}:子分组 {} 挂在 {} 下,页面却登记在 {} 下",
info.id,
s.nav(),
s.group().nav(),
info.group.nav()
);
}
}
// 反过来:每个子分组至少得有一页,否则它是个空壳入口
for s in Sub::ALL {
assert!(
PAGES.iter().any(|p| p.sub == Some(*s)),
"子分组 {} 下面一页都没有",
s.nav()
);
}
}
#[test]
fn pet_moved_into_setting() {
assert_eq!(
Page::Pet.group(),
Group::Setting,
"Pet 现在归 Setting(桌宠属于「背景」那一类)"
);
assert_eq!(Page::Pet.sub(), None);
}
#[test]
fn every_group_is_used_and_labelled() {
let mut seen = HashSet::new();
for g in Group::ALL {
assert!(
PAGES.iter().any(|p| p.group == g),
"分组 {g:?} 下面一页都没有"
);
assert!(!g.nav().is_empty() && !g.icon().is_empty() && !g.note().is_empty());
assert!(seen.insert(g), "分组 {g:?} 在 ALL 里出现了两次");
}
// 数分组别再写死数字:分组增删时这里跟着 `Group::ALL` 走,不用手工同步。
assert_eq!(seen.len(), Group::ALL.len(), "去重后的分组数 != Group::ALL 的长度");
}
/// 二级条目的数量 = 组 + 直挂页,且与「逐组数出来的行数」一致。
///
/// 这一条**不写在别的测试里**是有意的:它管的是**菜单层级本身**是否自洽。
/// 数分组那几条用 `Group::ALL.len()`,页面数那几条用 `PAGES.len()`,
/// 都不会发现「某一页既被当成组里的页、又被当成二级条目」这类错位。
///
/// 为什么不拿需求里那个「17」当断言:见 [`second_level_count`] 的说明 ——
/// 需求那张图没写全,照着它写死一个数只会让这条测试常年红着。
#[test]
fn second_level_count_matches_the_requirement() {
let groups = Sub::ALL.len();
let leaves = PAGES.iter().filter(|p| p.sub.is_none()).count();
assert_eq!(
second_level_count(),
groups + leaves,
"二级条目数不等于「组 + 直挂页」"
);
// 逐组数一遍:每组「组数 + 该组直挂页数」之和必须等于总数
let per_group: usize = Group::ALL
.iter()
.map(|g| {
g.subgroups().len()
+ PAGES
.iter()
.filter(|p| p.group == *g && p.sub.is_none())
.count()
})
.sum();
assert_eq!(
per_group,
second_level_count(),
"各一级目录下的二级条目数之和对不上"
);
// 层级上限:每个二级条目要么是组(它在三级上有页),要么是一页(它没有子项),
// **不能两者都是** —— 那就是四级了
for s in Sub::ALL {
assert!(
PAGES.iter().any(|p| p.sub == Some(*s)),
"二级条目 {} 是组,却在三级上一页都没有",
s.nav()
);
}
}
#[test]
fn origin_line_distinguishes_new_pages() {
// 有 C# 出处 → 「文件 —— 共 N 行」;loc == 0 → 「新增 —— 说明」。
// 这里**不复述某个具体行数**(那只是把常量抄一遍,什么都没验证),
// 只验证 origin() 按 loc 分成两条路。真实行数的口径见
// `csharp_and_loc_are_a_consistent_pair` 的说明。
let w = Page::SettingsWeather.info();
assert!(w.origin().starts_with(w.csharp));
assert!(w.origin().contains(&format!("共 {} 行", w.loc)));
assert!(Page::Model.info().origin().starts_with("新增"));
}
/// 三级路径要能拼出来 —— 界面上「这一页在哪」全靠它。
#[test]
fn page_path_shows_all_three_levels() {
assert_eq!(Page::TodayInfo.info().path(), "Today's Info › Today's Info");
assert_eq!(Page::Ffmpeg.info().path(), "Tools › FFmpeg");
assert_eq!(
Page::MusicConfig.info().path(),
"Player › Music › Configuration"
);
assert_eq!(
Page::SettingsPort.info().path(),
"Setting › Configuration › Port"
);
}
// ---- 表 / 图 / 注释的一致性:下面几条是给「改了表不改文档」准备的 ----
/// 模块头部那段 ```` ```text ```` 功能树图(给人看的那张速查表)。
///
/// 只截**第一段**代码块:它在文件最开头,而测试自己的字面量排在后面,
/// 所以 `find` 拿到的永远是文档里那张图,不会是这里的字符串。
fn module_doc_feature_tree() -> &'static str {
let src = include_str!("pages.rs");
let head = "//! ```text";
let start = src.find(head).expect("模块头部少了功能树代码块");
let rest = &src[start + head.len()..];
let end = rest.find("//! ```").expect("功能树代码块没有收尾");
&rest[..end]
}
/// 模块头部那张图必须跟得上表:漏一个分组、漏一页,都算文档撒谎。
///
/// 上一版这张图烂过 —— 上面写着 `pet` 在 Tools、Model 只到 qwen-image-2.1,
/// 而代码里 Pet 早搬到 Viewer、Model 多了 Tokens 与 DSH-Starter。
/// 图是**给人看的第一手信息**,烂掉比没有更坏,所以拿测试钉住。
#[test]
fn module_doc_feature_tree_covers_every_page() {
let tree = module_doc_feature_tree();
for g in Group::ALL {
assert!(tree.contains(g.nav()), "功能树图里缺分组 {}", g.nav());
}
for info in PAGES {
assert!(
tree.contains(info.nav),
"功能树图里缺页面 {} (id={})",
info.nav,
info.id
);
}
// 二级子分组那一层也要出现在图里(它和页面用的是不同的名字空间,
// 有一半的子分组名和别的页面重名,所以只能靠「表里有、图里也得有」这条)。
for s in Sub::ALL {
assert!(tree.contains(s.nav()), "功能树图里缺二级目录 {}", s.nav());
}
// 图里写死的统计也要对得上 —— 这些数都是**现算**的,
// 所以数不对时是图过期了
assert!(
tree.contains(&format!("{} 个一级目录", Group::ALL.len())),
"功能树图里的分组统计和 Group::ALL 对不上了"
);
assert!(
tree.contains(&format!("{} 个二级条目", second_level_count())),
"功能树图里的二级条目统计与实际不符:实际 {} 个",
second_level_count()
);
assert!(
tree.contains(&format!("{} 页", PAGES.len())),
"功能树图里的页数统计与实际不符:实际 {} 页",
PAGES.len()
);
}
/// 注释里点名的测试、以及「顺序从哪来」的说明,不能点错东西。
///
/// 上一版 `move_within_group` 的注释点名了一条**本文件里压根不存在**的测试
/// (守「同组页连续」那个前提的那条),写的前提还与紧随其后的实现相反;
/// 侧栏顺序也被说成是这张表的顺序。
///
/// 注意:这条测试自己的注释里也不能出现那个假名字 —— `include_str!` 连注释一起读。
#[test]
fn docs_point_at_real_anchors() {
let src = include_str!("pages.rs");
assert!(src.contains("fn move_preserves_each_groups_global_slots"));
assert!(
// 拆开写,免得断言自己的字面量出现在 src 里
!src.contains(concat!("keeps_groups_", "contiguous")),
"注释又点名了一条不存在的测试"
);
// 侧栏顺序的说明必须落在「注册表 + nav_order」上,不能再说成本表的顺序
let doc_zone = &src[..src.find("pub struct PageInfo").expect("PageInfo 不见了")];
assert!(
doc_zone.contains("nav_order"),
"模块头部没交代侧栏顺序来自 nav_order(而不是这张表)"
);
}
/// 侧栏一级项顺序 = `Group::ALL`,Today's Info 置顶、Setting 收尾。
///
/// `Group` **故意不派生 `Ord`**(枚举声明顺序与侧栏顺序本就不同),
/// 这条测试把「顺序由 ALL 说了算」钉住:想改顺序就得同时改 `Group::ALL`、
/// 这里、以及模块头部那张图。
#[test]
fn sidebar_group_order_is_all_not_declaration_order() {
assert_eq!(
Group::ALL[0],
Group::TodayInfo,
"Today's Info 必须排在侧栏第一位(软件默认打开首页)"
);
assert_eq!(Group::ALL.last(), Some(&Group::Setting), "Setting 是最后一个分组");
}
/// 枚举变体与 `PAGES` 必须是同一张表 —— 这条守「漏登记」那个老毛病。
///
/// 上一版是手写枚举 + 手写表:漏登记时 `Page::all()`、两条页数对比测试、
/// 桌面注册表全都自洽(它们都从 `PAGES` 反推),只有 `Page::info()` 在运行时崩。
/// 现在两者由 `pages!` 一次展开,这里的逐行对齐是给「绕过宏手写变体 / 手改展开结果」兜底。
#[test]
fn page_variants_and_pages_table_are_the_same_list() {
assert_eq!(Page::ALL.len(), PAGES.len(), "变体数与登记行数不一致");
for (i, (p, info)) in Page::ALL.iter().zip(PAGES.iter()).enumerate() {
assert_eq!(*p, info.page, "第 {i} 行:Page::ALL 与 PAGES 对不上");
assert_eq!(p.info().id, info.id, "第 {i} 行:{p:?} 的 info() 不是它自己那行");
}
// 子分组那一侧同理
assert_eq!(Sub::ALL.len(), SUBS.len(), "子分组变体数与登记行数不一致");
for (i, (s, info)) in Sub::ALL.iter().zip(SUBS.iter()).enumerate() {
assert_eq!(*s, info.sub, "第 {i} 行:Sub::ALL 与 SUBS 对不上");
assert_eq!(s.info().id, info.id, "第 {i} 行:{s:?} 的 info() 不是它自己那行");
}
}
/// `apply_order` 只认**精确 id**:标题 / 侧栏名 / 图标名都不算。
///
/// `layers` / `sparkle` / `image` 是全表里被多页共用的图标名,`Weather` 既是
/// 一页的侧栏名又是另一页的标题 —— 宽匹配会把 `store.json` 里手写的这种字符串
/// 记到别的页头上,顺序改错还不报错。
#[test]
fn apply_order_only_trusts_exact_ids() {
let all: Vec<Page> = Page::all().collect();
let saved = vec!["sparkle".to_string()];
assert_eq!(apply_order(&all, &saved), all, "apply_order 又用上宽匹配了");
let saved = vec!["layers".to_string()];
assert_eq!(apply_order(&all, &saved), all, "apply_order 又用上宽匹配了");
// 宽匹配(`--page` 那条路)照旧:认得出图标名与侧栏名
assert_eq!(Page::from_name("sparkle"), Some(Page::VideoXiami));
assert_eq!(Page::from_name("layers"), Some(Page::IlSpy));
// 精确入口只认 id
assert_eq!(Page::from_id("sparkle"), None);
assert_eq!(Page::from_id("layers"), None);
assert_eq!(Page::from_id(""), None);
// `image` 这个字符串**既是**图片页的 id、又是 Setting › Background 那页的图标名 ——
// 精确匹配落在「真 id」那一页上,和 `--page image` 的结果一致,不算歧义。
assert_eq!(Page::from_id("image"), Some(Page::ImageViewer));
assert_eq!(Page::from_name("image"), Some(Page::ImageViewer));
for p in Page::ALL {
assert_eq!(Page::from_id(p.id()), Some(*p), "{p:?} 的 id 找不回自己");
}
assert_eq!(Page::from_id("FFMPEG"), Some(Page::Ffmpeg));
}
/// `csharp` / `loc` / 副标题这一组字段必须**成对自洽**。
///
/// 这里只查「表内自洽」,**不查 `cs-src/` 的真实行数**:统计口径(数哪些文件、
/// 数非空行还是总行数、对哪个快照)在本仓库里没有依据,`Ffmpeg.cs` 这类文件名与
/// 登记值的差是口径还是过期也判断不了。拿一个猜的口径去比,测试红的时候分不清
/// 是登记过期还是口径不同 —— 那种测试比没有更糟。所以这条只钉住两件能确定的事:
/// `loc == 0` 必须走「新增」,`loc > 0` 必须给出 `.cs` 文件且行数一致。
#[test]
fn csharp_and_loc_are_a_consistent_pair() {
for info in PAGES {
assert!(!info.subtitle.is_empty(), "{} 缺副标题", info.id);
if info.loc == 0 {
assert!(
info.origin().starts_with("新增"),
"{}:loc == 0 表示原版没有这一页,origin() 必须走「新增」那条",
info.id
);
} else {
assert!(
info.csharp.contains(".cs"),
"{}:记了 {} 行,却没给出对应的 C# 文件({})",
info.id,
info.loc,
info.csharp
);
assert!(
info.origin().contains(&info.loc.to_string()),
"{}:origin() 里的行数与 loc 对不上",
info.id
);
}
}
}
}