From c6cb401522346867c94c20bf5fc14e6685d25891 Mon Sep 17 00:00:00 2001 From: xfy Date: Wed, 15 Jul 2026 18:28:39 +0800 Subject: [PATCH] =?UTF-8?q?refactor(admin):=20=E6=8B=86=E5=88=86=20system.?= =?UTF-8?q?rs=20=E4=B8=BA=20system/=20=E7=9B=AE=E5=BD=95=EF=BC=88=E6=8C=89?= =?UTF-8?q?=20tab=20=E5=88=86=E6=96=87=E4=BB=B6=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pages/admin/system.rs 单文件 1792 行装 5 个 tab(数据库状态/服务器状态/SQL控制台/导出/备份恢复),按已有 api/database/ 的对称模式拆成 system/ 目录。 关键前提(已验证):5 个 tab 状态完全独立、互不共享,切换时父组件用 key 强制卸载,零跨文件状态耦合。 拆分结构(与 api/database/mod.rs 全路径风格对称): - system/mod.rs: SystemTab enum + System 入口 + format_bytes(pub(super),3 tab 共用) + tests - system/db_status.rs: DbStatusTab - system/server_status.rs: ServerStatusTab + format_uptime(仅它用) - system/sql_console.rs: SqlConsoleTab(codemirror_bridge 依赖) - system/export.rs: ExportTab + urlencode(wasm32) - system/backup.rs: BackupTab + BackupRow + urlencode_dl(自包含,不再跨文件依赖 export.rs) 路由侧零改动: admin/mod.rs 的 pub mod system + pub use system::System 不变(Rust 自动认 system/mod.rs)。双目标编译 + clippy 严格 + dx check + SystemTab 测试全部通过。 --- src/pages/admin/system.rs | 1792 ----------------------- src/pages/admin/system/backup.rs | 443 ++++++ src/pages/admin/system/db_status.rs | 371 +++++ src/pages/admin/system/export.rs | 141 ++ src/pages/admin/system/mod.rs | 184 +++ src/pages/admin/system/server_status.rs | 339 +++++ src/pages/admin/system/sql_console.rs | 364 +++++ 7 files changed, 1842 insertions(+), 1792 deletions(-) delete mode 100644 src/pages/admin/system.rs create mode 100644 src/pages/admin/system/backup.rs create mode 100644 src/pages/admin/system/db_status.rs create mode 100644 src/pages/admin/system/export.rs create mode 100644 src/pages/admin/system/mod.rs create mode 100644 src/pages/admin/system/server_status.rs create mode 100644 src/pages/admin/system/sql_console.rs diff --git a/src/pages/admin/system.rs b/src/pages/admin/system.rs deleted file mode 100644 index 5208c4b..0000000 --- a/src/pages/admin/system.rs +++ /dev/null @@ -1,1792 +0,0 @@ -//! 后台系统管理页面(数据库 + 服务器状态 + SQL 控制台 + 导出 + 备份)。 -//! -//! 用顶部 tab 切换 5 个功能,tab 状态用 `use_signal`(不深链 / 不走分页路由)。 -//! 各 tab 的实际内容由后续 task 填充,本文件先搭好骨架与 tab 切换。 - -use dioxus::prelude::*; - -use crate::components::skeletons::atoms::SkeletonBox; -use crate::components::skeletons::delayed_skeleton::DelayedSkeleton; -use crate::components::ui::{ - FilterTabs, LoadingButton, BTN_OUTLINE, BTN_PRIMARY_SM, BTN_TEXT_AMBER, BTN_TEXT_RED, -}; - -/// 系统管理的 5 个功能 tab。 -#[derive(Clone, Copy, PartialEq, Debug)] -enum SystemTab { - /// 数据库运行状态(表/连接/死元组/迁移版本)。 - DbStatus, - /// 服务器状态(应用内 + 主机层 CPU/内存/磁盘)。 - ServerStatus, - /// SQL 控制台(全读写 + 护栏)。 - SqlConsole, - /// 数据导出(SQL/CSV 流式下载)。 - Export, - /// 备份恢复(pg_dump + 任务进度)。 - Backup, -} - -impl SystemTab { - /// 变体 → 稳定字符串 key(用于与基于 String 的 `FilterTabs` 组件桥接)。 - /// 改这些 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 { - // tab 状态:默认进第一个 tab(数据库状态)。用 signal 而非 URL query—— - // tab 切换无需深链/书签,避免新增路由变体。 - let mut active_tab = use_signal(|| SystemTab::DbStatus); - - rsx! { - div { class: "w-full max-w-7xl mx-auto space-y-6", - // 页面标题 - div { class: "flex flex-col md:flex-row md:items-end justify-between gap-6 pb-6 border-b border-[var(--color-paper-border)] mb-6", - div { - h1 { class: "text-4xl font-extrabold tracking-tight text-[var(--color-paper-primary)]", - "系统面板" - } - p { class: "text-base text-[var(--color-paper-secondary)] mt-2", - "数据库与服务器诊断" - } - } - } - - // 顶部 tab 切换栏:复用公共 FilterTabs 组件(String API,经 as_str/from_str 桥接枚举)。 - // 视觉与评论页一致:平滑滑动指示条 + 选中文字 text-paper-primary。 - FilterTabs { - 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 内容 - // key 保证切换 tab 时 Dioxus 完全卸载旧组件、重新挂载新组件, - // 避免 hook slot 复用导致 DelayedSkeleton 的 visible 信号残留为 true。 - div { key: "{active_tab().as_str()}", - match active_tab() { - SystemTab::DbStatus => rsx! { - DbStatusTab {} - }, - SystemTab::ServerStatus => rsx! { - ServerStatusTab {} - }, - SystemTab::SqlConsole => rsx! { - SqlConsoleTab {} - }, - SystemTab::Export => rsx! { - ExportTab {} - }, - SystemTab::Backup => rsx! { - BackupTab {} - }, - } - } - } - } -} - -/// 字节数 → 人类可读(如 1.2 MB)。 -fn format_bytes(bytes: i64) -> String { - const UNITS: &[&str] = &["B", "KB", "MB", "GB", "TB"]; - let mut size = bytes as f64; - let mut unit = 0; - while size.abs() >= 1024.0 && unit < UNITS.len() - 1 { - size /= 1024.0; - unit += 1; - } - if unit == 0 { - format!("{} {}", bytes, UNITS[0]) - } else { - format!("{:.2} {}", size, UNITS[unit]) - } -} - -/// 数据库状态 tab:概览卡片 + 表清单 + 索引 Top + 活跃连接。 -/// 手动刷新按钮 + 自动刷新开关(1s/2s/5s/30s/手动,默认手动)。 -#[allow(non_snake_case)] -// status/error/loading 在 spawn/onclick 闭包里 .set(),仅 WASM 前端真正用到; -// server 构建里这些 set 调用都在被剥离的 #[cfg(wasm32)] 块内,故 allow unused_mut。 -#[cfg_attr(not(target_arch = "wasm32"), allow(unused_mut, unused_variables))] -fn DbStatusTab() -> Element { - use crate::api::database::status::DbStatus; - // get_db_status 只在 WASM 前端调用,server 构建时该 server function 的客户端桩不需要导入。 - #[cfg(target_arch = "wasm32")] - use crate::api::database::status::get_db_status; - use crate::components::ui::{ADMIN_CARD_CLASS, ADMIN_TABLE_CLASS}; - - // Signal 是 Copy,可在多个 spawn/effect 中捕获同一副本;set 走内部可变(&self)。 - let mut status = use_signal(|| Option::::None); - let mut loading = use_signal(|| true); - let mut error = use_signal(|| Option::::None); - // 自动刷新间隔(秒);None = 手动。DB 查询有成本,最低 1s。 - let mut refresh_interval: Signal> = use_signal(|| None); - - // 数据加载:WASM 前端 spawn 请求,SSR 直接结束加载。 - // 因 Signal 是 Copy,每次 spawn 各自捕获副本即可,无需共享闭包。 - let mut load_once = move || { - loading.set(true); - #[cfg(target_arch = "wasm32")] - { - let t0 = crate::utils::time::now_millis(); - web_sys::console::log_1(&format!("[DEBUG-db] fetch start at {t0}").into()); - spawn(async move { - match get_db_status().await { - Ok(s) => { - let t1 = crate::utils::time::now_millis(); - web_sys::console::log_1( - &format!("[DEBUG-db] fetch done at {t1} (Δ={}ms)", t1 - t0).into(), - ); - status.set(Some(s)); - error.set(None); - } - Err(e) => error.set(Some(e.to_string())), - } - loading.set(false); - }); - } - #[cfg(not(target_arch = "wasm32"))] - { - loading.set(false); - } - }; - - // 首次加载 - use_effect(move || { - load_once(); - }); - - // 自动刷新:使用官方推荐模式——一个永不重建的长生命周期 loop,在每次循环 - // 内部读取 refresh_interval 的当前值,自然响应间隔切换。 - // 旧做法在闭包同步体内读 status/loading/error signal,导致这些 signal 每次 - // .set() 后都触发 use_future 重建,产生多个并发 loop(请求爆炸)。 - use_future(move || async move { - #[cfg(target_arch = "wasm32")] - { - loop { - // 每次循环读最新 interval(signal 的 Copy 语义,直接调用即可)。 - let secs = refresh_interval().unwrap_or(0); - if secs == 0 { - // 手动模式:短暂 yield,让事件循环呼吸,避免忙等; - // 用户切换到自动模式后最多等 200ms 即响应。 - crate::utils::time::sleep_ms(200).await; - continue; - } - crate::utils::time::sleep_ms(secs * 1000).await; - // 二次检查:sleep 期间用户可能切回手动。 - if refresh_interval().is_none() { - continue; - } - loading.set(true); - spawn(async move { - match get_db_status().await { - Ok(s) => { - status.set(Some(s)); - error.set(None); - } - Err(e) => error.set(Some(e.to_string())), - } - loading.set(false); - }); - } - } - #[cfg(not(target_arch = "wasm32"))] - { - let _ = (status, loading, error, refresh_interval); - } - }); - - // Option 非 Copy,读出来克隆一份供 rsx 消费。 - let current = status.read().clone(); - - rsx! { - div { class: "space-y-6", - // 工具栏:刷新按钮 + 自动刷新开关 - div { class: "flex items-center justify-between", - LoadingButton { - label: "刷新".to_string(), - loading: loading(), - variant: "sm", - onclick: move |_| { - loading.set(true); - #[cfg(target_arch = "wasm32")] - { - spawn(async move { - match get_db_status().await { - Ok(s) => { - status.set(Some(s)); - error.set(None); - } - Err(e) => error.set(Some(e.to_string())), - } - loading.set(false); - }); - } - #[cfg(not(target_arch = "wasm32"))] - { - loading.set(false); - } - }, - } - div { class: "flex items-center gap-2", - span { class: "text-sm text-paper-secondary", "自动刷新" } - select { - class: "text-sm border border-paper-border rounded px-2 py-1 bg-paper-theme text-paper-primary", - value: "{refresh_interval().map(|s| s.to_string()).unwrap_or_default()}", - onchange: move |e| { - let v = e.value(); - refresh_interval - .set( - match v.as_str() { - "1" => Some(1), - "2" => Some(2), - "5" => Some(5), - "30" => Some(30), - _ => None, - }, - ); - }, - option { value: "", "手动" } - option { value: "1", "1s" } - option { value: "2", "2s" } - option { value: "5", "5s" } - option { value: "30", "30s" } - } - } - } - - if let Some(err) = error.read().clone() { - div { class: "bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4 text-sm text-red-700 dark:text-red-300", - "加载失败:{err}" - } - } else if let Some(s) = current { - // 概览卡片 - div { class: "grid grid-cols-2 md:grid-cols-4 gap-4", - div { class: "{ADMIN_CARD_CLASS} p-4", - p { class: "text-xs text-paper-secondary", "数据库总大小" } - p { class: "mt-1 text-lg font-semibold text-paper-primary", - "{format_bytes(s.db_size_bytes)}" - } - } - div { class: "{ADMIN_CARD_CLASS} p-4", - p { class: "text-xs text-paper-secondary", "连接数" } - p { class: "mt-1 text-lg font-semibold text-paper-primary", - "{s.total_connections} / {s.max_connections}" - } - } - div { class: "{ADMIN_CARD_CLASS} p-4", - p { class: "text-xs text-paper-secondary", "表数量" } - p { class: "mt-1 text-lg font-semibold text-paper-primary", - "{s.tables.len()}" - } - } - div { class: "{ADMIN_CARD_CLASS} p-4", - p { class: "text-xs text-paper-secondary", "迁移版本" } - p { class: "mt-1 text-lg font-semibold text-paper-primary truncate", - {s.migration_version.clone().unwrap_or_else(|| "—".to_string())} - } - } - } - - // 表清单(小表显示真实行数;大表回退估算,行数前标 ~) - div { class: "{ADMIN_TABLE_CLASS}", - div { class: "px-4 py-3 border-b border-paper-border text-sm font-medium text-paper-primary", - "表清单(行数:小表为真实值,大表标 ~ 为估算)" - } - div { class: "overflow-x-auto", - table { class: "w-full text-sm", - thead { - tr { class: "border-b border-paper-border text-left text-paper-secondary", - th { class: "px-4 py-2 font-medium", "表名" } - th { class: "px-4 py-2 font-medium text-right", - "行数" - } - th { class: "px-4 py-2 font-medium text-right", - "表大小" - } - th { class: "px-4 py-2 font-medium text-right", - "索引大小" - } - th { class: "px-4 py-2 font-medium text-right", - "总大小" - } - th { class: "px-4 py-2 font-medium text-right", - "死元组" - } - } - } - tbody { - for t in s.tables.iter() { - tr { class: "border-b border-paper-border last:border-0 hover:bg-paper-entry transition-colors", - td { class: "px-4 py-2 font-mono text-paper-primary", - "{t.name}" - } - td { class: "px-4 py-2 text-right text-paper-secondary", - if t.row_count_estimated { - "~{t.row_count}" - } else { - "{t.row_count}" - } - } - td { class: "px-4 py-2 text-right text-paper-secondary", - "{format_bytes(t.table_size_bytes)}" - } - td { class: "px-4 py-2 text-right text-paper-secondary", - "{format_bytes(t.index_size_bytes)}" - } - td { class: "px-4 py-2 text-right text-paper-primary font-medium", - "{format_bytes(t.total_size_bytes)}" - } - td { class: "px-4 py-2 text-right text-paper-secondary", - "{t.dead_tuples}" - } - } - } - } - } - } - } - - // 索引占用 Top - if !s.top_indexes.is_empty() { - div { class: "{ADMIN_TABLE_CLASS}", - div { class: "px-4 py-3 border-b border-paper-border text-sm font-medium text-paper-primary", - "索引占用 Top 10" - } - div { class: "overflow-x-auto", - table { class: "w-full text-sm", - thead { - tr { class: "border-b border-paper-border text-left text-paper-secondary", - th { class: "px-4 py-2 font-medium", "索引名" } - th { class: "px-4 py-2 font-medium", "所属表" } - th { class: "px-4 py-2 font-medium text-right", - "大小" - } - } - } - tbody { - for i in s.top_indexes.iter() { - tr { class: "border-b border-paper-border last:border-0 hover:bg-paper-entry transition-colors", - td { class: "px-4 py-2 font-mono text-paper-primary", - "{i.name}" - } - td { class: "px-4 py-2 font-mono text-paper-secondary", - "{i.table_name}" - } - td { class: "px-4 py-2 text-right text-paper-secondary", - "{format_bytes(i.size_bytes)}" - } - } - } - } - } - } - } - } - - // 活跃连接 - div { class: "{ADMIN_TABLE_CLASS}", - div { class: "px-4 py-3 border-b border-paper-border text-sm font-medium text-paper-primary", - "活跃连接({s.active_connections.len()})" - } - div { class: "overflow-x-auto", - table { class: "w-full text-sm", - thead { - tr { class: "border-b border-paper-border text-left text-paper-secondary", - th { class: "px-4 py-2 font-medium", "PID" } - th { class: "px-4 py-2 font-medium", "用户" } - th { class: "px-4 py-2 font-medium", "状态" } - th { class: "px-4 py-2 font-medium text-right", - "时长(秒)" - } - th { class: "px-4 py-2 font-medium", "查询" } - } - } - tbody { - for c in s.active_connections.iter() { - tr { class: "border-b border-paper-border last:border-0 hover:bg-paper-entry transition-colors", - td { class: "px-4 py-2 text-paper-secondary", - "{c.pid}" - } - td { class: "px-4 py-2 text-paper-secondary", - "{c.user}" - } - td { class: "px-4 py-2 text-paper-secondary", - {c.state.clone().unwrap_or_else(|| "—".to_string())} - } - td { class: "px-4 py-2 text-right text-paper-secondary", - { - c.query_duration_secs - .map(|d| format!("{:.1}", d)) - .unwrap_or_else(|| "—".to_string()) - } - } - td { class: "px-4 py-2 font-mono text-xs text-paper-secondary max-w-md truncate", - {c.query.clone().unwrap_or_else(|| "—".to_string())} - } - } - } - } - } - } - } - } else if loading() { - // 首次加载骨架屏:延迟 200ms 显示,避免快速加载闪烁。 - DelayedSkeleton { - div { class: "space-y-4", - // 概览卡片骨架 - div { class: "grid grid-cols-2 md:grid-cols-4 gap-4", - for _ in 0..4 { - div { class: "rounded-2xl bg-paper-entry border border-paper-border p-4 space-y-2", - SkeletonBox { class: "h-3 w-16 rounded" } - SkeletonBox { class: "h-6 w-24 rounded" } - } - } - } - // 表清单骨架 - div { class: "rounded-2xl bg-paper-entry border border-paper-border overflow-hidden", - div { class: "px-4 py-3 border-b border-paper-border", - SkeletonBox { class: "h-4 w-40 rounded" } - } - for _ in 0..5 { - div { class: "flex justify-between px-4 py-3 border-b border-paper-border last:border-0", - SkeletonBox { class: "h-4 w-24 rounded" } - SkeletonBox { class: "h-4 w-16 rounded" } - } - } - } - } - } - } else { - div { class: "text-paper-secondary py-8", "暂无数据" } - } - } - } -} - -/// 秒数 → 人类可读运行时间(如 1d 2h 3m)。 -fn format_uptime(secs: u64) -> String { - let d = secs / 86400; - let h = (secs % 86400) / 3600; - let m = (secs % 3600) / 60; - if d > 0 { - format!("{d}d {h}h {m}m") - } else if h > 0 { - format!("{h}h {m}m") - } else if m > 0 { - format!("{m}m") - } else { - format!("{secs}s") - } -} - -/// 服务器状态 tab:应用内指标(连接池/会话/缓存命中率)+ 主机层(CPU/内存/磁盘)。 -/// 手动刷新 + 自动刷新开关(500ms/1s/2s/5s/手动,默认手动)。 -/// 主机层数据由后台 500ms 采样,前端轮询只读快照零成本,故可高频。 -#[allow(non_snake_case)] -#[cfg_attr(not(target_arch = "wasm32"), allow(unused_mut, unused_variables))] -fn ServerStatusTab() -> Element { - #[cfg(target_arch = "wasm32")] - use crate::api::database::system_status::get_server_status; - use crate::api::database::system_status::ServerStatus; - use crate::components::ui::{ADMIN_CARD_CLASS, ADMIN_TABLE_CLASS}; - - let mut status = use_signal(|| Option::::None); - let mut loading = use_signal(|| true); - let mut error = use_signal(|| Option::::None); - // 自动刷新间隔(毫秒);None = 手动。主机层后台采样,前端可高频轮询。 - let mut refresh_ms: Signal> = use_signal(|| None); - - let mut load_once = move || { - loading.set(true); - #[cfg(target_arch = "wasm32")] - { - spawn(async move { - match get_server_status().await { - Ok(s) => { - status.set(Some(s)); - error.set(None); - } - Err(e) => error.set(Some(e.to_string())), - } - loading.set(false); - }); - } - #[cfg(not(target_arch = "wasm32"))] - { - loading.set(false); - } - }; - - use_effect(move || { - load_once(); - }); - - // 自动刷新:同 DbStatusTab,采用官方推荐的单一长生命周期 loop 模式。 - // 闭包体内不读任何 signal(避免隐式依赖追踪导致重建),loop 内部实时读取。 - use_future(move || async move { - #[cfg(target_arch = "wasm32")] - { - loop { - let ms = refresh_ms().unwrap_or(0); - if ms == 0 { - crate::utils::time::sleep_ms(200).await; - continue; - } - crate::utils::time::sleep_ms(ms).await; - if refresh_ms().is_none() { - continue; - } - loading.set(true); - spawn(async move { - match get_server_status().await { - Ok(s) => { - status.set(Some(s)); - error.set(None); - } - Err(e) => error.set(Some(e.to_string())), - } - loading.set(false); - }); - } - } - #[cfg(not(target_arch = "wasm32"))] - { - let _ = (status, loading, error, refresh_ms); - } - }); - - let current = status.read().clone(); - // rsx 不支持格式说明符({:.1}),也不允许在 for 循环体内 let,故预格式化所有展示值。 - let cpu_pct = current - .as_ref() - .map(|s| format!("{:.1}%", s.host.cpu_usage)) - .unwrap_or_default(); - let load_1 = current - .as_ref() - .map(|s| format!("{:.2}", s.host.load_avg_1)) - .unwrap_or_default(); - // 缓存表预格式化:把每行需要展示的值都算好字符串,避免在 rsx 里做格式化。 - let cache_rows: Vec<(String, u64, u64, u64, String)> = current - .as_ref() - .map(|s| { - s.caches - .iter() - .map(|c| { - ( - c.name.clone(), - c.entry_count, - c.hits, - c.misses, - format!("{:.1}%", c.hit_rate * 100.0), - ) - }) - .collect() - }) - .unwrap_or_default(); - - rsx! { - div { class: "space-y-6", - div { class: "flex items-center justify-between", - LoadingButton { - label: "刷新".to_string(), - loading: loading(), - variant: "sm", - onclick: move |_| { - loading.set(true); - #[cfg(target_arch = "wasm32")] - { - spawn(async move { - match get_server_status().await { - Ok(s) => { - status.set(Some(s)); - error.set(None); - } - Err(e) => error.set(Some(e.to_string())), - } - loading.set(false); - }); - } - #[cfg(not(target_arch = "wasm32"))] - { - loading.set(false); - } - }, - } - div { class: "flex items-center gap-2", - span { class: "text-sm text-paper-secondary", "自动刷新" } - select { - class: "text-sm border border-paper-border rounded px-2 py-1 bg-paper-theme text-paper-primary", - value: "{refresh_ms().map(|s| s.to_string()).unwrap_or_default()}", - onchange: move |e| { - let v = e.value(); - refresh_ms - .set( - match v.as_str() { - "500" => Some(500), - "1000" => Some(1000), - "2000" => Some(2000), - "5000" => Some(5000), - _ => None, - }, - ); - }, - option { value: "", "手动" } - option { value: "500", "500ms" } - option { value: "1000", "1s" } - option { value: "2000", "2s" } - option { value: "5000", "5s" } - } - } - } - - if let Some(err) = error.read().clone() { - div { class: "bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4 text-sm text-red-700 dark:text-red-300", - "加载失败:{err}" - } - } else if let Some(s) = current { - // 应用内指标卡片 - div { class: "grid grid-cols-2 md:grid-cols-4 gap-4", - div { class: "{ADMIN_CARD_CLASS} p-4", - p { class: "text-xs text-paper-secondary", "运行时间" } - p { class: "mt-1 text-lg font-semibold text-paper-primary", - "{format_uptime(s.uptime_secs)}" - } - } - div { class: "{ADMIN_CARD_CLASS} p-4", - p { class: "text-xs text-paper-secondary", "DB 连接池" } - p { class: "mt-1 text-lg font-semibold text-paper-primary", - "{s.pool_size} / {s.pool_max_size}" - } - p { class: "text-xs text-paper-secondary", - "空闲 {s.pool_available} · 等待 {s.pool_waiting}" - } - } - div { class: "{ADMIN_CARD_CLASS} p-4", - p { class: "text-xs text-paper-secondary", "活跃会话" } - p { class: "mt-1 text-lg font-semibold text-paper-primary", - "{s.active_sessions}" - } - } - div { class: "{ADMIN_CARD_CLASS} p-4", - p { class: "text-xs text-paper-secondary", "CPU" } - p { class: "mt-1 text-lg font-semibold text-paper-primary", - "{cpu_pct}" - } - } - } - - // 主机层指标卡片 - div { class: "grid grid-cols-2 md:grid-cols-4 gap-4", - div { class: "{ADMIN_CARD_CLASS} p-4", - p { class: "text-xs text-paper-secondary", "内存" } - p { class: "mt-1 text-lg font-semibold text-paper-primary", - "{format_bytes(s.host.used_memory as i64)} / {format_bytes(s.host.total_memory as i64)}" - } - } - div { class: "{ADMIN_CARD_CLASS} p-4", - p { class: "text-xs text-paper-secondary", "磁盘" } - p { class: "mt-1 text-lg font-semibold text-paper-primary", - "{format_bytes((s.host.disk_total - s.host.disk_available) as i64)} / {format_bytes(s.host.disk_total as i64)}" - } - } - div { class: "{ADMIN_CARD_CLASS} p-4", - p { class: "text-xs text-paper-secondary", "Load (1m)" } - p { class: "mt-1 text-lg font-semibold text-paper-primary", - "{load_1}" - } - } - div { class: "{ADMIN_CARD_CLASS} p-4", - p { class: "text-xs text-paper-secondary", "系统" } - p { class: "mt-1 text-sm font-medium text-paper-primary truncate", - "{s.host.os_name}" - } - } - } - - // 缓存命中率表 - div { class: "{ADMIN_TABLE_CLASS}", - div { class: "px-4 py-3 border-b border-paper-border text-sm font-medium text-paper-primary", - "缓存命中率" - } - div { class: "overflow-x-auto", - table { class: "w-full text-sm", - thead { - tr { class: "border-b border-paper-border text-left text-paper-secondary", - th { class: "px-4 py-2 font-medium", "缓存" } - th { class: "px-4 py-2 font-medium text-right", - "条目" - } - th { class: "px-4 py-2 font-medium text-right", - "命中" - } - th { class: "px-4 py-2 font-medium text-right", - "未命中" - } - th { class: "px-4 py-2 font-medium text-right", - "命中率" - } - } - } - tbody { - for (name, entry_count, hits, misses, rate_pct) in cache_rows.iter() { - tr { class: "border-b border-paper-border last:border-0 hover:bg-paper-entry transition-colors", - td { class: "px-4 py-2 text-paper-primary", - "{name}" - } - td { class: "px-4 py-2 text-right text-paper-secondary", - "{entry_count}" - } - td { class: "px-4 py-2 text-right text-paper-secondary", - "{hits}" - } - td { class: "px-4 py-2 text-right text-paper-secondary", - "{misses}" - } - td { class: "px-4 py-2 text-right text-paper-primary font-medium", - "{rate_pct}" - } - } - } - } - } - } - } - } else if loading() { - // 首次加载骨架屏:延迟 200ms 显示,避免快速加载闪烁。 - DelayedSkeleton { - div { class: "space-y-4", - // 应用内指标卡片骨架 - div { class: "grid grid-cols-2 md:grid-cols-4 gap-4", - for _ in 0..4 { - div { class: "rounded-2xl bg-paper-entry border border-paper-border p-4 space-y-2", - SkeletonBox { class: "h-3 w-16 rounded" } - SkeletonBox { class: "h-6 w-24 rounded" } - } - } - } - // 主机层指标卡片骨架 - div { class: "grid grid-cols-2 md:grid-cols-4 gap-4", - for _ in 0..4 { - div { class: "rounded-2xl bg-paper-entry border border-paper-border p-4 space-y-2", - SkeletonBox { class: "h-3 w-16 rounded" } - SkeletonBox { class: "h-6 w-24 rounded" } - } - } - } - // 缓存命中率表骨架 - div { class: "rounded-2xl bg-paper-entry border border-paper-border overflow-hidden", - div { class: "px-4 py-3 border-b border-paper-border", - SkeletonBox { class: "h-4 w-24 rounded" } - } - for _ in 0..4 { - div { class: "flex justify-between px-4 py-3 border-b border-paper-border last:border-0", - SkeletonBox { class: "h-4 w-20 rounded" } - SkeletonBox { class: "h-4 w-12 rounded" } - } - } - } - } - } - } else { - div { class: "text-paper-secondary py-8", "暂无数据" } - } - } - } -} - -/// SQL 控制台 tab:CodeMirror 编辑器(SQL 高亮/补全/Vim)+ 4 道护栏 + 结果表 + EXPLAIN。 -/// -/// 护栏 4(前端二次确认):提交写操作前弹窗确认。 -#[allow(non_snake_case)] -#[cfg_attr(not(target_arch = "wasm32"), allow(unused_mut, unused_variables))] -fn SqlConsoleTab() -> Element { - #[cfg(target_arch = "wasm32")] - use crate::api::database::schema::get_db_schema; - use crate::api::database::sql_console::SqlResult; - #[cfg(target_arch = "wasm32")] - use crate::api::database::sql_console::{execute_sql, ExecuteSqlOpts}; - #[cfg(target_arch = "wasm32")] - use crate::codemirror_bridge; - use crate::components::sql_result_table::SqlResultTable; - use crate::components::ui::ADMIN_TABLE_CLASS; - // use_resolved_theme 两种构建都用:resolved() 在 wasm 块内消费,非 wasm 仅引用避免警告。 - use crate::theme::use_resolved_theme; - #[cfg(target_arch = "wasm32")] - use crate::theme::ResolvedTheme; - - let resolved = use_resolved_theme(); - let sql_text = use_signal(String::new); - let mut result = use_signal(|| Option::::None); - let mut error = use_signal(|| Option::::None); - let mut running = use_signal(|| false); - // 选项 toggles - let mut with_explain = use_signal(|| false); - let mut allow_multi = use_signal(|| false); - let mut confirm_dangerous = use_signal(|| false); - // resolved/sql_text 仅在 wasm32 块内使用;server 构建时显式引用避免 unused 警告。 - #[cfg(not(target_arch = "wasm32"))] - let _ = (&resolved, &sql_text); - - // CodeMirror 实例句柄(仅 WASM)。 - #[cfg(target_arch = "wasm32")] - let mut editor_handle: Signal> = use_signal(|| None); - - // 执行 SQL 的核心逻辑:抽成独立闭包,按钮 onclick 与 Ctrl+Enter 快捷键共用。 - // 所有捕获都是 Copy 的 Signal,故每次调用读取最新值,闭包本身是 Fn 可重复调用。 - // 用两个独立闭包(execute_for_editor / run_sql)避免 move 单一所有权冲突—— - // 它们捕获相同的 Copy signal,行为完全等价。 - #[cfg(target_arch = "wasm32")] - let mut execute_for_editor = move || { - running.set(true); - error.set(None); - let sql = sql_text.read().clone(); - let opts = ExecuteSqlOpts { - allow_multi: allow_multi(), - confirm_dangerous: confirm_dangerous(), - with_explain: with_explain(), - }; - // 护栏 4:写操作前端二次确认(简单判断:含 UPDATE/DELETE/INSERT/ALTER/DROP/TRUNCATE/CREATE 关键词) - let lower = sql.to_lowercase(); - let looks_write = [ - "update ", - "delete ", - "insert ", - "alter ", - "drop ", - "truncate ", - "create ", - ] - .iter() - .any(|k| lower.contains(k)); - if looks_write && !confirm_dangerous() { - // 简单提示;真正的高危放行靠 confirm_dangerous 开关 - let confirmed = web_sys::window().and_then(|w| { - w.confirm_with_message( - "这是写操作(修改数据/结构),确认执行?\n\n高危操作(DROP/TRUNCATE/ALTER)还需勾选「我了解后果」。", - ) - .ok() - }); - if confirmed != Some(true) { - running.set(false); - return; - } - } - spawn(async move { - match execute_sql(sql, opts).await { - Ok(r) => result.set(Some(r)), - Err(e) => error.set(Some(e.to_string())), - } - running.set(false); - }); - }; - - // 捕获当前 scope id:Ctrl+Enter 从 CodeMirror 的 JS 事件处理中触发时, - // dioxus scope stack 为空,spawn() 内部 current_scope_id().unwrap() 会 panic。 - // 在 effect 里用 Runtime::in_scope 重建 scope 上下文,保证 spawn 找得到 origin。 - #[cfg(target_arch = "wasm32")] - let scope_id = dioxus::core::Runtime::current() - .try_current_scope_id() - .unwrap_or(dioxus::core::ScopeId::ROOT); - - // 初始化 CodeMirror + 拉取 schema 注入补全。仅 WASM。 - #[cfg(target_arch = "wasm32")] - { - use wasm_bindgen::closure::Closure; - use_effect(move || { - if editor_handle.read().is_some() { - return; - } - let mut text = sql_text; - let on_change = Closure::new(move |v: String| { - text.set(v); - }); - let on_ready = Closure::new(|| {}); - // Ctrl/Cmd+Enter 触发执行(与按钮共用同一套资源/护栏逻辑)。 - // 从 CodeMirror JS 事件触发时无 dioxus scope,必须用 in_scope 重建。 - // 刻意用闭包 `|| execute_for_editor()` 而非直接传 `execute_for_editor`: - // 后者会 move 闭包值进 in_scope,导致第二次快捷键触发时 use-after-move - // (Closure::new 要求 Fn 可重复调用)。闭包包装走借用调用,每次都读最新值。 - #[allow(clippy::redundant_closure)] - let on_run_shortcut = Closure::new(move || { - dioxus::core::Runtime::current().in_scope(scope_id, || execute_for_editor()); - }); - - let theme_name = if resolved() == ResolvedTheme::Dark { - "dark" - } else { - "light" - }; - let opts = codemirror_bridge::EditorOptions::new(); - opts.set_language("sql"); - opts.set_theme(theme_name); - opts.set_vim(true); - opts.set_on_change(&on_change); - opts.set_on_ready(&on_ready); - opts.set_on_run_shortcut(&on_run_shortcut); - - if let Ok(Some(inst)) = codemirror_bridge::get_module().create("sql-editor", &opts) { - let handle = codemirror_bridge::EditorHandle::new( - inst, - on_change, - on_ready, - on_run_shortcut, - ); - editor_handle.set(Some(handle)); - } - - // 异步拉取 schema 注入补全 - spawn(async move { - if let Ok(schema) = get_db_schema().await { - // SqlSchema 是 serde 类型:先用 serde_wasm_bindgen 转 JsValue, - // 再传给 CodeMirror(extern set_schema 只接受 &JsValue)。 - if let Ok(js) = serde_wasm_bindgen::to_value(&schema) { - if let Some(h) = editor_handle.read().as_ref() { - h.instance().set_schema(&js); - } - } - } - }); - }); - - // 主题切换(含 System 模式下系统偏好变化)时同步编辑器主题。 - // resolved 是 theme + system_dark 的派生 memo,任一变化都自动触发此 effect。 - use_effect(move || { - let r = resolved(); - if let Some(h) = editor_handle.read().as_ref() { - h.instance().set_theme(if r == ResolvedTheme::Dark { - "dark" - } else { - "light" - }); - } - }); - } - - // 执行 SQL:按钮 onclick 用的闭包。wasm 下复制 execute_for_editor 的逻辑, - // server 下仅复位 running(无网络层)。两处闭包捕获相同的 Copy signal。 - let mut run_sql = move || { - running.set(true); - error.set(None); - #[cfg(target_arch = "wasm32")] - { - let sql = sql_text.read().clone(); - let opts = ExecuteSqlOpts { - allow_multi: allow_multi(), - confirm_dangerous: confirm_dangerous(), - with_explain: with_explain(), - }; - // 护栏 4:写操作前端二次确认(简单判断:含 UPDATE/DELETE/INSERT/ALTER/DROP/TRUNCATE/CREATE 关键词) - let lower = sql.to_lowercase(); - let looks_write = [ - "update ", - "delete ", - "insert ", - "alter ", - "drop ", - "truncate ", - "create ", - ] - .iter() - .any(|k| lower.contains(k)); - if looks_write && !confirm_dangerous() { - // 简单提示;真正的高危放行靠 confirm_dangerous 开关 - let confirmed = web_sys::window().and_then(|w| { - w.confirm_with_message( - "这是写操作(修改数据/结构),确认执行?\n\n高危操作(DROP/TRUNCATE/ALTER)还需勾选「我了解后果」。", - ) - .ok() - }); - if confirmed != Some(true) { - running.set(false); - return; - } - } - spawn(async move { - match execute_sql(sql, opts).await { - Ok(r) => result.set(Some(r)), - Err(e) => error.set(Some(e.to_string())), - } - running.set(false); - }); - } - #[cfg(not(target_arch = "wasm32"))] - { - running.set(false); - } - }; - - let current_result = result.read().clone(); - let current_error = error.read().clone(); - let elapsed = current_result.as_ref().map(|r| r.elapsed_ms).unwrap_or(0); - let affected = current_result - .as_ref() - .map(|r| r.affected_rows) - .unwrap_or(0); - let stmt_type = current_result - .as_ref() - .map(|r| r.statement_type.clone()) - .unwrap_or_default(); - let truncated = current_result - .as_ref() - .map(|r| r.truncated) - .unwrap_or(false); - let explain = current_result.as_ref().and_then(|r| r.explain.clone()); - - rsx! { - div { class: "space-y-5", - // 编辑器卡片:标题栏 + CodeMirror 一体化。 - div { class: "rounded-2xl overflow-hidden border border-[var(--color-paper-border)] bg-[var(--color-paper-entry)]", - // 标题栏 - div { class: "flex justify-between items-center px-4 py-2.5 border-b border-[var(--color-paper-border)] bg-[var(--color-paper-theme)]", - div { class: "flex items-center gap-2", - span { class: "w-2 h-2 rounded-full bg-[var(--color-paper-accent)]" } - span { class: "font-mono text-sm font-semibold text-[var(--color-paper-primary)]", - "SQL" - } - } - span { class: "text-xs text-[var(--color-paper-tertiary)] font-mono", - "⌘↵ 执行" - } - } - // CodeMirror 容器:用 flex 让 .cm-editor(flex:1) 填满整个高度, - // 避免编辑器塌缩到内容高度、底部透出容器背景造成上下色差。 - div { - id: "sql-editor", - style: "min-height: 280px; display: flex; flex-direction: column", - } - } - - // 工具条:执行按钮 + 普通/危险选项分层 - div { class: "flex flex-wrap items-center gap-x-4 gap-y-3", - LoadingButton { - label: "执行".to_string(), - loading: running(), - onclick: move |_| run_sql(), - } - // 普通选项 - label { class: "flex items-center gap-1.5 text-sm text-[var(--color-paper-secondary)] cursor-pointer", - input { - r#type: "checkbox", - class: "rounded border-[var(--color-paper-border)]", - checked: with_explain(), - onchange: move |e| with_explain.set(e.checked()), - } - "EXPLAIN" - } - label { class: "flex items-center gap-1.5 text-sm text-[var(--color-paper-secondary)] cursor-pointer", - input { - r#type: "checkbox", - class: "rounded border-[var(--color-paper-border)]", - checked: allow_multi(), - onchange: move |e| allow_multi.set(e.checked()), - } - "允许多语句" - } - // 危险选项:视觉分隔 + 红色语义 - span { class: "w-px h-4 bg-[var(--color-paper-border)] mx-1" } - label { class: "flex items-center gap-1.5 text-sm text-red-600 dark:text-red-400 cursor-pointer", - input { - r#type: "checkbox", - class: "rounded border-red-300 dark:border-red-700", - checked: confirm_dangerous(), - onchange: move |e| confirm_dangerous.set(e.checked()), - } - "我了解后果(DROP/TRUNCATE/ALTER)" - } - } - - // 错误 - if let Some(err) = current_error { - div { class: "bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-2xl p-3 text-sm text-red-700 dark:text-red-300", - "{err}" - } - } - - // 结果摘要(徽章式) - if current_result.is_some() { - div { class: "flex flex-wrap items-center gap-2", - if !stmt_type.is_empty() { - span { class: "inline-flex items-center px-2.5 py-1 rounded-full bg-[var(--color-paper-accent-soft)] text-[var(--color-paper-secondary)] text-xs font-medium", - "{stmt_type}" - } - } - if affected > 0 { - span { class: "inline-flex items-center px-2.5 py-1 rounded-full bg-[var(--color-paper-accent-soft)] text-[var(--color-paper-secondary)] text-xs font-medium", - "影响 {affected} 行" - } - } - span { class: "inline-flex items-center px-2.5 py-1 rounded-full bg-[var(--color-paper-accent-soft)] text-[var(--color-paper-secondary)] text-xs font-medium", - "{elapsed}ms" - } - if truncated { - span { class: "inline-flex items-center px-2.5 py-1 rounded-full bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-300 text-xs font-medium", - "结果超过 500 行,已截断" - } - } - } - } - - // 结果表格 - if let Some(res) = ¤t_result { - if !res.rows.is_empty() { - SqlResultTable { result: res.clone() } - } else if res.statement_type.to_uppercase().contains("SELECT") { - // 有结果但无行:SELECT 返回空集的友好提示 - div { class: "{ADMIN_TABLE_CLASS} px-4 py-8 text-center text-sm text-[var(--color-paper-tertiary)]", - "查询成功,无返回行" - } - } - } - - // EXPLAIN 输出 - if let Some(explain) = explain { - div { class: "{ADMIN_TABLE_CLASS}", - div { class: "px-4 py-2 border-b border-paper-border text-sm font-medium text-paper-primary", - "执行计划" - } - pre { class: "p-4 text-xs font-mono text-paper-secondary overflow-x-auto whitespace-pre m-0", - "{explain}" - } - } - } - } - } -} - -/// 数据导出 tab:按表/按查询导出 SQL/CSV,走 Axum 流式下载。 -#[allow(non_snake_case)] -fn ExportTab() -> Element { - use crate::components::ui::ADMIN_CARD_CLASS; - // 导出模式:"table" / "query" - let mut mode = use_signal(|| "table".to_string()); - let mut table_name = use_signal(String::new); - let mut query = use_signal(String::new); - let mut format = use_signal(|| "csv".to_string()); - let mut include_columns = use_signal(|| true); - - // 触发下载:构造 GET /api/database/export?... URL 并打开 - let do_export = move || { - #[cfg(target_arch = "wasm32")] - { - let source = if mode().as_str() == "table" { - format!("table:{}", table_name.read().trim()) - } else { - format!("query:{}", query.read()) - }; - let url = format!( - "/api/database/export?source={}&format={}&include_columns={}", - urlencode(&source), - format(), - include_columns(), - ); - if let Some(window) = web_sys::window() { - let _ = window.open_with_url(&url); - } - } - }; - - rsx! { - div { class: "space-y-4", - div { class: "{ADMIN_CARD_CLASS} p-4 space-y-4", - // 模式选择 - div { class: "flex items-center gap-4", - label { class: "flex items-center gap-2 text-sm text-paper-primary", - input { - r#type: "radio", - name: "export-mode", - checked: mode() == "table", - onchange: move |_| mode.set("table".to_string()), - } - "按表导出" - } - label { class: "flex items-center gap-2 text-sm text-paper-primary", - input { - r#type: "radio", - name: "export-mode", - checked: mode() == "query", - onchange: move |_| mode.set("query".to_string()), - } - "按查询导出" - } - } - - // 表名输入 - if mode().as_str() == "table" { - div { - label { class: "block text-sm text-paper-secondary mb-1", "表名" } - input { - r#type: "text", - class: "w-full px-3 py-2 text-sm border border-paper-border rounded bg-paper-theme text-paper-primary font-mono", - placeholder: "如 posts", - value: "{table_name}", - oninput: move |e| table_name.set(e.value()), - } - p { class: "text-xs text-paper-secondary mt-1", - "仅支持 public schema 下的用户表,表名需为合法标识符" - } - } - } else { - // 查询输入 - div { - label { class: "block text-sm text-paper-secondary mb-1", - "SELECT 查询(只读)" - } - textarea { - class: "w-full px-3 py-2 text-sm border border-paper-border rounded bg-paper-theme text-paper-primary font-mono", - rows: "4", - placeholder: "SELECT id, title FROM posts WHERE published = true", - value: "{query}", - oninput: move |e| query.set(e.value()), - } - } - } - - // 格式 + 选项 - div { class: "flex flex-wrap items-center gap-4", - div { class: "flex items-center gap-2", - span { class: "text-sm text-paper-secondary", "格式" } - select { - class: "text-sm border border-paper-border rounded px-2 py-1 bg-paper-theme text-paper-primary", - value: "{format}", - onchange: move |e| format.set(e.value()), - option { value: "csv", "CSV" } - option { value: "sql", "SQL (INSERT)" } - } - } - label { class: "flex items-center gap-1 text-sm text-paper-secondary", - input { - r#type: "checkbox", - class: "mr-1", - checked: include_columns(), - onchange: move |e| include_columns.set(e.checked()), - } - "包含列名(CSV 表头 / INSERT 列清单)" - } - } - - button { class: "{BTN_PRIMARY_SM}", onclick: move |_| do_export(), "导出并下载" } - } - p { class: "text-xs text-paper-secondary", - "导出走流式响应,大表不会占满内存。SQL 格式仅含 INSERT 语句(不含 DDL/schema)。" - } - } - } -} - -/// 简易 URL 编码(避免引入新依赖;仅编码导出参数里的特殊字符)。 -/// 仅在 WASM 前端的导出按钮里用。 -#[cfg(target_arch = "wasm32")] -fn urlencode(s: &str) -> String { - let mut out = String::with_capacity(s.len()); - for b in s.bytes() { - match b { - b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { - out.push(b as char); - } - b' ' => out.push('+'), - _ => out.push_str(&format!("%{:02X}", b)), - } - } - out -} - -/// 备份恢复 tab:备份按钮 + 进度轮询 + 备份列表(下载/恢复/删除)。 -#[allow(non_snake_case)] -#[cfg_attr(not(target_arch = "wasm32"), allow(unused_mut, unused_variables))] -fn BackupTab() -> Element { - use crate::api::database::backup::BackupInfo; - #[cfg(target_arch = "wasm32")] - use crate::api::database::backup::{ - create_backup, delete_backup, list_backups, restore_backup, - }; - use crate::api::database::tasks::TaskProgress; - #[cfg(target_arch = "wasm32")] - use crate::api::database::tasks::{get_task_progress, TaskStatus}; - use crate::components::ui::{ADMIN_CARD_CLASS, ADMIN_TABLE_CLASS}; - - // backups/active_task_id 仅在闭包内的重绑定副本上 .set()(如 backups_f), - // 外层绑定本身不改值,故无需 mut。 - let backups = use_signal(Vec::::new); - let mut loading = use_signal(|| false); - let mut error = use_signal(|| Option::::None); - // 当前进行中的任务(备份/恢复)id + 进度 - let active_task_id: Signal> = use_signal(|| None); - let mut active_progress = use_signal(|| Option::::None); - let mut busy = use_signal(|| false); - - // 刷新备份列表 - let mut refresh_list = move || { - loading.set(true); - #[cfg(target_arch = "wasm32")] - { - let mut backups = backups; - let mut error = error; - spawn(async move { - match list_backups().await { - Ok(list) => { - backups.set(list); - error.set(None); - } - Err(e) => error.set(Some(e.to_string())), - } - loading.set(false); - }); - } - #[cfg(not(target_arch = "wasm32"))] - { - loading.set(false); - } - }; - - use_effect(move || { - refresh_list(); - }); - - // 任务进度轮询:active_task_id 存在时每 1.5s 拉取进度,Done/Failed 后停止 + 刷新列表。 - // - // 同样用长生命周期 loop + 循环内读 active_task_id() 的模式。原先在挂载时把 - // active_task_id 快照进 _task_id_for_poll(彼时为 None),use_future 只跑一次 - // 即 return;用户点"创建备份"后 create_backup 返回 task id 并设置信号,但 - // future 已结束 → 轮询永不启动,busy 永远为 true(用户报告的 bug)。 - use_future(move || { - let mut active_task_id = active_task_id; - let mut active_progress = active_progress; - let mut backups_f = backups; - let mut busy_f = busy; - async move { - #[cfg(target_arch = "wasm32")] - { - loop { - let tid = match active_task_id() { - Some(t) => t, - None => { - // 空闲:短 yield,最多 200ms 后响应新任务。 - crate::utils::time::sleep_ms(200).await; - continue; - } - }; - // 有任务在途:进入 1.5s 轮询,直到 Done/Failed/出错。 - loop { - crate::utils::time::sleep_ms(1500).await; - match get_task_progress(tid.clone()).await { - Ok(p) => { - let done = - p.status == TaskStatus::Done || p.status == TaskStatus::Failed; - active_progress.set(Some(p)); - if done { - // 刷新列表(备份完成后新文件出现)并清理任务态 - if let Ok(list) = list_backups().await { - backups_f.set(list); - } - active_task_id.set(None); - busy_f.set(false); - break; - } - } - Err(_) => { - active_task_id.set(None); - busy_f.set(false); - break; - } - } - } - // 内层 loop 退出后回到外层,继续等待下一个任务或空闲。 - } - } - #[cfg(not(target_arch = "wasm32"))] - { - let _ = (active_task_id, active_progress, backups_f, busy_f); - } - } - }); - - let current_backups = backups.read().clone(); - let current_error = error.read().clone(); - let current_progress = active_progress.read().clone(); - let is_busy = busy(); - // 预格式化备份行(避免在 rsx for 循环体内 let / 格式化)。 - // 每行:(filename, mode, size_str, dl_url) - let backup_rows: Vec<(String, String, String, String)> = current_backups - .iter() - .map(|b| { - ( - b.filename.clone(), - b.mode.clone(), - format_bytes(b.size_bytes as i64), - format!("/api/database/backups/{}", urlencode_dl(&b.filename)), - ) - }) - .collect(); - - rsx! { - div { class: "space-y-4", - // 操作栏 - div { class: "flex items-center gap-3", - LoadingButton { - label: "创建备份".to_string(), - loading: is_busy, - variant: "sm", - onclick: move |_| { - #[cfg(target_arch = "wasm32")] - { - busy.set(true); - active_progress.set(None); - let mut active_task_id = active_task_id; - spawn(async move { - match create_backup().await { - Ok(id) => active_task_id.set(Some(id)), - Err(e) => { - error.set(Some(e.to_string())); - busy.set(false); - } - } - }); - } - }, - } - button { - class: "{BTN_OUTLINE}", - disabled: loading() || is_busy, - onclick: move |_| refresh_list(), - "刷新列表" - } - } - - // 进度 - if let Some(p) = current_progress { - div { class: "{ADMIN_CARD_CLASS} p-4", - div { class: "flex items-center justify-between mb-2", - span { class: "text-sm font-medium text-paper-primary", "{p.stage}" } - span { class: "text-sm text-paper-secondary", "{p.percent}%" } - } - div { class: "w-full bg-paper-entry rounded-full h-2 overflow-hidden", - div { - class: "bg-paper-accent h-full transition-all", - style: "width: {p.percent}%", - } - } - if let Some(detail) = p.detail { - p { class: "text-xs text-paper-secondary mt-2", "{detail}" } - } - if let Some(err) = p.error { - p { class: "text-xs text-red-600 dark:text-red-400 mt-2", - "错误:{err}" - } - } - } - } - - // 错误 - if let Some(err) = current_error { - div { class: "bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-3 text-sm text-red-700 dark:text-red-300", - "{err}" - } - } - - // 备份列表 - if !current_backups.is_empty() { - div { class: "{ADMIN_TABLE_CLASS}", - div { class: "overflow-x-auto", - table { class: "w-full text-sm", - thead { - tr { class: "border-b border-paper-border text-left text-paper-secondary", - th { class: "px-4 py-2 font-medium", "文件名" } - th { class: "px-4 py-2 font-medium", "模式" } - th { class: "px-4 py-2 font-medium text-right", - "大小" - } - th { class: "px-4 py-2 font-medium text-right", - "操作" - } - } - } - tbody { - for (fname, mode, size_str, dl_url) in backup_rows.iter() { - BackupRow { - key: "{fname}", - filename: fname.clone(), - mode: mode.clone(), - size_str: size_str.clone(), - dl_url: dl_url.clone(), - busy: is_busy, - // 恢复:确认已在 BackupRow 的 Popover 内完成, - // 这里直接发起 restore_backup 并交由轮询 use_future 接管。 - // pending_restore signal + 确认 use_future 链路已移除 - //(原生 confirm 是阻塞式才需要那套间接机制)。 - on_restore: move |f: String| { - #[cfg(target_arch = "wasm32")] - { - let mut busy = busy; - let mut active_progress = active_progress; - let mut active_task_id = active_task_id; - let mut error = error; - spawn(async move { - busy.set(true); - active_progress.set(None); - match restore_backup(f, true).await { - Ok(id) => active_task_id.set(Some(id)), - Err(e) => { - error.set(Some(e.to_string())); - busy.set(false); - } - } - }); - } - }, - // 删除:确认已在 BackupRow 的 Popover 内完成, - // 直接执行 delete_backup + 刷新列表。 - on_delete: move |fname_del: String| { - #[cfg(target_arch = "wasm32")] - { - let mut backups = backups; - spawn(async move { - let _ = delete_backup(fname_del).await; - if let Ok(list) = list_backups().await { - backups.set(list); - } - }); - } - }, - } - } - } - } - } - } - } else if !loading() { - div { class: "text-paper-secondary text-sm py-4", "暂无备份文件" } - } - - p { class: "text-xs text-paper-secondary", - "备份优先用 pg_dump(含 schema),不可用时回退纯 SQL(仅数据)。" - "恢复仅接受本系统生成的备份,且会覆盖现有数据。" - } - } - } -} - -/// 下载链接用的 URL 编码(wasm32 才用,server 端 rsx 也引用故需编译)。 -fn urlencode_dl(s: &str) -> String { - #[cfg(target_arch = "wasm32")] - { - urlencode(s) - } - #[cfg(not(target_arch = "wasm32"))] - { - s.to_string() - } -} - -/// 备份列表单行(抽取为子组件:各自 scope 内 let/clone 不冲突)。 -/// -/// 删除/恢复不再用浏览器原生 confirm(),改用 [`Popover`](crate::components::ui::Popover) 确认框(`position:fixed` -/// 逃出表格 `overflow-hidden`)。点击按钮读 `MouseEvent::client_coordinates()` 作为 -/// popover 锚点,`confirm` 按钮回调父组件的 `on_delete`/`on_restore`。 -#[derive(Props, Clone, PartialEq)] -struct BackupRowProps { - filename: String, - mode: String, - size_str: String, - dl_url: String, - busy: bool, - on_restore: Callback, - on_delete: Callback, -} - -#[component] -#[cfg_attr(not(target_arch = "wasm32"), allow(unused_mut, unused_variables))] -fn BackupRow(props: BackupRowProps) -> Element { - use crate::components::ui::Popover; - use crate::components::ui::BTN_DANGER_OUTLINE; - - // Callback 是 Copy,直接复用;filename 需 clone(确认框闭包各取一份)。 - let on_restore = props.on_restore; - let on_delete = props.on_delete; - let fname_for_restore = props.filename.clone(); - let fname_for_delete = props.filename.clone(); - - // Popover 状态:哪个动作的确认框打开 + 锚点坐标。None = 都关闭。 - // 用一个 String("delete"/"restore") 而非两个 bool,避免同时开两个 popover。 - let mut open_action = use_signal(|| Option::::None); - // 锚点坐标:按钮点击的视口坐标(client_coordinates)。 - let mut anchor_x = use_signal(|| 0i32); - let mut anchor_y = use_signal(|| 0i32); - - rsx! { - tr { class: "border-b border-paper-border last:border-0 hover:bg-paper-entry transition-colors", - td { class: "px-4 py-2 font-mono text-xs text-paper-primary", "{props.filename}" } - td { class: "px-4 py-2 text-paper-secondary", "{props.mode}" } - td { class: "px-4 py-2 text-right text-paper-secondary", "{props.size_str}" } - td { class: "px-4 py-2 text-right whitespace-nowrap", - a { - class: "text-xs text-paper-accent hover:underline mr-3", - href: "{props.dl_url}", - download: "", - "下载" - } - button { - class: "{BTN_TEXT_AMBER} mr-3 disabled:opacity-50", - disabled: props.busy, - // 点击记录坐标并打开恢复确认 popover。client_coordinates 两端编译。 - onclick: move |e| { - let c = e.client_coordinates(); - anchor_x.set(c.x as i32); - anchor_y.set(c.y as i32); - open_action.set(Some("restore".to_string())); - }, - "恢复" - } - button { - class: "{BTN_TEXT_RED} disabled:opacity-50", - disabled: props.busy, - onclick: move |e| { - let c = e.client_coordinates(); - anchor_x.set(c.x as i32); - anchor_y.set(c.y as i32); - open_action.set(Some("delete".to_string())); - }, - "删除" - } - } - - // 恢复确认 popover - Popover { - open: open_action().as_deref() == Some("restore"), - anchor_x: anchor_x(), - anchor_y: anchor_y(), - placement: "bottom", - on_close: move |_| open_action.set(None), - div { class: "w-64 space-y-3", - p { class: "text-sm text-paper-primary leading-relaxed", - "恢复将覆盖现有数据,确认恢复 " - span { class: "font-mono text-xs break-all", "{props.filename}" } - "?" - } - p { class: "text-xs text-paper-secondary", - "仅本系统生成的备份可恢复。" - } - div { class: "flex justify-end gap-2 pt-1", - button { - class: "px-3 py-1.5 text-xs text-paper-secondary hover:text-paper-primary transition-colors cursor-pointer", - onclick: move |_| open_action.set(None), - "取消" - } - button { - class: "{BTN_DANGER_OUTLINE}", - onclick: move |_| { - open_action.set(None); - on_restore.call(fname_for_restore.clone()); - }, - "确认恢复" - } - } - } - } - - // 删除确认 popover - Popover { - open: open_action().as_deref() == Some("delete"), - anchor_x: anchor_x(), - anchor_y: anchor_y(), - placement: "bottom", - on_close: move |_| open_action.set(None), - div { class: "w-64 space-y-3", - p { class: "text-sm text-paper-primary", - "确认删除 " - span { class: "font-mono text-xs break-all", "{props.filename}" } - "?" - } - div { class: "flex justify-end gap-2 pt-1", - button { - class: "px-3 py-1.5 text-xs text-paper-secondary hover:text-paper-primary transition-colors cursor-pointer", - onclick: move |_| open_action.set(None), - "取消" - } - button { - class: "{BTN_DANGER_OUTLINE}", - onclick: move |_| { - open_action.set(None); - on_delete.call(fname_for_delete.clone()); - }, - "确认删除" - } - } - } - } - } - } -} - -#[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()); - } -} diff --git a/src/pages/admin/system/backup.rs b/src/pages/admin/system/backup.rs new file mode 100644 index 0000000..8b572f8 --- /dev/null +++ b/src/pages/admin/system/backup.rs @@ -0,0 +1,443 @@ +//! 备份恢复 tab。 + +use dioxus::prelude::*; + +use crate::components::ui::{BTN_OUTLINE, BTN_TEXT_AMBER, BTN_TEXT_RED, LoadingButton}; + +use super::format_bytes; + +/// 备份恢复 tab:备份按钮 + 进度轮询 + 备份列表(下载/恢复/删除)。 +#[allow(non_snake_case)] +#[cfg_attr(not(target_arch = "wasm32"), allow(unused_mut, unused_variables))] +pub(super) fn BackupTab() -> Element { + use crate::api::database::backup::BackupInfo; + #[cfg(target_arch = "wasm32")] + use crate::api::database::backup::{ + create_backup, delete_backup, list_backups, restore_backup, + }; + use crate::api::database::tasks::TaskProgress; + #[cfg(target_arch = "wasm32")] + use crate::api::database::tasks::{get_task_progress, TaskStatus}; + use crate::components::ui::{ADMIN_CARD_CLASS, ADMIN_TABLE_CLASS}; + + // backups/active_task_id 仅在闭包内的重绑定副本上 .set()(如 backups_f), + // 外层绑定本身不改值,故无需 mut。 + let backups = use_signal(Vec::::new); + let mut loading = use_signal(|| false); + let mut error = use_signal(|| Option::::None); + // 当前进行中的任务(备份/恢复)id + 进度 + let active_task_id: Signal> = use_signal(|| None); + let mut active_progress = use_signal(|| Option::::None); + let mut busy = use_signal(|| false); + + // 刷新备份列表 + let mut refresh_list = move || { + loading.set(true); + #[cfg(target_arch = "wasm32")] + { + let mut backups = backups; + let mut error = error; + spawn(async move { + match list_backups().await { + Ok(list) => { + backups.set(list); + error.set(None); + } + Err(e) => error.set(Some(e.to_string())), + } + loading.set(false); + }); + } + #[cfg(not(target_arch = "wasm32"))] + { + loading.set(false); + } + }; + + use_effect(move || { + refresh_list(); + }); + + // 任务进度轮询:active_task_id 存在时每 1.5s 拉取进度,Done/Failed 后停止 + 刷新列表。 + // + // 同样用长生命周期 loop + 循环内读 active_task_id() 的模式。原先在挂载时把 + // active_task_id 快照进 _task_id_for_poll(彼时为 None),use_future 只跑一次 + // 即 return;用户点"创建备份"后 create_backup 返回 task id 并设置信号,但 + // future 已结束 → 轮询永不启动,busy 永远为 true(用户报告的 bug)。 + use_future(move || { + let mut active_task_id = active_task_id; + let mut active_progress = active_progress; + let mut backups_f = backups; + let mut busy_f = busy; + async move { + #[cfg(target_arch = "wasm32")] + { + loop { + let tid = match active_task_id() { + Some(t) => t, + None => { + // 空闲:短 yield,最多 200ms 后响应新任务。 + crate::utils::time::sleep_ms(200).await; + continue; + } + }; + // 有任务在途:进入 1.5s 轮询,直到 Done/Failed/出错。 + loop { + crate::utils::time::sleep_ms(1500).await; + match get_task_progress(tid.clone()).await { + Ok(p) => { + let done = + p.status == TaskStatus::Done || p.status == TaskStatus::Failed; + active_progress.set(Some(p)); + if done { + // 刷新列表(备份完成后新文件出现)并清理任务态 + if let Ok(list) = list_backups().await { + backups_f.set(list); + } + active_task_id.set(None); + busy_f.set(false); + break; + } + } + Err(_) => { + active_task_id.set(None); + busy_f.set(false); + break; + } + } + } + // 内层 loop 退出后回到外层,继续等待下一个任务或空闲。 + } + } + #[cfg(not(target_arch = "wasm32"))] + { + let _ = (active_task_id, active_progress, backups_f, busy_f); + } + } + }); + + let current_backups = backups.read().clone(); + let current_error = error.read().clone(); + let current_progress = active_progress.read().clone(); + let is_busy = busy(); + // 预格式化备份行(避免在 rsx for 循环体内 let / 格式化)。 + // 每行:(filename, mode, size_str, dl_url) + let backup_rows: Vec<(String, String, String, String)> = current_backups + .iter() + .map(|b| { + ( + b.filename.clone(), + b.mode.clone(), + format_bytes(b.size_bytes as i64), + format!("/api/database/backups/{}", urlencode_dl(&b.filename)), + ) + }) + .collect(); + + rsx! { + div { class: "space-y-4", + // 操作栏 + div { class: "flex items-center gap-3", + LoadingButton { + label: "创建备份".to_string(), + loading: is_busy, + variant: "sm", + onclick: move |_| { + #[cfg(target_arch = "wasm32")] + { + busy.set(true); + active_progress.set(None); + let mut active_task_id = active_task_id; + spawn(async move { + match create_backup().await { + Ok(id) => active_task_id.set(Some(id)), + Err(e) => { + error.set(Some(e.to_string())); + busy.set(false); + } + } + }); + } + }, + } + button { + class: "{BTN_OUTLINE}", + disabled: loading() || is_busy, + onclick: move |_| refresh_list(), + "刷新列表" + } + } + + // 进度 + if let Some(p) = current_progress { + div { class: "{ADMIN_CARD_CLASS} p-4", + div { class: "flex items-center justify-between mb-2", + span { class: "text-sm font-medium text-paper-primary", "{p.stage}" } + span { class: "text-sm text-paper-secondary", "{p.percent}%" } + } + div { class: "w-full bg-paper-entry rounded-full h-2 overflow-hidden", + div { + class: "bg-paper-accent h-full transition-all", + style: "width: {p.percent}%", + } + } + if let Some(detail) = p.detail { + p { class: "text-xs text-paper-secondary mt-2", "{detail}" } + } + if let Some(err) = p.error { + p { class: "text-xs text-red-600 dark:text-red-400 mt-2", + "错误:{err}" + } + } + } + } + + // 错误 + if let Some(err) = current_error { + div { class: "bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-3 text-sm text-red-700 dark:text-red-300", + "{err}" + } + } + + // 备份列表 + if !current_backups.is_empty() { + div { class: "{ADMIN_TABLE_CLASS}", + div { class: "overflow-x-auto", + table { class: "w-full text-sm", + thead { + tr { class: "border-b border-paper-border text-left text-paper-secondary", + th { class: "px-4 py-2 font-medium", "文件名" } + th { class: "px-4 py-2 font-medium", "模式" } + th { class: "px-4 py-2 font-medium text-right", + "大小" + } + th { class: "px-4 py-2 font-medium text-right", + "操作" + } + } + } + tbody { + for (fname, mode, size_str, dl_url) in backup_rows.iter() { + BackupRow { + key: "{fname}", + filename: fname.clone(), + mode: mode.clone(), + size_str: size_str.clone(), + dl_url: dl_url.clone(), + busy: is_busy, + // 恢复:确认已在 BackupRow 的 Popover 内完成, + // 这里直接发起 restore_backup 并交由轮询 use_future 接管。 + // pending_restore signal + 确认 use_future 链路已移除 + //(原生 confirm 是阻塞式才需要那套间接机制)。 + on_restore: move |f: String| { + #[cfg(target_arch = "wasm32")] + { + let mut busy = busy; + let mut active_progress = active_progress; + let mut active_task_id = active_task_id; + let mut error = error; + spawn(async move { + busy.set(true); + active_progress.set(None); + match restore_backup(f, true).await { + Ok(id) => active_task_id.set(Some(id)), + Err(e) => { + error.set(Some(e.to_string())); + busy.set(false); + } + } + }); + } + }, + // 删除:确认已在 BackupRow 的 Popover 内完成, + // 直接执行 delete_backup + 刷新列表。 + on_delete: move |fname_del: String| { + #[cfg(target_arch = "wasm32")] + { + let mut backups = backups; + spawn(async move { + let _ = delete_backup(fname_del).await; + if let Ok(list) = list_backups().await { + backups.set(list); + } + }); + } + }, + } + } + } + } + } + } + } else if !loading() { + div { class: "text-paper-secondary text-sm py-4", "暂无备份文件" } + } + + p { class: "text-xs text-paper-secondary", + "备份优先用 pg_dump(含 schema),不可用时回退纯 SQL(仅数据)。" + "恢复仅接受本系统生成的备份,且会覆盖现有数据。" + } + } + } +} +/// 下载链接用的 URL 编码(wasm32 才编码,server 端原样返回——rsx 构造 dl_url 时两端都调)。 +/// 自包含实现,不跨文件依赖 export.rs 的 urlencode。 +fn urlencode_dl(s: &str) -> String { + #[cfg(target_arch = "wasm32")] + { + let mut out = String::with_capacity(s.len()); + for b in s.bytes() { + match b { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { + out.push(b as char); + } + b' ' => out.push('+'), + _ => out.push_str(&format!("%{:02X}", b)), + } + } + out + } + #[cfg(not(target_arch = "wasm32"))] + { + s.to_string() + } +} +/// 备份列表单行(抽取为子组件:各自 scope 内 let/clone 不冲突)。 +/// +/// 删除/恢复不再用浏览器原生 confirm(),改用 [`Popover`](crate::components::ui::Popover) 确认框(`position:fixed` +/// 逃出表格 `overflow-hidden`)。点击按钮读 `MouseEvent::client_coordinates()` 作为 +/// popover 锚点,`confirm` 按钮回调父组件的 `on_delete`/`on_restore`。 +#[derive(Props, Clone, PartialEq)] +struct BackupRowProps { + filename: String, + mode: String, + size_str: String, + dl_url: String, + busy: bool, + on_restore: Callback, + on_delete: Callback, +} + +#[component] +#[cfg_attr(not(target_arch = "wasm32"), allow(unused_mut, unused_variables))] +fn BackupRow(props: BackupRowProps) -> Element { + use crate::components::ui::Popover; + use crate::components::ui::BTN_DANGER_OUTLINE; + + // Callback 是 Copy,直接复用;filename 需 clone(确认框闭包各取一份)。 + let on_restore = props.on_restore; + let on_delete = props.on_delete; + let fname_for_restore = props.filename.clone(); + let fname_for_delete = props.filename.clone(); + + // Popover 状态:哪个动作的确认框打开 + 锚点坐标。None = 都关闭。 + // 用一个 String("delete"/"restore") 而非两个 bool,避免同时开两个 popover。 + let mut open_action = use_signal(|| Option::::None); + // 锚点坐标:按钮点击的视口坐标(client_coordinates)。 + let mut anchor_x = use_signal(|| 0i32); + let mut anchor_y = use_signal(|| 0i32); + + rsx! { + tr { class: "border-b border-paper-border last:border-0 hover:bg-paper-entry transition-colors", + td { class: "px-4 py-2 font-mono text-xs text-paper-primary", "{props.filename}" } + td { class: "px-4 py-2 text-paper-secondary", "{props.mode}" } + td { class: "px-4 py-2 text-right text-paper-secondary", "{props.size_str}" } + td { class: "px-4 py-2 text-right whitespace-nowrap", + a { + class: "text-xs text-paper-accent hover:underline mr-3", + href: "{props.dl_url}", + download: "", + "下载" + } + button { + class: "{BTN_TEXT_AMBER} mr-3 disabled:opacity-50", + disabled: props.busy, + // 点击记录坐标并打开恢复确认 popover。client_coordinates 两端编译。 + onclick: move |e| { + let c = e.client_coordinates(); + anchor_x.set(c.x as i32); + anchor_y.set(c.y as i32); + open_action.set(Some("restore".to_string())); + }, + "恢复" + } + button { + class: "{BTN_TEXT_RED} disabled:opacity-50", + disabled: props.busy, + onclick: move |e| { + let c = e.client_coordinates(); + anchor_x.set(c.x as i32); + anchor_y.set(c.y as i32); + open_action.set(Some("delete".to_string())); + }, + "删除" + } + } + + // 恢复确认 popover + Popover { + open: open_action().as_deref() == Some("restore"), + anchor_x: anchor_x(), + anchor_y: anchor_y(), + placement: "bottom", + on_close: move |_| open_action.set(None), + div { class: "w-64 space-y-3", + p { class: "text-sm text-paper-primary leading-relaxed", + "恢复将覆盖现有数据,确认恢复 " + span { class: "font-mono text-xs break-all", "{props.filename}" } + "?" + } + p { class: "text-xs text-paper-secondary", + "仅本系统生成的备份可恢复。" + } + div { class: "flex justify-end gap-2 pt-1", + button { + class: "px-3 py-1.5 text-xs text-paper-secondary hover:text-paper-primary transition-colors cursor-pointer", + onclick: move |_| open_action.set(None), + "取消" + } + button { + class: "{BTN_DANGER_OUTLINE}", + onclick: move |_| { + open_action.set(None); + on_restore.call(fname_for_restore.clone()); + }, + "确认恢复" + } + } + } + } + + // 删除确认 popover + Popover { + open: open_action().as_deref() == Some("delete"), + anchor_x: anchor_x(), + anchor_y: anchor_y(), + placement: "bottom", + on_close: move |_| open_action.set(None), + div { class: "w-64 space-y-3", + p { class: "text-sm text-paper-primary", + "确认删除 " + span { class: "font-mono text-xs break-all", "{props.filename}" } + "?" + } + div { class: "flex justify-end gap-2 pt-1", + button { + class: "px-3 py-1.5 text-xs text-paper-secondary hover:text-paper-primary transition-colors cursor-pointer", + onclick: move |_| open_action.set(None), + "取消" + } + button { + class: "{BTN_DANGER_OUTLINE}", + onclick: move |_| { + open_action.set(None); + on_delete.call(fname_for_delete.clone()); + }, + "确认删除" + } + } + } + } + } + } +} diff --git a/src/pages/admin/system/db_status.rs b/src/pages/admin/system/db_status.rs new file mode 100644 index 0000000..381939a --- /dev/null +++ b/src/pages/admin/system/db_status.rs @@ -0,0 +1,371 @@ +//! 数据库状态 tab。 + +use dioxus::prelude::*; + +use crate::components::skeletons::atoms::SkeletonBox; +use crate::components::skeletons::delayed_skeleton::DelayedSkeleton; +use crate::components::ui::LoadingButton; + +use super::format_bytes; + +/// 数据库状态 tab:概览卡片 + 表清单 + 索引 Top + 活跃连接。 +/// 手动刷新按钮 + 自动刷新开关(1s/2s/5s/30s/手动,默认手动)。 +#[allow(non_snake_case)] +// status/error/loading 在 spawn/onclick 闭包里 .set(),仅 WASM 前端真正用到; +// server 构建里这些 set 调用都在被剥离的 #[cfg(wasm32)] 块内,故 allow unused_mut。 +#[cfg_attr(not(target_arch = "wasm32"), allow(unused_mut, unused_variables))] +pub(super) fn DbStatusTab() -> Element { + use crate::api::database::status::DbStatus; + // get_db_status 只在 WASM 前端调用,server 构建时该 server function 的客户端桩不需要导入。 + #[cfg(target_arch = "wasm32")] + use crate::api::database::status::get_db_status; + use crate::components::ui::{ADMIN_CARD_CLASS, ADMIN_TABLE_CLASS}; + + // Signal 是 Copy,可在多个 spawn/effect 中捕获同一副本;set 走内部可变(&self)。 + let mut status = use_signal(|| Option::::None); + let mut loading = use_signal(|| true); + let mut error = use_signal(|| Option::::None); + // 自动刷新间隔(秒);None = 手动。DB 查询有成本,最低 1s。 + let mut refresh_interval: Signal> = use_signal(|| None); + + // 数据加载:WASM 前端 spawn 请求,SSR 直接结束加载。 + // 因 Signal 是 Copy,每次 spawn 各自捕获副本即可,无需共享闭包。 + let mut load_once = move || { + loading.set(true); + #[cfg(target_arch = "wasm32")] + { + let t0 = crate::utils::time::now_millis(); + web_sys::console::log_1(&format!("[DEBUG-db] fetch start at {t0}").into()); + spawn(async move { + match get_db_status().await { + Ok(s) => { + let t1 = crate::utils::time::now_millis(); + web_sys::console::log_1( + &format!("[DEBUG-db] fetch done at {t1} (Δ={}ms)", t1 - t0).into(), + ); + status.set(Some(s)); + error.set(None); + } + Err(e) => error.set(Some(e.to_string())), + } + loading.set(false); + }); + } + #[cfg(not(target_arch = "wasm32"))] + { + loading.set(false); + } + }; + + // 首次加载 + use_effect(move || { + load_once(); + }); + + // 自动刷新:使用官方推荐模式——一个永不重建的长生命周期 loop,在每次循环 + // 内部读取 refresh_interval 的当前值,自然响应间隔切换。 + // 旧做法在闭包同步体内读 status/loading/error signal,导致这些 signal 每次 + // .set() 后都触发 use_future 重建,产生多个并发 loop(请求爆炸)。 + use_future(move || async move { + #[cfg(target_arch = "wasm32")] + { + loop { + // 每次循环读最新 interval(signal 的 Copy 语义,直接调用即可)。 + let secs = refresh_interval().unwrap_or(0); + if secs == 0 { + // 手动模式:短暂 yield,让事件循环呼吸,避免忙等; + // 用户切换到自动模式后最多等 200ms 即响应。 + crate::utils::time::sleep_ms(200).await; + continue; + } + crate::utils::time::sleep_ms(secs * 1000).await; + // 二次检查:sleep 期间用户可能切回手动。 + if refresh_interval().is_none() { + continue; + } + loading.set(true); + spawn(async move { + match get_db_status().await { + Ok(s) => { + status.set(Some(s)); + error.set(None); + } + Err(e) => error.set(Some(e.to_string())), + } + loading.set(false); + }); + } + } + #[cfg(not(target_arch = "wasm32"))] + { + let _ = (status, loading, error, refresh_interval); + } + }); + + // Option 非 Copy,读出来克隆一份供 rsx 消费。 + let current = status.read().clone(); + + rsx! { + div { class: "space-y-6", + // 工具栏:刷新按钮 + 自动刷新开关 + div { class: "flex items-center justify-between", + LoadingButton { + label: "刷新".to_string(), + loading: loading(), + variant: "sm", + onclick: move |_| { + loading.set(true); + #[cfg(target_arch = "wasm32")] + { + spawn(async move { + match get_db_status().await { + Ok(s) => { + status.set(Some(s)); + error.set(None); + } + Err(e) => error.set(Some(e.to_string())), + } + loading.set(false); + }); + } + #[cfg(not(target_arch = "wasm32"))] + { + loading.set(false); + } + }, + } + div { class: "flex items-center gap-2", + span { class: "text-sm text-paper-secondary", "自动刷新" } + select { + class: "text-sm border border-paper-border rounded px-2 py-1 bg-paper-theme text-paper-primary", + value: "{refresh_interval().map(|s| s.to_string()).unwrap_or_default()}", + onchange: move |e| { + let v = e.value(); + refresh_interval + .set( + match v.as_str() { + "1" => Some(1), + "2" => Some(2), + "5" => Some(5), + "30" => Some(30), + _ => None, + }, + ); + }, + option { value: "", "手动" } + option { value: "1", "1s" } + option { value: "2", "2s" } + option { value: "5", "5s" } + option { value: "30", "30s" } + } + } + } + + if let Some(err) = error.read().clone() { + div { class: "bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4 text-sm text-red-700 dark:text-red-300", + "加载失败:{err}" + } + } else if let Some(s) = current { + // 概览卡片 + div { class: "grid grid-cols-2 md:grid-cols-4 gap-4", + div { class: "{ADMIN_CARD_CLASS} p-4", + p { class: "text-xs text-paper-secondary", "数据库总大小" } + p { class: "mt-1 text-lg font-semibold text-paper-primary", + "{format_bytes(s.db_size_bytes)}" + } + } + div { class: "{ADMIN_CARD_CLASS} p-4", + p { class: "text-xs text-paper-secondary", "连接数" } + p { class: "mt-1 text-lg font-semibold text-paper-primary", + "{s.total_connections} / {s.max_connections}" + } + } + div { class: "{ADMIN_CARD_CLASS} p-4", + p { class: "text-xs text-paper-secondary", "表数量" } + p { class: "mt-1 text-lg font-semibold text-paper-primary", + "{s.tables.len()}" + } + } + div { class: "{ADMIN_CARD_CLASS} p-4", + p { class: "text-xs text-paper-secondary", "迁移版本" } + p { class: "mt-1 text-lg font-semibold text-paper-primary truncate", + {s.migration_version.clone().unwrap_or_else(|| "—".to_string())} + } + } + } + + // 表清单(小表显示真实行数;大表回退估算,行数前标 ~) + div { class: "{ADMIN_TABLE_CLASS}", + div { class: "px-4 py-3 border-b border-paper-border text-sm font-medium text-paper-primary", + "表清单(行数:小表为真实值,大表标 ~ 为估算)" + } + div { class: "overflow-x-auto", + table { class: "w-full text-sm", + thead { + tr { class: "border-b border-paper-border text-left text-paper-secondary", + th { class: "px-4 py-2 font-medium", "表名" } + th { class: "px-4 py-2 font-medium text-right", + "行数" + } + th { class: "px-4 py-2 font-medium text-right", + "表大小" + } + th { class: "px-4 py-2 font-medium text-right", + "索引大小" + } + th { class: "px-4 py-2 font-medium text-right", + "总大小" + } + th { class: "px-4 py-2 font-medium text-right", + "死元组" + } + } + } + tbody { + for t in s.tables.iter() { + tr { class: "border-b border-paper-border last:border-0 hover:bg-paper-entry transition-colors", + td { class: "px-4 py-2 font-mono text-paper-primary", + "{t.name}" + } + td { class: "px-4 py-2 text-right text-paper-secondary", + if t.row_count_estimated { + "~{t.row_count}" + } else { + "{t.row_count}" + } + } + td { class: "px-4 py-2 text-right text-paper-secondary", + "{format_bytes(t.table_size_bytes)}" + } + td { class: "px-4 py-2 text-right text-paper-secondary", + "{format_bytes(t.index_size_bytes)}" + } + td { class: "px-4 py-2 text-right text-paper-primary font-medium", + "{format_bytes(t.total_size_bytes)}" + } + td { class: "px-4 py-2 text-right text-paper-secondary", + "{t.dead_tuples}" + } + } + } + } + } + } + } + + // 索引占用 Top + if !s.top_indexes.is_empty() { + div { class: "{ADMIN_TABLE_CLASS}", + div { class: "px-4 py-3 border-b border-paper-border text-sm font-medium text-paper-primary", + "索引占用 Top 10" + } + div { class: "overflow-x-auto", + table { class: "w-full text-sm", + thead { + tr { class: "border-b border-paper-border text-left text-paper-secondary", + th { class: "px-4 py-2 font-medium", "索引名" } + th { class: "px-4 py-2 font-medium", "所属表" } + th { class: "px-4 py-2 font-medium text-right", + "大小" + } + } + } + tbody { + for i in s.top_indexes.iter() { + tr { class: "border-b border-paper-border last:border-0 hover:bg-paper-entry transition-colors", + td { class: "px-4 py-2 font-mono text-paper-primary", + "{i.name}" + } + td { class: "px-4 py-2 font-mono text-paper-secondary", + "{i.table_name}" + } + td { class: "px-4 py-2 text-right text-paper-secondary", + "{format_bytes(i.size_bytes)}" + } + } + } + } + } + } + } + } + + // 活跃连接 + div { class: "{ADMIN_TABLE_CLASS}", + div { class: "px-4 py-3 border-b border-paper-border text-sm font-medium text-paper-primary", + "活跃连接({s.active_connections.len()})" + } + div { class: "overflow-x-auto", + table { class: "w-full text-sm", + thead { + tr { class: "border-b border-paper-border text-left text-paper-secondary", + th { class: "px-4 py-2 font-medium", "PID" } + th { class: "px-4 py-2 font-medium", "用户" } + th { class: "px-4 py-2 font-medium", "状态" } + th { class: "px-4 py-2 font-medium text-right", + "时长(秒)" + } + th { class: "px-4 py-2 font-medium", "查询" } + } + } + tbody { + for c in s.active_connections.iter() { + tr { class: "border-b border-paper-border last:border-0 hover:bg-paper-entry transition-colors", + td { class: "px-4 py-2 text-paper-secondary", + "{c.pid}" + } + td { class: "px-4 py-2 text-paper-secondary", + "{c.user}" + } + td { class: "px-4 py-2 text-paper-secondary", + {c.state.clone().unwrap_or_else(|| "—".to_string())} + } + td { class: "px-4 py-2 text-right text-paper-secondary", + { + c.query_duration_secs + .map(|d| format!("{:.1}", d)) + .unwrap_or_else(|| "—".to_string()) + } + } + td { class: "px-4 py-2 font-mono text-xs text-paper-secondary max-w-md truncate", + {c.query.clone().unwrap_or_else(|| "—".to_string())} + } + } + } + } + } + } + } + } else if loading() { + // 首次加载骨架屏:延迟 200ms 显示,避免快速加载闪烁。 + DelayedSkeleton { + div { class: "space-y-4", + // 概览卡片骨架 + div { class: "grid grid-cols-2 md:grid-cols-4 gap-4", + for _ in 0..4 { + div { class: "rounded-2xl bg-paper-entry border border-paper-border p-4 space-y-2", + SkeletonBox { class: "h-3 w-16 rounded" } + SkeletonBox { class: "h-6 w-24 rounded" } + } + } + } + // 表清单骨架 + div { class: "rounded-2xl bg-paper-entry border border-paper-border overflow-hidden", + div { class: "px-4 py-3 border-b border-paper-border", + SkeletonBox { class: "h-4 w-40 rounded" } + } + for _ in 0..5 { + div { class: "flex justify-between px-4 py-3 border-b border-paper-border last:border-0", + SkeletonBox { class: "h-4 w-24 rounded" } + SkeletonBox { class: "h-4 w-16 rounded" } + } + } + } + } + } + } else { + div { class: "text-paper-secondary py-8", "暂无数据" } + } + } + } +} diff --git a/src/pages/admin/system/export.rs b/src/pages/admin/system/export.rs new file mode 100644 index 0000000..825d249 --- /dev/null +++ b/src/pages/admin/system/export.rs @@ -0,0 +1,141 @@ +//! 数据导出 tab。 + +use dioxus::prelude::*; + +use crate::components::ui::BTN_PRIMARY_SM; + +/// 数据导出 tab:按表/按查询导出 SQL/CSV,走 Axum 流式下载。 +#[allow(non_snake_case)] +pub(super) fn ExportTab() -> Element { + use crate::components::ui::ADMIN_CARD_CLASS; + // 导出模式:"table" / "query" + let mut mode = use_signal(|| "table".to_string()); + let mut table_name = use_signal(String::new); + let mut query = use_signal(String::new); + let mut format = use_signal(|| "csv".to_string()); + let mut include_columns = use_signal(|| true); + + // 触发下载:构造 GET /api/database/export?... URL 并打开 + let do_export = move || { + #[cfg(target_arch = "wasm32")] + { + let source = if mode().as_str() == "table" { + format!("table:{}", table_name.read().trim()) + } else { + format!("query:{}", query.read()) + }; + let url = format!( + "/api/database/export?source={}&format={}&include_columns={}", + urlencode(&source), + format(), + include_columns(), + ); + if let Some(window) = web_sys::window() { + let _ = window.open_with_url(&url); + } + } + }; + + rsx! { + div { class: "space-y-4", + div { class: "{ADMIN_CARD_CLASS} p-4 space-y-4", + // 模式选择 + div { class: "flex items-center gap-4", + label { class: "flex items-center gap-2 text-sm text-paper-primary", + input { + r#type: "radio", + name: "export-mode", + checked: mode() == "table", + onchange: move |_| mode.set("table".to_string()), + } + "按表导出" + } + label { class: "flex items-center gap-2 text-sm text-paper-primary", + input { + r#type: "radio", + name: "export-mode", + checked: mode() == "query", + onchange: move |_| mode.set("query".to_string()), + } + "按查询导出" + } + } + + // 表名输入 + if mode().as_str() == "table" { + div { + label { class: "block text-sm text-paper-secondary mb-1", "表名" } + input { + r#type: "text", + class: "w-full px-3 py-2 text-sm border border-paper-border rounded bg-paper-theme text-paper-primary font-mono", + placeholder: "如 posts", + value: "{table_name}", + oninput: move |e| table_name.set(e.value()), + } + p { class: "text-xs text-paper-secondary mt-1", + "仅支持 public schema 下的用户表,表名需为合法标识符" + } + } + } else { + // 查询输入 + div { + label { class: "block text-sm text-paper-secondary mb-1", + "SELECT 查询(只读)" + } + textarea { + class: "w-full px-3 py-2 text-sm border border-paper-border rounded bg-paper-theme text-paper-primary font-mono", + rows: "4", + placeholder: "SELECT id, title FROM posts WHERE published = true", + value: "{query}", + oninput: move |e| query.set(e.value()), + } + } + } + + // 格式 + 选项 + div { class: "flex flex-wrap items-center gap-4", + div { class: "flex items-center gap-2", + span { class: "text-sm text-paper-secondary", "格式" } + select { + class: "text-sm border border-paper-border rounded px-2 py-1 bg-paper-theme text-paper-primary", + value: "{format}", + onchange: move |e| format.set(e.value()), + option { value: "csv", "CSV" } + option { value: "sql", "SQL (INSERT)" } + } + } + label { class: "flex items-center gap-1 text-sm text-paper-secondary", + input { + r#type: "checkbox", + class: "mr-1", + checked: include_columns(), + onchange: move |e| include_columns.set(e.checked()), + } + "包含列名(CSV 表头 / INSERT 列清单)" + } + } + + button { class: "{BTN_PRIMARY_SM}", onclick: move |_| do_export(), "导出并下载" } + } + p { class: "text-xs text-paper-secondary", + "导出走流式响应,大表不会占满内存。SQL 格式仅含 INSERT 语句(不含 DDL/schema)。" + } + } + } +} +/// 简易 URL 编码(避免引入新依赖;仅编码导出参数里的特殊字符)。 +/// 仅在 WASM 前端的导出按钮里用。 +#[cfg(target_arch = "wasm32")] +fn urlencode(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + for b in s.bytes() { + match b { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { + out.push(b as char); + } + b' ' => out.push('+'), + _ => out.push_str(&format!("%{:02X}", b)), + } + } + out +} diff --git a/src/pages/admin/system/mod.rs b/src/pages/admin/system/mod.rs new file mode 100644 index 0000000..0721d85 --- /dev/null +++ b/src/pages/admin/system/mod.rs @@ -0,0 +1,184 @@ +//! 后台系统管理页面(数据库 + 服务器状态 + SQL 控制台 + 导出 + 备份)。 +//! +//! 用顶部 tab 切换 5 个功能,tab 状态用 `use_signal`(不深链 / 不走分页路由)。 +//! 各 tab 拆分为独立子模块,状态完全独立、互不共享——切换 tab 时父组件用 `key` +//! 强制卸载旧 tab 组件,其内部 signal 随之销毁。 + +mod backup; +mod db_status; +mod export; +mod server_status; +mod sql_console; + +use backup::BackupTab; +use db_status::DbStatusTab; +use dioxus::prelude::*; +use export::ExportTab; +use server_status::ServerStatusTab; +use sql_console::SqlConsoleTab; + +use crate::components::ui::FilterTabs; + +/// 系统管理的 5 个功能 tab。 +#[derive(Clone, Copy, PartialEq, Debug)] +enum SystemTab { + /// 数据库运行状态(表/连接/死元组/迁移版本)。 + DbStatus, + /// 服务器状态(应用内 + 主机层 CPU/内存/磁盘)。 + ServerStatus, + /// SQL 控制台(全读写 + 护栏)。 + SqlConsole, + /// 数据导出(SQL/CSV 流式下载)。 + Export, + /// 备份恢复(pg_dump + 任务进度)。 + Backup, +} + +impl SystemTab { + /// 变体 → 稳定字符串 key(用于与基于 String 的 `FilterTabs` 组件桥接)。 + /// 改这些 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 { + // tab 状态:默认进第一个 tab(数据库状态)。用 signal 而非 URL query—— + // tab 切换无需深链/书签,避免新增路由变体。 + let mut active_tab = use_signal(|| SystemTab::DbStatus); + + rsx! { + div { class: "w-full max-w-7xl mx-auto space-y-6", + // 页面标题 + div { class: "flex flex-col md:flex-row md:items-end justify-between gap-6 pb-6 border-b border-[var(--color-paper-border)] mb-6", + div { + h1 { class: "text-4xl font-extrabold tracking-tight text-[var(--color-paper-primary)]", + "系统面板" + } + p { class: "text-base text-[var(--color-paper-secondary)] mt-2", + "数据库与服务器诊断" + } + } + } + + // 顶部 tab 切换栏:复用公共 FilterTabs 组件(String API,经 as_str/from_str 桥接枚举)。 + // 视觉与评论页一致:平滑滑动指示条 + 选中文字 text-paper-primary。 + FilterTabs { + 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 内容 + // key 保证切换 tab 时 Dioxus 完全卸载旧组件、重新挂载新组件, + // 避免 hook slot 复用导致 DelayedSkeleton 的 visible 信号残留为 true。 + div { key: "{active_tab().as_str()}", + match active_tab() { + SystemTab::DbStatus => rsx! { + DbStatusTab {} + }, + SystemTab::ServerStatus => rsx! { + ServerStatusTab {} + }, + SystemTab::SqlConsole => rsx! { + SqlConsoleTab {} + }, + SystemTab::Export => rsx! { + ExportTab {} + }, + SystemTab::Backup => rsx! { + BackupTab {} + }, + } + } + } + } +} + +/// 字节数 → 人类可读(如 1.2 MB)。db_status / server_status / backup 三个 tab 共用。 +pub(super) fn format_bytes(bytes: i64) -> String { + const UNITS: &[&str] = &["B", "KB", "MB", "GB", "TB"]; + let mut size = bytes as f64; + let mut unit = 0; + while size.abs() >= 1024.0 && unit < UNITS.len() - 1 { + size /= 1024.0; + unit += 1; + } + if unit == 0 { + format!("{} {}", bytes, UNITS[0]) + } else { + format!("{:.2} {}", size, UNITS[unit]) + } +} + +#[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()); + } +} diff --git a/src/pages/admin/system/server_status.rs b/src/pages/admin/system/server_status.rs new file mode 100644 index 0000000..5dc1ef1 --- /dev/null +++ b/src/pages/admin/system/server_status.rs @@ -0,0 +1,339 @@ +//! 服务器状态 tab。 + +use dioxus::prelude::*; + +use crate::components::skeletons::atoms::SkeletonBox; +use crate::components::skeletons::delayed_skeleton::DelayedSkeleton; +use crate::components::ui::LoadingButton; + +use super::format_bytes; + +/// 秒数 → 人类可读运行时间(如 1d 2h 3m)。 +fn format_uptime(secs: u64) -> String { + let d = secs / 86400; + let h = (secs % 86400) / 3600; + let m = (secs % 3600) / 60; + if d > 0 { + format!("{d}d {h}h {m}m") + } else if h > 0 { + format!("{h}h {m}m") + } else if m > 0 { + format!("{m}m") + } else { + format!("{secs}s") + } +} +/// 服务器状态 tab:应用内指标(连接池/会话/缓存命中率)+ 主机层(CPU/内存/磁盘)。 +/// 手动刷新 + 自动刷新开关(500ms/1s/2s/5s/手动,默认手动)。 +/// 主机层数据由后台 500ms 采样,前端轮询只读快照零成本,故可高频。 +#[allow(non_snake_case)] +#[cfg_attr(not(target_arch = "wasm32"), allow(unused_mut, unused_variables))] +pub(super) fn ServerStatusTab() -> Element { + #[cfg(target_arch = "wasm32")] + use crate::api::database::system_status::get_server_status; + use crate::api::database::system_status::ServerStatus; + use crate::components::ui::{ADMIN_CARD_CLASS, ADMIN_TABLE_CLASS}; + + let mut status = use_signal(|| Option::::None); + let mut loading = use_signal(|| true); + let mut error = use_signal(|| Option::::None); + // 自动刷新间隔(毫秒);None = 手动。主机层后台采样,前端可高频轮询。 + let mut refresh_ms: Signal> = use_signal(|| None); + + let mut load_once = move || { + loading.set(true); + #[cfg(target_arch = "wasm32")] + { + spawn(async move { + match get_server_status().await { + Ok(s) => { + status.set(Some(s)); + error.set(None); + } + Err(e) => error.set(Some(e.to_string())), + } + loading.set(false); + }); + } + #[cfg(not(target_arch = "wasm32"))] + { + loading.set(false); + } + }; + + use_effect(move || { + load_once(); + }); + + // 自动刷新:同 DbStatusTab,采用官方推荐的单一长生命周期 loop 模式。 + // 闭包体内不读任何 signal(避免隐式依赖追踪导致重建),loop 内部实时读取。 + use_future(move || async move { + #[cfg(target_arch = "wasm32")] + { + loop { + let ms = refresh_ms().unwrap_or(0); + if ms == 0 { + crate::utils::time::sleep_ms(200).await; + continue; + } + crate::utils::time::sleep_ms(ms).await; + if refresh_ms().is_none() { + continue; + } + loading.set(true); + spawn(async move { + match get_server_status().await { + Ok(s) => { + status.set(Some(s)); + error.set(None); + } + Err(e) => error.set(Some(e.to_string())), + } + loading.set(false); + }); + } + } + #[cfg(not(target_arch = "wasm32"))] + { + let _ = (status, loading, error, refresh_ms); + } + }); + + let current = status.read().clone(); + // rsx 不支持格式说明符({:.1}),也不允许在 for 循环体内 let,故预格式化所有展示值。 + let cpu_pct = current + .as_ref() + .map(|s| format!("{:.1}%", s.host.cpu_usage)) + .unwrap_or_default(); + let load_1 = current + .as_ref() + .map(|s| format!("{:.2}", s.host.load_avg_1)) + .unwrap_or_default(); + // 缓存表预格式化:把每行需要展示的值都算好字符串,避免在 rsx 里做格式化。 + let cache_rows: Vec<(String, u64, u64, u64, String)> = current + .as_ref() + .map(|s| { + s.caches + .iter() + .map(|c| { + ( + c.name.clone(), + c.entry_count, + c.hits, + c.misses, + format!("{:.1}%", c.hit_rate * 100.0), + ) + }) + .collect() + }) + .unwrap_or_default(); + + rsx! { + div { class: "space-y-6", + div { class: "flex items-center justify-between", + LoadingButton { + label: "刷新".to_string(), + loading: loading(), + variant: "sm", + onclick: move |_| { + loading.set(true); + #[cfg(target_arch = "wasm32")] + { + spawn(async move { + match get_server_status().await { + Ok(s) => { + status.set(Some(s)); + error.set(None); + } + Err(e) => error.set(Some(e.to_string())), + } + loading.set(false); + }); + } + #[cfg(not(target_arch = "wasm32"))] + { + loading.set(false); + } + }, + } + div { class: "flex items-center gap-2", + span { class: "text-sm text-paper-secondary", "自动刷新" } + select { + class: "text-sm border border-paper-border rounded px-2 py-1 bg-paper-theme text-paper-primary", + value: "{refresh_ms().map(|s| s.to_string()).unwrap_or_default()}", + onchange: move |e| { + let v = e.value(); + refresh_ms + .set( + match v.as_str() { + "500" => Some(500), + "1000" => Some(1000), + "2000" => Some(2000), + "5000" => Some(5000), + _ => None, + }, + ); + }, + option { value: "", "手动" } + option { value: "500", "500ms" } + option { value: "1000", "1s" } + option { value: "2000", "2s" } + option { value: "5000", "5s" } + } + } + } + + if let Some(err) = error.read().clone() { + div { class: "bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4 text-sm text-red-700 dark:text-red-300", + "加载失败:{err}" + } + } else if let Some(s) = current { + // 应用内指标卡片 + div { class: "grid grid-cols-2 md:grid-cols-4 gap-4", + div { class: "{ADMIN_CARD_CLASS} p-4", + p { class: "text-xs text-paper-secondary", "运行时间" } + p { class: "mt-1 text-lg font-semibold text-paper-primary", + "{format_uptime(s.uptime_secs)}" + } + } + div { class: "{ADMIN_CARD_CLASS} p-4", + p { class: "text-xs text-paper-secondary", "DB 连接池" } + p { class: "mt-1 text-lg font-semibold text-paper-primary", + "{s.pool_size} / {s.pool_max_size}" + } + p { class: "text-xs text-paper-secondary", + "空闲 {s.pool_available} · 等待 {s.pool_waiting}" + } + } + div { class: "{ADMIN_CARD_CLASS} p-4", + p { class: "text-xs text-paper-secondary", "活跃会话" } + p { class: "mt-1 text-lg font-semibold text-paper-primary", + "{s.active_sessions}" + } + } + div { class: "{ADMIN_CARD_CLASS} p-4", + p { class: "text-xs text-paper-secondary", "CPU" } + p { class: "mt-1 text-lg font-semibold text-paper-primary", + "{cpu_pct}" + } + } + } + + // 主机层指标卡片 + div { class: "grid grid-cols-2 md:grid-cols-4 gap-4", + div { class: "{ADMIN_CARD_CLASS} p-4", + p { class: "text-xs text-paper-secondary", "内存" } + p { class: "mt-1 text-lg font-semibold text-paper-primary", + "{format_bytes(s.host.used_memory as i64)} / {format_bytes(s.host.total_memory as i64)}" + } + } + div { class: "{ADMIN_CARD_CLASS} p-4", + p { class: "text-xs text-paper-secondary", "磁盘" } + p { class: "mt-1 text-lg font-semibold text-paper-primary", + "{format_bytes((s.host.disk_total - s.host.disk_available) as i64)} / {format_bytes(s.host.disk_total as i64)}" + } + } + div { class: "{ADMIN_CARD_CLASS} p-4", + p { class: "text-xs text-paper-secondary", "Load (1m)" } + p { class: "mt-1 text-lg font-semibold text-paper-primary", + "{load_1}" + } + } + div { class: "{ADMIN_CARD_CLASS} p-4", + p { class: "text-xs text-paper-secondary", "系统" } + p { class: "mt-1 text-sm font-medium text-paper-primary truncate", + "{s.host.os_name}" + } + } + } + + // 缓存命中率表 + div { class: "{ADMIN_TABLE_CLASS}", + div { class: "px-4 py-3 border-b border-paper-border text-sm font-medium text-paper-primary", + "缓存命中率" + } + div { class: "overflow-x-auto", + table { class: "w-full text-sm", + thead { + tr { class: "border-b border-paper-border text-left text-paper-secondary", + th { class: "px-4 py-2 font-medium", "缓存" } + th { class: "px-4 py-2 font-medium text-right", + "条目" + } + th { class: "px-4 py-2 font-medium text-right", + "命中" + } + th { class: "px-4 py-2 font-medium text-right", + "未命中" + } + th { class: "px-4 py-2 font-medium text-right", + "命中率" + } + } + } + tbody { + for (name, entry_count, hits, misses, rate_pct) in cache_rows.iter() { + tr { class: "border-b border-paper-border last:border-0 hover:bg-paper-entry transition-colors", + td { class: "px-4 py-2 text-paper-primary", + "{name}" + } + td { class: "px-4 py-2 text-right text-paper-secondary", + "{entry_count}" + } + td { class: "px-4 py-2 text-right text-paper-secondary", + "{hits}" + } + td { class: "px-4 py-2 text-right text-paper-secondary", + "{misses}" + } + td { class: "px-4 py-2 text-right text-paper-primary font-medium", + "{rate_pct}" + } + } + } + } + } + } + } + } else if loading() { + // 首次加载骨架屏:延迟 200ms 显示,避免快速加载闪烁。 + DelayedSkeleton { + div { class: "space-y-4", + // 应用内指标卡片骨架 + div { class: "grid grid-cols-2 md:grid-cols-4 gap-4", + for _ in 0..4 { + div { class: "rounded-2xl bg-paper-entry border border-paper-border p-4 space-y-2", + SkeletonBox { class: "h-3 w-16 rounded" } + SkeletonBox { class: "h-6 w-24 rounded" } + } + } + } + // 主机层指标卡片骨架 + div { class: "grid grid-cols-2 md:grid-cols-4 gap-4", + for _ in 0..4 { + div { class: "rounded-2xl bg-paper-entry border border-paper-border p-4 space-y-2", + SkeletonBox { class: "h-3 w-16 rounded" } + SkeletonBox { class: "h-6 w-24 rounded" } + } + } + } + // 缓存命中率表骨架 + div { class: "rounded-2xl bg-paper-entry border border-paper-border overflow-hidden", + div { class: "px-4 py-3 border-b border-paper-border", + SkeletonBox { class: "h-4 w-24 rounded" } + } + for _ in 0..4 { + div { class: "flex justify-between px-4 py-3 border-b border-paper-border last:border-0", + SkeletonBox { class: "h-4 w-20 rounded" } + SkeletonBox { class: "h-4 w-12 rounded" } + } + } + } + } + } + } else { + div { class: "text-paper-secondary py-8", "暂无数据" } + } + } + } +} diff --git a/src/pages/admin/system/sql_console.rs b/src/pages/admin/system/sql_console.rs new file mode 100644 index 0000000..0697dcb --- /dev/null +++ b/src/pages/admin/system/sql_console.rs @@ -0,0 +1,364 @@ +//! SQL 控制台 tab。 + +use dioxus::prelude::*; + +use crate::components::ui::LoadingButton; + +/// SQL 控制台 tab:CodeMirror 编辑器(SQL 高亮/补全/Vim)+ 4 道护栏 + 结果表 + EXPLAIN。 +/// +/// 护栏 4(前端二次确认):提交写操作前弹窗确认。 +#[allow(non_snake_case)] +#[cfg_attr(not(target_arch = "wasm32"), allow(unused_mut, unused_variables))] +pub(super) fn SqlConsoleTab() -> Element { + #[cfg(target_arch = "wasm32")] + use crate::api::database::schema::get_db_schema; + use crate::api::database::sql_console::SqlResult; + #[cfg(target_arch = "wasm32")] + use crate::api::database::sql_console::{execute_sql, ExecuteSqlOpts}; + #[cfg(target_arch = "wasm32")] + use crate::codemirror_bridge; + use crate::components::sql_result_table::SqlResultTable; + use crate::components::ui::ADMIN_TABLE_CLASS; + // use_resolved_theme 两种构建都用:resolved() 在 wasm 块内消费,非 wasm 仅引用避免警告。 + use crate::theme::use_resolved_theme; + #[cfg(target_arch = "wasm32")] + use crate::theme::ResolvedTheme; + + let resolved = use_resolved_theme(); + let sql_text = use_signal(String::new); + let mut result = use_signal(|| Option::::None); + let mut error = use_signal(|| Option::::None); + let mut running = use_signal(|| false); + // 选项 toggles + let mut with_explain = use_signal(|| false); + let mut allow_multi = use_signal(|| false); + let mut confirm_dangerous = use_signal(|| false); + // resolved/sql_text 仅在 wasm32 块内使用;server 构建时显式引用避免 unused 警告。 + #[cfg(not(target_arch = "wasm32"))] + let _ = (&resolved, &sql_text); + + // CodeMirror 实例句柄(仅 WASM)。 + #[cfg(target_arch = "wasm32")] + let mut editor_handle: Signal> = use_signal(|| None); + + // 执行 SQL 的核心逻辑:抽成独立闭包,按钮 onclick 与 Ctrl+Enter 快捷键共用。 + // 所有捕获都是 Copy 的 Signal,故每次调用读取最新值,闭包本身是 Fn 可重复调用。 + // 用两个独立闭包(execute_for_editor / run_sql)避免 move 单一所有权冲突—— + // 它们捕获相同的 Copy signal,行为完全等价。 + #[cfg(target_arch = "wasm32")] + let mut execute_for_editor = move || { + running.set(true); + error.set(None); + let sql = sql_text.read().clone(); + let opts = ExecuteSqlOpts { + allow_multi: allow_multi(), + confirm_dangerous: confirm_dangerous(), + with_explain: with_explain(), + }; + // 护栏 4:写操作前端二次确认(简单判断:含 UPDATE/DELETE/INSERT/ALTER/DROP/TRUNCATE/CREATE 关键词) + let lower = sql.to_lowercase(); + let looks_write = [ + "update ", + "delete ", + "insert ", + "alter ", + "drop ", + "truncate ", + "create ", + ] + .iter() + .any(|k| lower.contains(k)); + if looks_write && !confirm_dangerous() { + // 简单提示;真正的高危放行靠 confirm_dangerous 开关 + let confirmed = web_sys::window().and_then(|w| { + w.confirm_with_message( + "这是写操作(修改数据/结构),确认执行?\n\n高危操作(DROP/TRUNCATE/ALTER)还需勾选「我了解后果」。", + ) + .ok() + }); + if confirmed != Some(true) { + running.set(false); + return; + } + } + spawn(async move { + match execute_sql(sql, opts).await { + Ok(r) => result.set(Some(r)), + Err(e) => error.set(Some(e.to_string())), + } + running.set(false); + }); + }; + + // 捕获当前 scope id:Ctrl+Enter 从 CodeMirror 的 JS 事件处理中触发时, + // dioxus scope stack 为空,spawn() 内部 current_scope_id().unwrap() 会 panic。 + // 在 effect 里用 Runtime::in_scope 重建 scope 上下文,保证 spawn 找得到 origin。 + #[cfg(target_arch = "wasm32")] + let scope_id = dioxus::core::Runtime::current() + .try_current_scope_id() + .unwrap_or(dioxus::core::ScopeId::ROOT); + + // 初始化 CodeMirror + 拉取 schema 注入补全。仅 WASM。 + #[cfg(target_arch = "wasm32")] + { + use wasm_bindgen::closure::Closure; + use_effect(move || { + if editor_handle.read().is_some() { + return; + } + let mut text = sql_text; + let on_change = Closure::new(move |v: String| { + text.set(v); + }); + let on_ready = Closure::new(|| {}); + // Ctrl/Cmd+Enter 触发执行(与按钮共用同一套资源/护栏逻辑)。 + // 从 CodeMirror JS 事件触发时无 dioxus scope,必须用 in_scope 重建。 + // 刻意用闭包 `|| execute_for_editor()` 而非直接传 `execute_for_editor`: + // 后者会 move 闭包值进 in_scope,导致第二次快捷键触发时 use-after-move + // (Closure::new 要求 Fn 可重复调用)。闭包包装走借用调用,每次都读最新值。 + #[allow(clippy::redundant_closure)] + let on_run_shortcut = Closure::new(move || { + dioxus::core::Runtime::current().in_scope(scope_id, || execute_for_editor()); + }); + + let theme_name = if resolved() == ResolvedTheme::Dark { + "dark" + } else { + "light" + }; + let opts = codemirror_bridge::EditorOptions::new(); + opts.set_language("sql"); + opts.set_theme(theme_name); + opts.set_vim(true); + opts.set_on_change(&on_change); + opts.set_on_ready(&on_ready); + opts.set_on_run_shortcut(&on_run_shortcut); + + if let Ok(Some(inst)) = codemirror_bridge::get_module().create("sql-editor", &opts) { + let handle = codemirror_bridge::EditorHandle::new( + inst, + on_change, + on_ready, + on_run_shortcut, + ); + editor_handle.set(Some(handle)); + } + + // 异步拉取 schema 注入补全 + spawn(async move { + if let Ok(schema) = get_db_schema().await { + // SqlSchema 是 serde 类型:先用 serde_wasm_bindgen 转 JsValue, + // 再传给 CodeMirror(extern set_schema 只接受 &JsValue)。 + if let Ok(js) = serde_wasm_bindgen::to_value(&schema) { + if let Some(h) = editor_handle.read().as_ref() { + h.instance().set_schema(&js); + } + } + } + }); + }); + + // 主题切换(含 System 模式下系统偏好变化)时同步编辑器主题。 + // resolved 是 theme + system_dark 的派生 memo,任一变化都自动触发此 effect。 + use_effect(move || { + let r = resolved(); + if let Some(h) = editor_handle.read().as_ref() { + h.instance().set_theme(if r == ResolvedTheme::Dark { + "dark" + } else { + "light" + }); + } + }); + } + + // 执行 SQL:按钮 onclick 用的闭包。wasm 下复制 execute_for_editor 的逻辑, + // server 下仅复位 running(无网络层)。两处闭包捕获相同的 Copy signal。 + let mut run_sql = move || { + running.set(true); + error.set(None); + #[cfg(target_arch = "wasm32")] + { + let sql = sql_text.read().clone(); + let opts = ExecuteSqlOpts { + allow_multi: allow_multi(), + confirm_dangerous: confirm_dangerous(), + with_explain: with_explain(), + }; + // 护栏 4:写操作前端二次确认(简单判断:含 UPDATE/DELETE/INSERT/ALTER/DROP/TRUNCATE/CREATE 关键词) + let lower = sql.to_lowercase(); + let looks_write = [ + "update ", + "delete ", + "insert ", + "alter ", + "drop ", + "truncate ", + "create ", + ] + .iter() + .any(|k| lower.contains(k)); + if looks_write && !confirm_dangerous() { + // 简单提示;真正的高危放行靠 confirm_dangerous 开关 + let confirmed = web_sys::window().and_then(|w| { + w.confirm_with_message( + "这是写操作(修改数据/结构),确认执行?\n\n高危操作(DROP/TRUNCATE/ALTER)还需勾选「我了解后果」。", + ) + .ok() + }); + if confirmed != Some(true) { + running.set(false); + return; + } + } + spawn(async move { + match execute_sql(sql, opts).await { + Ok(r) => result.set(Some(r)), + Err(e) => error.set(Some(e.to_string())), + } + running.set(false); + }); + } + #[cfg(not(target_arch = "wasm32"))] + { + running.set(false); + } + }; + + let current_result = result.read().clone(); + let current_error = error.read().clone(); + let elapsed = current_result.as_ref().map(|r| r.elapsed_ms).unwrap_or(0); + let affected = current_result + .as_ref() + .map(|r| r.affected_rows) + .unwrap_or(0); + let stmt_type = current_result + .as_ref() + .map(|r| r.statement_type.clone()) + .unwrap_or_default(); + let truncated = current_result + .as_ref() + .map(|r| r.truncated) + .unwrap_or(false); + let explain = current_result.as_ref().and_then(|r| r.explain.clone()); + + rsx! { + div { class: "space-y-5", + // 编辑器卡片:标题栏 + CodeMirror 一体化。 + div { class: "rounded-2xl overflow-hidden border border-[var(--color-paper-border)] bg-[var(--color-paper-entry)]", + // 标题栏 + div { class: "flex justify-between items-center px-4 py-2.5 border-b border-[var(--color-paper-border)] bg-[var(--color-paper-theme)]", + div { class: "flex items-center gap-2", + span { class: "w-2 h-2 rounded-full bg-[var(--color-paper-accent)]" } + span { class: "font-mono text-sm font-semibold text-[var(--color-paper-primary)]", + "SQL" + } + } + span { class: "text-xs text-[var(--color-paper-tertiary)] font-mono", + "⌘↵ 执行" + } + } + // CodeMirror 容器:用 flex 让 .cm-editor(flex:1) 填满整个高度, + // 避免编辑器塌缩到内容高度、底部透出容器背景造成上下色差。 + div { + id: "sql-editor", + style: "min-height: 280px; display: flex; flex-direction: column", + } + } + + // 工具条:执行按钮 + 普通/危险选项分层 + div { class: "flex flex-wrap items-center gap-x-4 gap-y-3", + LoadingButton { + label: "执行".to_string(), + loading: running(), + onclick: move |_| run_sql(), + } + // 普通选项 + label { class: "flex items-center gap-1.5 text-sm text-[var(--color-paper-secondary)] cursor-pointer", + input { + r#type: "checkbox", + class: "rounded border-[var(--color-paper-border)]", + checked: with_explain(), + onchange: move |e| with_explain.set(e.checked()), + } + "EXPLAIN" + } + label { class: "flex items-center gap-1.5 text-sm text-[var(--color-paper-secondary)] cursor-pointer", + input { + r#type: "checkbox", + class: "rounded border-[var(--color-paper-border)]", + checked: allow_multi(), + onchange: move |e| allow_multi.set(e.checked()), + } + "允许多语句" + } + // 危险选项:视觉分隔 + 红色语义 + span { class: "w-px h-4 bg-[var(--color-paper-border)] mx-1" } + label { class: "flex items-center gap-1.5 text-sm text-red-600 dark:text-red-400 cursor-pointer", + input { + r#type: "checkbox", + class: "rounded border-red-300 dark:border-red-700", + checked: confirm_dangerous(), + onchange: move |e| confirm_dangerous.set(e.checked()), + } + "我了解后果(DROP/TRUNCATE/ALTER)" + } + } + + // 错误 + if let Some(err) = current_error { + div { class: "bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-2xl p-3 text-sm text-red-700 dark:text-red-300", + "{err}" + } + } + + // 结果摘要(徽章式) + if current_result.is_some() { + div { class: "flex flex-wrap items-center gap-2", + if !stmt_type.is_empty() { + span { class: "inline-flex items-center px-2.5 py-1 rounded-full bg-[var(--color-paper-accent-soft)] text-[var(--color-paper-secondary)] text-xs font-medium", + "{stmt_type}" + } + } + if affected > 0 { + span { class: "inline-flex items-center px-2.5 py-1 rounded-full bg-[var(--color-paper-accent-soft)] text-[var(--color-paper-secondary)] text-xs font-medium", + "影响 {affected} 行" + } + } + span { class: "inline-flex items-center px-2.5 py-1 rounded-full bg-[var(--color-paper-accent-soft)] text-[var(--color-paper-secondary)] text-xs font-medium", + "{elapsed}ms" + } + if truncated { + span { class: "inline-flex items-center px-2.5 py-1 rounded-full bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-300 text-xs font-medium", + "结果超过 500 行,已截断" + } + } + } + } + + // 结果表格 + if let Some(res) = ¤t_result { + if !res.rows.is_empty() { + SqlResultTable { result: res.clone() } + } else if res.statement_type.to_uppercase().contains("SELECT") { + // 有结果但无行:SELECT 返回空集的友好提示 + div { class: "{ADMIN_TABLE_CLASS} px-4 py-8 text-center text-sm text-[var(--color-paper-tertiary)]", + "查询成功,无返回行" + } + } + } + + // EXPLAIN 输出 + if let Some(explain) = explain { + div { class: "{ADMIN_TABLE_CLASS}", + div { class: "px-4 py-2 border-b border-paper-border text-sm font-medium text-paper-primary", + "执行计划" + } + pre { class: "p-4 text-xs font-mono text-paper-secondary overflow-x-auto whitespace-pre m-0", + "{explain}" + } + } + } + } + } +}