diff --git a/src/components/ui.rs b/src/components/ui.rs index b43ca5d..04ac33b 100644 --- a/src/components/ui.rs +++ b/src/components/ui.rs @@ -274,3 +274,46 @@ pub fn FilterTabs( } } } + +/// 页面级导航 Tab 组件。 +/// +/// 与 [`FilterTabs`] 的区别: +/// - [`FilterTabs`]:**列表筛选**语义,带平滑滑动底部指示条动画(靠 WASM 端测量 +/// `offsetLeft/offsetWidth`)。适合「全部 / 待审核 / 已通过」这类同质数据过滤。 +/// - `Tabs`:**页面导航**语义,选中态用按钮自身 `border-b-2` 下划线(无动画)。 +/// 适合「数据库状态 / 服务器状态 / SQL 控制台」这类切换到结构完全不同的面板。 +/// +/// 因 Dioxus 0.7 `#[component]` 宏不支持泛型类型参数,value 用 `String`(与 +/// `FilterTabs` 一致);调用方负责枚举 ↔ String 的桥接(见 `SystemTab::as_str`)。 +/// +/// Props: +/// - `items`:`(value, label)` 列表,`value` 是稳定字符串 key,`label` 是展示文案 +/// - `active_value`:当前选中项的 value +/// - `on_change`:切换时回调,传入新选中项的 value +#[component] +pub fn Tabs( + items: Vec<(&'static str, &'static str)>, + active_value: String, + on_change: EventHandler, +) -> Element { + rsx! { + div { class: "flex flex-wrap gap-1 border-b border-paper-border", + for (value, label) in items { + button { + key: "{value}", + class: "px-4 py-2 text-sm font-medium border-b-2 -mb-px transition-colors", + class: if active_value == *value { + "border-paper-accent text-paper-accent" + } else { + "border-transparent text-paper-secondary hover:text-paper-primary" + }, + onclick: { + let v = value.to_string(); + move |_| on_change.call(v.clone()) + }, + {label} + } + } + } + } +} diff --git a/src/pages/admin/system.rs b/src/pages/admin/system.rs index fa18b98..f16b8c1 100644 --- a/src/pages/admin/system.rs +++ b/src/pages/admin/system.rs @@ -5,6 +5,8 @@ use dioxus::prelude::*; +use crate::components::ui::Tabs; + /// 系统管理的 5 个功能 tab。 #[derive(Clone, Copy, PartialEq, Debug)] enum SystemTab { @@ -20,6 +22,33 @@ enum SystemTab { Backup, } +impl SystemTab { + /// 变体 → 稳定字符串 key(用于与基于 String 的 `Tabs` 组件桥接)。 + /// 改这些 key 会破坏潜在的持久化/调试场景,见 `from_str` 的反向映射。 + fn as_str(&self) -> &'static str { + match self { + SystemTab::DbStatus => "db_status", + SystemTab::ServerStatus => "server_status", + SystemTab::SqlConsole => "sql_console", + SystemTab::Export => "export", + SystemTab::Backup => "backup", + } + } + + /// 字符串 key → 变体。未知/空串返回 Err(调用方 fallback 到默认 tab)。 + /// 与 `as_str` 严格对应;大小写敏感。 + fn from_str(s: &str) -> Result { + match s { + "db_status" => Ok(SystemTab::DbStatus), + "server_status" => Ok(SystemTab::ServerStatus), + "sql_console" => Ok(SystemTab::SqlConsole), + "export" => Ok(SystemTab::Export), + "backup" => Ok(SystemTab::Backup), + _ => Err("unknown tab key"), + } + } +} + /// 系统管理入口组件。 #[component] pub fn System() -> Element { @@ -27,33 +56,24 @@ pub fn System() -> Element { // tab 切换无需深链/书签,避免新增路由变体。 let mut active_tab = use_signal(|| SystemTab::DbStatus); - let tabs = [ - ("数据库状态", SystemTab::DbStatus), - ("服务器状态", SystemTab::ServerStatus), - ("SQL 控制台", SystemTab::SqlConsole), - ("数据导出", SystemTab::Export), - ("备份恢复", SystemTab::Backup), - ]; - rsx! { div { class: "space-y-6", h1 { class: "text-2xl font-bold text-paper-primary", "系统管理" } - // 顶部 tab 切换栏 - div { class: "flex flex-wrap gap-1 border-b border-paper-border", - for (label, tab) in tabs { - button { - key: "{tab:?}", - class: "px-4 py-2 text-sm font-medium border-b-2 -mb-px transition-colors", - class: if active_tab() == tab { - "border-paper-accent text-paper-accent" - } else { - "border-transparent text-paper-secondary hover:text-paper-primary" - }, - onclick: move |_| active_tab.set(tab), - {label} - } - } + // 顶部 tab 切换栏:用公共 Tabs 组件(String API,经 as_str/from_str 桥接枚举)。 + Tabs { + items: vec![ + ("db_status", "数据库状态"), + ("server_status", "服务器状态"), + ("sql_console", "SQL 控制台"), + ("export", "数据导出"), + ("backup", "备份恢复"), + ], + active_value: active_tab().as_str().to_string(), + on_change: move |v: String| { + // 未知 key fallback 到默认 tab,保证状态始终有效。 + active_tab.set(SystemTab::from_str(&v).unwrap_or(SystemTab::DbStatus)); + }, } // tab 内容 @@ -1393,3 +1413,41 @@ fn BackupRow(props: BackupRowProps) -> Element { } } } + +#[cfg(test)] +mod tests { + use super::SystemTab; + + #[test] + fn as_str_roundtrips_all_variants() { + // 每个变体经 as_str -> from_str 必须还原为自身。 + for tab in [ + SystemTab::DbStatus, + SystemTab::ServerStatus, + SystemTab::SqlConsole, + SystemTab::Export, + SystemTab::Backup, + ] { + let s = tab.as_str(); + assert_eq!(SystemTab::from_str(s), Ok(tab), "roundtrip failed for {tab:?}"); + } + } + + #[test] + fn as_str_returns_stable_keys() { + // 字符串 key 必须稳定(改 key 会破坏 URL/调试/未来持久化),锁定之。 + assert_eq!(SystemTab::DbStatus.as_str(), "db_status"); + assert_eq!(SystemTab::ServerStatus.as_str(), "server_status"); + assert_eq!(SystemTab::SqlConsole.as_str(), "sql_console"); + assert_eq!(SystemTab::Export.as_str(), "export"); + assert_eq!(SystemTab::Backup.as_str(), "backup"); + } + + #[test] + fn from_str_rejects_unknown_and_empty() { + assert!(SystemTab::from_str("nonsense").is_err()); + assert!(SystemTab::from_str("").is_err()); + // 大小写敏感:不接受大写变体,避免歧义。 + assert!(SystemTab::from_str("DbStatus").is_err()); + } +}