refactor(admin): 拆分 system.rs 为 system/ 目录(按 tab 分文件)
Some checks failed
CI / check (push) Has been cancelled
CI / build (push) Has been cancelled

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 测试全部通过。
This commit is contained in:
xfy 2026-07-15 18:28:39 +08:00
parent 110be35c54
commit c6cb401522
7 changed files with 1842 additions and 1792 deletions

File diff suppressed because it is too large Load Diff

View File

@ -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::<BackupInfo>::new);
let mut loading = use_signal(|| false);
let mut error = use_signal(|| Option::<String>::None);
// 当前进行中的任务(备份/恢复id + 进度
let active_task_id: Signal<Option<String>> = use_signal(|| None);
let mut active_progress = use_signal(|| Option::<TaskProgress>::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彼时为 Noneuse_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<String>,
on_delete: Callback<String>,
}
#[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::<String>::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());
},
"确认删除"
}
}
}
}
}
}
}

View File

@ -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::<DbStatus>::None);
let mut loading = use_signal(|| true);
let mut error = use_signal(|| Option::<String>::None);
// 自动刷新间隔None = 手动。DB 查询有成本,最低 1s。
let mut refresh_interval: Signal<Option<u32>> = 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 {
// 每次循环读最新 intervalsignal 的 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<DbStatus> 非 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", "暂无数据" }
}
}
}
}

View File

@ -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
}

View File

@ -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<SystemTab, &'static str> {
match s {
"db_status" => Ok(SystemTab::DbStatus),
"server_status" => Ok(SystemTab::ServerStatus),
"sql_console" => Ok(SystemTab::SqlConsole),
"export" => Ok(SystemTab::Export),
"backup" => Ok(SystemTab::Backup),
_ => Err("unknown tab key"),
}
}
}
/// 系统管理入口组件。
#[component]
pub fn System() -> Element {
// 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());
}
}

View File

@ -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::<ServerStatus>::None);
let mut loading = use_signal(|| true);
let mut error = use_signal(|| Option::<String>::None);
// 自动刷新间隔毫秒None = 手动。主机层后台采样,前端可高频轮询。
let mut refresh_ms: Signal<Option<u32>> = 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", "暂无数据" }
}
}
}
}

View File

@ -0,0 +1,364 @@
//! SQL 控制台 tab。
use dioxus::prelude::*;
use crate::components::ui::LoadingButton;
/// SQL 控制台 tabCodeMirror 编辑器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::<SqlResult>::None);
let mut error = use_signal(|| Option::<String>::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<Option<codemirror_bridge::EditorHandle>> = 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 idCtrl+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
// 再传给 CodeMirrorextern 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) = &current_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}"
}
}
}
}
}
}