Merge branch 'feature/admin-database-system'
Some checks failed
CI / check (push) Failing after 5m21s
CI / build (push) Has been skipped

抽取 /admin/system 页面级导航 Tab 公共组件并迁移调用方:
- feat(database): SystemTab::as_str/from_str 字符串桥接 + 单测
- feat(ui): Tabs 组件(与 FilterTabs 并列,页面导航语义,无动画)
- refactor(admin): System 组件改用 Tabs,移除内联 tab 与临时 allow
This commit is contained in:
xfy 2026-06-30 14:56:20 +08:00
commit 95fb8d8698
2 changed files with 124 additions and 23 deletions

View File

@ -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<String>,
) -> 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}
}
}
}
}
}

View File

@ -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<SystemTab, &'static str> {
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());
}
}