feat(admin): add backup/restore tab (dual-mode + task progress polling)
新增 dashmap 依赖。create_backup 探测 pg_dump 优先(含 schema,签名头前置)、
不可用回退纯 SQL(COPY TO STDOUT 逐表,按表精确进度)。restore_backup 校验
签名头(仅本系统备份)+ 路径穿越防护 + 二次确认,探测 psql 执行。
list_backups/delete_backup 管理备份,GET /api/database/backups/{name} 下载
(admin 鉴权 + 白名单)。tasks.rs DashMap 进度表(1h 惰性 GC),前端每 1.5s
轮询 get_task_progress 显示进度条/stage,完成后刷新列表。
backups/ gitignored 不直接暴露。AGENTS.md 同步新增 CodeMirror 子项目与
数据库管理章节。
This commit is contained in:
parent
5c4b2a53d1
commit
6e94db0c2b
3
.gitignore
vendored
3
.gitignore
vendored
@ -22,6 +22,9 @@ uploads/*
|
||||
!uploads/.gitkeep
|
||||
uploads/.cache/
|
||||
|
||||
# Database backups
|
||||
backups/
|
||||
|
||||
profile.json.gz
|
||||
|
||||
# Git worktrees
|
||||
|
||||
24
AGENTS.md
24
AGENTS.md
@ -162,6 +162,30 @@ Core JavaScript bundle in `libs/yggdrasil-core/`, built as an IIFE library expos
|
||||
|
||||
Do not edit `public/yggdrasil-core/` — they are build artifacts.
|
||||
|
||||
## CodeMirror Editor Subproject
|
||||
|
||||
CodeMirror 6 code editor in `libs/codemirror-editor/`, built as an IIFE library exposing `window.CodeMirrorEditor` (object literal `{ create(containerId, options) }`) and `window.EditorOptions` (a class so `new EditorOptions()` survives TS erasure from wasm). Mirrors the tiptap-editor packaging pattern exactly.
|
||||
|
||||
- Output: `public/codemirror/` (`editor.js`, `editor.js.map`). No CSS file — themes are JS `Extension`s from `@catppuccin/codemirror` (Latte light / Mocha dark, matching the syntax-highlighting `themes/`).
|
||||
- `make build` runs `pnpm ci --include=dev && pnpm run build` inside `libs/codemirror-editor`; the `build` script is `tsc --noEmit && vite build` (Vite 8 / Rolldown, type-check before bundle). Output is IIFE (`formats: ['iife']`, `exports: 'default'`).
|
||||
- Features: `@codemirror/lang-sql` with live schema autocomplete (schema injected via `set_schema` from `get_db_schema` server function), `@replit/codemirror-vim` keymap (injected before other keymaps), Catppuccin theme hot-swap via `Compartment.reconfigure` (no instance rebuild — preserves Vim state/cursor/undo).
|
||||
- Rust bridge: `src/codemirror_bridge.rs` mirrors `src/tiptap_bridge.rs` — `get_module()` uses `js_sys::Reflect::get` + `unchecked_into` (object literal, not a constructor, so NOT an extern fn call and NOT `dyn_into`); `EditorHandle` holds the instance + all `Closure`s; `impl Drop` calls `destroy()`.
|
||||
- Shared data types `SqlSchema`/`SqlTable` (serde) compile on both targets; the wasm-bindgen externs live in a `#[cfg(target_arch = "wasm32")] mod wasm`.
|
||||
- Unit tests: `pnpm test` (Vitest 4 + happy-dom), covering getValue/setValue, setTheme (Compartment), setSchema, vim toggle, onChange.
|
||||
- Injected via `Dioxus.toml` `script` array (`/codemirror/editor.js`).
|
||||
|
||||
Do not edit `public/codemirror/` — they are build artifacts.
|
||||
|
||||
## Database Management (`/admin/system`)
|
||||
|
||||
Admin area at `/admin/system` (menu "系统") with 5 tabs: 数据库状态 / 服务器状态 / SQL 控制台 / 数据导出 / 备份恢复. All gated by `get_current_admin_user` (admin-only). Backend in `src/api/database/` (status/system_status/sql_console/schema/export/backup/tasks), page in `src/pages/admin/system.rs`.
|
||||
|
||||
- **SQL 控制台** is full read-write with 4 guards: (1) `sqlparser` AST gates — `DROP DATABASE`/`DROP SCHEMA`/`CREATE DATABASE` absolutely forbidden (string pre-check); `DROP`/`TRUNCATE`/`ALTER` require a `confirm_dangerous` checkbox; (2) `UPDATE`/`DELETE` without `WHERE` rejected; (3) `STATEMENT_TIMEOUT_SECS` query timeout (pool-level GUC); (4) frontend write-confirm dialog. Multi-statement disabled by default. Results capped at 500 rows.
|
||||
- **备份恢复** uses `dashmap` task-progress table; `create_backup`/`restore_backup` return a task_id immediately and poll `get_task_progress`. Backup prefers `pg_dump` (full, incl. schema), falls back to per-table `COPY TO STDOUT` (data only) when `pg_dump` is unavailable. Backup files carry a `-- YGGDRASIL BACKUP v1` signature header; restore rejects non-system files. `backups/` is gitignored and served only via `GET /api/database/backups/{filename}` (admin-gated, path-allowlist).
|
||||
- **服务器状态** uses `sysinfo` (optional, server feature) with a background sampler (`SYSINFO_SAMPLE_SECS`, default 0.5s) writing to a `RwLock<SystemSnapshot>`; server functions read the snapshot (zero sampling cost), so frontend can poll high-frequency. `src/cache.rs` exposes moka hit-rate via `AtomicU64` hit/miss counters per cache + `cache_stats()`.
|
||||
- New env var: `SYSINFO_SAMPLE_SECS` (sysinfo sampling interval in seconds, supports decimals).
|
||||
- New deps (all `optional = true`, server feature): `sysinfo`, `sqlparser`, `dashmap`.
|
||||
|
||||
## Syntax Highlighting Pipeline
|
||||
|
||||
- `themes/` contains Catppuccin Latte (light) and Mocha (dark) `.tmTheme` files
|
||||
|
||||
1
Cargo.lock
generated
1
Cargo.lock
generated
@ -5479,6 +5479,7 @@ dependencies = [
|
||||
"axum",
|
||||
"bytes",
|
||||
"chrono",
|
||||
"dashmap",
|
||||
"deadpool-postgres",
|
||||
"dioxus",
|
||||
"dotenvy",
|
||||
|
||||
@ -37,6 +37,7 @@ moka = { version = "0.12", features = ["future", "sync"], optional = true }
|
||||
governor = { version = "0.8", optional = true }
|
||||
sysinfo = { version = "0.34", optional = true }
|
||||
sqlparser = { version = "0.45", optional = true }
|
||||
dashmap = { version = "6.2", optional = true }
|
||||
md-5 = { version = "0.10", optional = true }
|
||||
futures = { version = "0.3", optional = true }
|
||||
bytes = { version = "1", optional = true }
|
||||
@ -91,6 +92,7 @@ server = [
|
||||
"dep:governor",
|
||||
"dep:sysinfo",
|
||||
"dep:sqlparser",
|
||||
"dep:dashmap",
|
||||
"dep:md-5",
|
||||
"dep:futures",
|
||||
"dep:bytes",
|
||||
|
||||
549
src/api/database/backup.rs
Normal file
549
src/api/database/backup.rs
Normal file
@ -0,0 +1,549 @@
|
||||
#![allow(clippy::unused_unit, deprecated)]
|
||||
|
||||
//! 备份与恢复(读写,最高风险)。
|
||||
//!
|
||||
//! 备份:探测 pg_dump 可用性——可用则子进程生成完整 .sql,不可用则回退纯 SQL
|
||||
//! (仅数据)。备份文件含签名头。
|
||||
//! 恢复:仅接受本系统生成的备份(签名校验)+ 二次确认 + 路径穿越防护。
|
||||
//! 长耗时操作走后台任务 + 进度轮询(见 [`super::tasks`])。
|
||||
|
||||
use std::path::{Component, PathBuf};
|
||||
|
||||
use chrono::Utc;
|
||||
use dioxus::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::api::auth::get_current_admin_user;
|
||||
use crate::api::database::tasks::{self, TaskKind, TaskStatus};
|
||||
use crate::api::error::AppError;
|
||||
|
||||
/// 备份目录(项目根,与 uploads/ 平级,gitignored)。
|
||||
const BACKUP_DIR: &str = "backups";
|
||||
/// 文件名白名单正则:仅字母数字下划线点连字符(防路径穿越)。
|
||||
const FILENAME_RE: &str = r"^[a-zA-Z0-9_.\-]+$";
|
||||
/// 备份文件签名头(恢复时校验,拒绝非本系统文件)。
|
||||
const BACKUP_SIGNATURE: &str = "-- YGGDRASIL BACKUP v1";
|
||||
|
||||
/// 备份文件元信息(列表展示用)。
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
pub struct BackupInfo {
|
||||
pub filename: String,
|
||||
pub size_bytes: u64,
|
||||
/// 备份模式:pg_dump / sql-fallback(从签名头解析)。
|
||||
pub mode: String,
|
||||
pub created_at: Option<String>,
|
||||
}
|
||||
|
||||
/// 发起备份,立即返回 task_id,后台任务执行。
|
||||
#[server(CreateBackup, "/api")]
|
||||
pub async fn create_backup() -> Result<String, ServerFnError> {
|
||||
let _user = get_current_admin_user().await?;
|
||||
|
||||
#[cfg(feature = "server")]
|
||||
{
|
||||
let task_id = uuid::Uuid::new_v4().to_string();
|
||||
tasks::insert(task_id.clone(), TaskKind::Backup);
|
||||
let tid = task_id.clone();
|
||||
tokio::spawn(async move {
|
||||
run_backup(&tid).await;
|
||||
});
|
||||
Ok(task_id)
|
||||
}
|
||||
#[cfg(not(feature = "server"))]
|
||||
{
|
||||
Ok(String::new())
|
||||
}
|
||||
}
|
||||
|
||||
/// 后台执行备份:pg_dump 优先,不可用回退纯 SQL。
|
||||
#[cfg(feature = "server")]
|
||||
async fn run_backup(task_id: &str) {
|
||||
let _ = std::fs::create_dir_all(BACKUP_DIR);
|
||||
let timestamp = Utc::now().format("%Y%m%d_%H%M%S").to_string();
|
||||
|
||||
// 探测 pg_dump
|
||||
let pg_dump_ok = std::process::Command::new("pg_dump")
|
||||
.arg("--version")
|
||||
.output()
|
||||
.is_ok();
|
||||
|
||||
if pg_dump_ok {
|
||||
run_pg_dump_backup(task_id, ×tamp).await;
|
||||
} else {
|
||||
run_sql_fallback_backup(task_id, ×tamp).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// pg_dump 模式:子进程生成完整备份(含 schema),前置签名头。
|
||||
#[cfg(feature = "server")]
|
||||
async fn run_pg_dump_backup(task_id: &str, timestamp: &str) {
|
||||
tasks::update(
|
||||
task_id,
|
||||
"正在用 pg_dump 导出",
|
||||
10,
|
||||
TaskStatus::Running,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
let filename = format!("backup_{}.sql", timestamp);
|
||||
let path = backup_path(&filename);
|
||||
let db_url = std::env::var("DATABASE_URL").unwrap_or_default();
|
||||
|
||||
let mut header = String::new();
|
||||
header.push_str(&format!("{}\n", BACKUP_SIGNATURE));
|
||||
header.push_str(&format!("-- created_at: {}\n", Utc::now()));
|
||||
header.push_str("-- mode: pg_dump\n");
|
||||
|
||||
// 先写签名头,再追加 pg_dump 输出。
|
||||
let write_header = std::fs::write(&path, &header);
|
||||
if write_header.is_err() {
|
||||
tasks::update(
|
||||
task_id,
|
||||
"写入备份文件失败",
|
||||
100,
|
||||
TaskStatus::Failed,
|
||||
None,
|
||||
Some("无法写入备份目录".to_string()),
|
||||
None,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let stdout_file = match std::fs::OpenOptions::new().append(true).open(&path) {
|
||||
Ok(f) => f,
|
||||
Err(e) => {
|
||||
tasks::update(
|
||||
task_id,
|
||||
"pg_dump 启动失败",
|
||||
100,
|
||||
TaskStatus::Failed,
|
||||
None,
|
||||
Some(e.to_string()),
|
||||
None,
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
let child = match std::process::Command::new("pg_dump")
|
||||
.arg(&db_url)
|
||||
.stdout(std::process::Stdio::from(stdout_file))
|
||||
.stderr(std::process::Stdio::piped())
|
||||
.spawn()
|
||||
{
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
tasks::update(
|
||||
task_id,
|
||||
"pg_dump 启动失败",
|
||||
100,
|
||||
TaskStatus::Failed,
|
||||
None,
|
||||
Some(e.to_string()),
|
||||
None,
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
let output = child.wait_with_output();
|
||||
match output {
|
||||
Ok(o) if o.status.success() => {
|
||||
tasks::update(
|
||||
task_id,
|
||||
"完成",
|
||||
100,
|
||||
TaskStatus::Done,
|
||||
None,
|
||||
None,
|
||||
Some(filename),
|
||||
);
|
||||
}
|
||||
Ok(o) => {
|
||||
let stderr = String::from_utf8_lossy(&o.stderr).to_string();
|
||||
tasks::update(
|
||||
task_id,
|
||||
"pg_dump 失败",
|
||||
100,
|
||||
TaskStatus::Failed,
|
||||
None,
|
||||
Some(stderr),
|
||||
None,
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
tasks::update(
|
||||
task_id,
|
||||
"pg_dump 执行失败",
|
||||
100,
|
||||
TaskStatus::Failed,
|
||||
None,
|
||||
Some(e.to_string()),
|
||||
None,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 纯 SQL 回退:仅备份数据(不含 schema),按表计数精确进度。
|
||||
#[cfg(feature = "server")]
|
||||
async fn run_sql_fallback_backup(task_id: &str, timestamp: &str) {
|
||||
tasks::update(
|
||||
task_id,
|
||||
"pg_dump 不可用,使用纯 SQL 回退(仅数据)",
|
||||
10,
|
||||
TaskStatus::Running,
|
||||
Some("仅备份数据,不含 schema/索引/触发器".to_string()),
|
||||
None,
|
||||
None,
|
||||
);
|
||||
let filename = format!("backup_{}_sqlfallback.sql", timestamp);
|
||||
let path = backup_path(&filename);
|
||||
|
||||
let client = match crate::db::pool::get_conn().await {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
tasks::update(
|
||||
task_id,
|
||||
"数据库连接失败",
|
||||
100,
|
||||
TaskStatus::Failed,
|
||||
None,
|
||||
Some(e.to_string()),
|
||||
None,
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// 取 public schema 下所有表名
|
||||
let tables: Vec<String> = match client
|
||||
.query(
|
||||
"SELECT tablename FROM pg_tables WHERE schemaname = 'public' ORDER BY tablename",
|
||||
&[],
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(rows) => rows.into_iter().map(|r| r.get(0)).collect(),
|
||||
Err(e) => {
|
||||
tasks::update(
|
||||
task_id,
|
||||
"读取表清单失败",
|
||||
100,
|
||||
TaskStatus::Failed,
|
||||
None,
|
||||
Some(e.to_string()),
|
||||
None,
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
let total = tables.len().max(1);
|
||||
|
||||
let mut out = String::new();
|
||||
out.push_str(&format!("{}\n", BACKUP_SIGNATURE));
|
||||
out.push_str(&format!("-- created_at: {}\n", Utc::now()));
|
||||
out.push_str("-- mode: sql-fallback\n\n");
|
||||
|
||||
for (i, table) in tables.iter().enumerate() {
|
||||
out.push_str(&format!("\n-- table: {}\n", table));
|
||||
let copy_stmt = format!("COPY \"{}\" TO STDOUT WITH CSV", table);
|
||||
match client.copy_out(©_stmt).await {
|
||||
Ok(stream) => {
|
||||
use futures::StreamExt;
|
||||
// CopyOutStream 是 !Unpin,必须 pin 才能调 next。
|
||||
tokio::pin!(stream);
|
||||
while let Some(chunk) = stream.next().await {
|
||||
if let Ok(bytes) = chunk {
|
||||
out.push_str(&String::from_utf8_lossy(&bytes));
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
out.push_str(&format!("-- 导出失败: {}\n", e));
|
||||
}
|
||||
}
|
||||
// 按表更新进度
|
||||
tasks::update(
|
||||
task_id,
|
||||
&format!("导出表 {}/{}", i + 1, total),
|
||||
10 + (i + 1) as u8 * 90 / total as u8,
|
||||
TaskStatus::Running,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
}
|
||||
|
||||
if std::fs::write(&path, out).is_err() {
|
||||
tasks::update(
|
||||
task_id,
|
||||
"写入备份文件失败",
|
||||
100,
|
||||
TaskStatus::Failed,
|
||||
None,
|
||||
Some("无法写入备份目录".to_string()),
|
||||
None,
|
||||
);
|
||||
return;
|
||||
}
|
||||
tasks::update(
|
||||
task_id,
|
||||
"完成",
|
||||
100,
|
||||
TaskStatus::Done,
|
||||
None,
|
||||
None,
|
||||
Some(filename),
|
||||
);
|
||||
}
|
||||
|
||||
/// 发起恢复:校验签名 + 路径穿越防护 + 二次确认,立即返回 task_id。
|
||||
#[server(RestoreBackup, "/api")]
|
||||
pub async fn restore_backup(filename: String, confirm: bool) -> Result<String, ServerFnError> {
|
||||
let _user = get_current_admin_user().await?;
|
||||
|
||||
if !confirm {
|
||||
return Err(AppError::BadRequest("需确认恢复(会覆盖现有数据)".to_string()).into());
|
||||
}
|
||||
// 路径穿越防护
|
||||
let re = regex::Regex::new(FILENAME_RE).unwrap();
|
||||
if !re.is_match(&filename) {
|
||||
return Err(AppError::BadRequest("无效的文件名".to_string()).into());
|
||||
}
|
||||
let path = backup_path(&filename);
|
||||
if !path.exists() {
|
||||
return Err(AppError::NotFound("备份文件不存在").into());
|
||||
}
|
||||
|
||||
// 签名校验:首行需含签名
|
||||
let head = std::fs::read_to_string(&path)
|
||||
.ok()
|
||||
.and_then(|s| s.lines().next().map(|l| l.trim().to_string()))
|
||||
.unwrap_or_default();
|
||||
if !head.contains(BACKUP_SIGNATURE) {
|
||||
return Err(AppError::BadRequest("非本系统生成的备份文件,拒绝恢复".to_string()).into());
|
||||
}
|
||||
|
||||
#[cfg(feature = "server")]
|
||||
{
|
||||
let task_id = uuid::Uuid::new_v4().to_string();
|
||||
tasks::insert(task_id.clone(), TaskKind::Restore);
|
||||
let tid = task_id.clone();
|
||||
let f = filename;
|
||||
tokio::spawn(async move {
|
||||
run_restore(&tid, &f).await;
|
||||
});
|
||||
Ok(task_id)
|
||||
}
|
||||
#[cfg(not(feature = "server"))]
|
||||
{
|
||||
Ok(String::new())
|
||||
}
|
||||
}
|
||||
|
||||
/// 后台执行恢复:探测 psql,可用则 psql -f,不可用则报告。
|
||||
#[cfg(feature = "server")]
|
||||
async fn run_restore(task_id: &str, filename: &str) {
|
||||
let path = backup_path(filename);
|
||||
let db_url = std::env::var("DATABASE_URL").unwrap_or_default();
|
||||
let psql_ok = std::process::Command::new("psql")
|
||||
.arg("--version")
|
||||
.output()
|
||||
.is_ok();
|
||||
if !psql_ok {
|
||||
tasks::update(
|
||||
task_id,
|
||||
"psql 不可用",
|
||||
100,
|
||||
TaskStatus::Failed,
|
||||
None,
|
||||
Some("恢复需要 psql,但当前环境未安装 psql".to_string()),
|
||||
None,
|
||||
);
|
||||
return;
|
||||
}
|
||||
tasks::update(
|
||||
task_id,
|
||||
"正在用 psql 恢复",
|
||||
50,
|
||||
TaskStatus::Running,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
let output = std::process::Command::new("psql")
|
||||
.arg(&db_url)
|
||||
.arg("-f")
|
||||
.arg(&path)
|
||||
.stderr(std::process::Stdio::piped())
|
||||
.output();
|
||||
match output {
|
||||
Ok(o) if o.status.success() => {
|
||||
tasks::update(task_id, "恢复完成", 100, TaskStatus::Done, None, None, None);
|
||||
}
|
||||
Ok(o) => {
|
||||
let stderr = String::from_utf8_lossy(&o.stderr).to_string();
|
||||
tasks::update(
|
||||
task_id,
|
||||
"恢复失败",
|
||||
100,
|
||||
TaskStatus::Failed,
|
||||
None,
|
||||
Some(stderr),
|
||||
None,
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
tasks::update(
|
||||
task_id,
|
||||
"psql 启动失败",
|
||||
100,
|
||||
TaskStatus::Failed,
|
||||
None,
|
||||
Some(e.to_string()),
|
||||
None,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 列出 backups/ 目录下的备份文件元信息。
|
||||
#[server(ListBackups, "/api")]
|
||||
pub async fn list_backups() -> Result<Vec<BackupInfo>, ServerFnError> {
|
||||
let _user = get_current_admin_user().await?;
|
||||
#[cfg(feature = "server")]
|
||||
{
|
||||
let mut infos: Vec<BackupInfo> = Vec::new();
|
||||
if let Ok(entries) = std::fs::read_dir(BACKUP_DIR) {
|
||||
for entry in entries.flatten() {
|
||||
let name = entry.file_name().to_string_lossy().to_string();
|
||||
if !name.ends_with(".sql") {
|
||||
continue;
|
||||
}
|
||||
let meta = match entry.metadata() {
|
||||
Ok(m) => m,
|
||||
Err(_) => continue,
|
||||
};
|
||||
let mode = std::fs::read_to_string(entry.path())
|
||||
.ok()
|
||||
.and_then(|s| {
|
||||
s.lines()
|
||||
.find(|l| l.starts_with("-- mode:"))
|
||||
.map(|l| l.trim_start_matches("-- mode: ").trim().to_string())
|
||||
})
|
||||
.unwrap_or_else(|| "unknown".to_string());
|
||||
let created_at = meta
|
||||
.modified()
|
||||
.ok()
|
||||
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
|
||||
.map(|d| {
|
||||
chrono::DateTime::<Utc>::from_timestamp(d.as_secs() as i64, 0)
|
||||
.map(|dt| dt.to_rfc3339())
|
||||
.unwrap_or_default()
|
||||
});
|
||||
infos.push(BackupInfo {
|
||||
filename: name,
|
||||
size_bytes: meta.len(),
|
||||
mode,
|
||||
created_at,
|
||||
});
|
||||
}
|
||||
}
|
||||
// 按创建时间降序(新的在前)
|
||||
infos.sort_by(|a, b| b.created_at.cmp(&a.created_at));
|
||||
Ok(infos)
|
||||
}
|
||||
#[cfg(not(feature = "server"))]
|
||||
{
|
||||
Ok(vec![])
|
||||
}
|
||||
}
|
||||
|
||||
/// 删除备份文件。
|
||||
#[server(DeleteBackup, "/api")]
|
||||
pub async fn delete_backup(filename: String) -> Result<(), ServerFnError> {
|
||||
let _user = get_current_admin_user().await?;
|
||||
#[cfg(feature = "server")]
|
||||
{
|
||||
let re = regex::Regex::new(FILENAME_RE).unwrap();
|
||||
if !re.is_match(&filename) {
|
||||
return Err(AppError::BadRequest("无效的文件名".to_string()).into());
|
||||
}
|
||||
let path = backup_path(&filename);
|
||||
if !path.exists() {
|
||||
return Err(AppError::NotFound("备份文件不存在").into());
|
||||
}
|
||||
std::fs::remove_file(&path).map_err(|_| AppError::Internal("删除失败"))?;
|
||||
Ok(())
|
||||
}
|
||||
#[cfg(not(feature = "server"))]
|
||||
{
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// 构造 backups/ 下的安全路径(额外防御:校验规范化后仍在 BACKUP_DIR 内)。
|
||||
#[cfg(feature = "server")]
|
||||
fn backup_path(filename: &str) -> PathBuf {
|
||||
let raw: PathBuf = [BACKUP_DIR, filename].iter().collect();
|
||||
// 确保规范化后首两段仍是 BACKUP_DIR/filename(防任何路径穿越残留)
|
||||
let mut it = raw.components();
|
||||
let _ = it.next(); // BACKUP_DIR
|
||||
if it.all(|c| !matches!(c, Component::ParentDir | Component::RootDir)) {
|
||||
raw
|
||||
} else {
|
||||
PathBuf::from(BACKUP_DIR)
|
||||
}
|
||||
}
|
||||
|
||||
/// Axum 处理器:下载备份文件(admin 鉴权 + 路径白名单)。
|
||||
pub async fn download_backup(
|
||||
axum::extract::Path(filename): axum::extract::Path<String>,
|
||||
headers: axum::http::HeaderMap,
|
||||
) -> Result<impl axum::response::IntoResponse, (axum::http::StatusCode, String)> {
|
||||
use axum::http::{header, StatusCode};
|
||||
|
||||
// 鉴权
|
||||
let cookie_header = headers
|
||||
.get("cookie")
|
||||
.and_then(|h| h.to_str().ok())
|
||||
.unwrap_or("");
|
||||
let token = crate::auth::session::parse_session_token(cookie_header).map(str::to_string);
|
||||
let token = match token {
|
||||
Some(t) => t,
|
||||
None => return Err((StatusCode::UNAUTHORIZED, "未登录".to_string())),
|
||||
};
|
||||
let user = match crate::api::auth::get_user_by_token(&token).await {
|
||||
Ok(Some(u)) => u,
|
||||
_ => return Err((StatusCode::UNAUTHORIZED, "会话已过期".to_string())),
|
||||
};
|
||||
if user.role != crate::models::user::UserRole::Admin {
|
||||
return Err((StatusCode::FORBIDDEN, "权限不足".to_string()));
|
||||
}
|
||||
|
||||
// 路径白名单
|
||||
let re = regex::Regex::new(FILENAME_RE).unwrap();
|
||||
if !re.is_match(&filename) {
|
||||
return Err((StatusCode::BAD_REQUEST, "无效的文件名".to_string()));
|
||||
}
|
||||
let path = backup_path(&filename);
|
||||
let bytes = tokio::fs::read(&path)
|
||||
.await
|
||||
.map_err(|_| (StatusCode::NOT_FOUND, "文件不存在".to_string()))?;
|
||||
let disposition = format!("attachment; filename=\"{}\"", filename);
|
||||
Ok((
|
||||
StatusCode::OK,
|
||||
[
|
||||
(
|
||||
header::CONTENT_TYPE,
|
||||
axum::http::HeaderValue::from_static("application/sql; charset=utf-8"),
|
||||
),
|
||||
(
|
||||
header::CONTENT_DISPOSITION,
|
||||
axum::http::HeaderValue::from_str(&disposition)
|
||||
.unwrap_or_else(|_| axum::http::HeaderValue::from_static("attachment")),
|
||||
),
|
||||
],
|
||||
axum::body::Body::from(bytes),
|
||||
))
|
||||
}
|
||||
@ -16,3 +16,7 @@ pub mod sql_console;
|
||||
pub mod schema;
|
||||
/// 数据导出 Axum 流式处理器。
|
||||
pub mod export;
|
||||
/// 备份/恢复(双模式 + 任务进度)。
|
||||
pub mod backup;
|
||||
/// 备份/恢复异步任务进度表。
|
||||
pub mod tasks;
|
||||
|
||||
120
src/api/database/tasks.rs
Normal file
120
src/api/database/tasks.rs
Normal file
@ -0,0 +1,120 @@
|
||||
#![allow(clippy::unused_unit, deprecated)]
|
||||
|
||||
//! 备份/恢复的异步任务进度表(DashMap)。
|
||||
//!
|
||||
//! create_backup/restore_backup 立即返回 task_id,后台任务跑时通过
|
||||
//! [`update`] 更新进度,前端轮询 [`get_task_progress`]。
|
||||
//! 已完成超过 1 小时的任务惰性清理,避免内存累积。
|
||||
|
||||
use std::sync::LazyLock;
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use dashmap::DashMap;
|
||||
use dioxus::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::api::auth::get_current_admin_user;
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize, Debug, PartialEq)]
|
||||
pub enum TaskKind {
|
||||
Backup,
|
||||
Restore,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize, Debug, PartialEq)]
|
||||
pub enum TaskStatus {
|
||||
Running,
|
||||
Done,
|
||||
Failed,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize, Debug)]
|
||||
pub struct TaskProgress {
|
||||
pub id: String,
|
||||
pub kind: TaskKind,
|
||||
pub stage: String,
|
||||
pub percent: u8,
|
||||
pub detail: Option<String>,
|
||||
pub status: TaskStatus,
|
||||
pub error: Option<String>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
/// 完成后的备份文件名(下载/恢复用)。
|
||||
pub result_filename: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg(feature = "server")]
|
||||
static TASKS: LazyLock<DashMap<String, TaskProgress>> = LazyLock::new(DashMap::new);
|
||||
|
||||
/// 注册新任务(初始 Running,0%)。
|
||||
#[cfg(feature = "server")]
|
||||
pub(super) fn insert(id: String, kind: TaskKind) {
|
||||
TASKS.insert(
|
||||
id.clone(),
|
||||
TaskProgress {
|
||||
id,
|
||||
kind,
|
||||
stage: "排队中".to_string(),
|
||||
percent: 0,
|
||||
detail: None,
|
||||
status: TaskStatus::Running,
|
||||
error: None,
|
||||
created_at: Utc::now(),
|
||||
result_filename: None,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// 更新任务进度(后台任务调用)。
|
||||
#[cfg(feature = "server")]
|
||||
pub(super) fn update(
|
||||
id: &str,
|
||||
stage: &str,
|
||||
percent: u8,
|
||||
status: TaskStatus,
|
||||
detail: Option<String>,
|
||||
error: Option<String>,
|
||||
result_filename: Option<String>,
|
||||
) {
|
||||
if let Some(mut p) = TASKS.get_mut(id) {
|
||||
p.stage = stage.to_string();
|
||||
p.percent = percent;
|
||||
p.status = status;
|
||||
p.detail = detail;
|
||||
p.error = error;
|
||||
p.result_filename = result_filename;
|
||||
}
|
||||
}
|
||||
|
||||
/// 惰性清理已完成超过 1 小时的任务(查询时顺便清)。
|
||||
#[cfg(feature = "server")]
|
||||
fn gc_old() {
|
||||
let cutoff = Utc::now() - chrono::Duration::hours(1);
|
||||
TASKS.retain(|_, p| !(matches!(p.status, TaskStatus::Done | TaskStatus::Failed) && p.created_at < cutoff));
|
||||
}
|
||||
|
||||
/// 查询任务进度(轮询用)。
|
||||
#[server(GetTaskProgress, "/api")]
|
||||
pub async fn get_task_progress(task_id: String) -> Result<TaskProgress, ServerFnError> {
|
||||
let _user = get_current_admin_user().await?;
|
||||
#[cfg(feature = "server")]
|
||||
{
|
||||
gc_old();
|
||||
TASKS.get(&task_id)
|
||||
.map(|p| p.clone())
|
||||
.ok_or_else(|| crate::api::error::AppError::NotFound("任务不存在").into())
|
||||
}
|
||||
#[cfg(not(feature = "server"))]
|
||||
{
|
||||
Ok(TaskProgress {
|
||||
id: task_id,
|
||||
kind: TaskKind::Backup,
|
||||
stage: String::new(),
|
||||
percent: 0,
|
||||
detail: None,
|
||||
status: TaskStatus::Done,
|
||||
error: None,
|
||||
created_at: Utc::now(),
|
||||
result_filename: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -395,6 +395,11 @@ fn main() {
|
||||
"/api/database/export",
|
||||
axum::routing::get(crate::api::database::export::export_data),
|
||||
)
|
||||
// 备份下载:admin 鉴权 + 路径白名单(backups/ 不直接暴露静态目录)
|
||||
.route(
|
||||
"/api/database/backups/{filename}",
|
||||
axum::routing::get(crate::api::database::backup::download_backup),
|
||||
)
|
||||
.layer(TimeoutLayer::with_status_code(
|
||||
StatusCode::REQUEST_TIMEOUT,
|
||||
Duration::from_secs(120),
|
||||
|
||||
@ -63,7 +63,7 @@ pub fn System() -> Element {
|
||||
SystemTab::ServerStatus => rsx! { ServerStatusTab {} },
|
||||
SystemTab::SqlConsole => rsx! { SqlConsoleTab {} },
|
||||
SystemTab::Export => rsx! { ExportTab {} },
|
||||
SystemTab::Backup => rsx! { div { class: "text-paper-secondary py-8", "备份恢复(待实现)" } },
|
||||
SystemTab::Backup => rsx! { BackupTab {} },
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1037,3 +1037,341 @@ fn urlencode(s: &str) -> String {
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// 备份恢复 tab:备份按钮 + 进度轮询 + 备份列表(下载/恢复/删除)。
|
||||
#[allow(non_snake_case)]
|
||||
fn BackupTab() -> Element {
|
||||
use crate::api::database::backup::BackupInfo;
|
||||
use crate::api::database::tasks::TaskProgress;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
use crate::api::database::backup::{create_backup, delete_backup, list_backups, restore_backup};
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
use crate::api::database::tasks::{get_task_progress, TaskStatus};
|
||||
use crate::components::ui::{ADMIN_CARD_CLASS, ADMIN_TABLE_CLASS};
|
||||
|
||||
let backups = use_signal(Vec::<BackupInfo>::new);
|
||||
let mut loading = use_signal(|| false);
|
||||
let error = use_signal(|| Option::<String>::None);
|
||||
// 当前进行中的任务(备份/恢复)id + 进度
|
||||
let active_task_id: Signal<Option<String>> = use_signal(|| None);
|
||||
let active_progress = use_signal(|| Option::<TaskProgress>::None);
|
||||
// 待恢复的文件名(确认对话框用)
|
||||
let mut pending_restore: Signal<Option<String>> = use_signal(|| None);
|
||||
let busy = use_signal(|| false);
|
||||
|
||||
// 刷新备份列表
|
||||
let mut refresh_list = move || {
|
||||
loading.set(true);
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
let backups = backups;
|
||||
let 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();
|
||||
});
|
||||
|
||||
// 恢复确认:pending_restore 被设置时弹出原生 confirm,确认后发起 restore_backup
|
||||
// 并开始进度轮询。用 use_future 响应 pending_restore 变化。
|
||||
let _pending_for_confirm = pending_restore.read().clone();
|
||||
use_future(move || {
|
||||
let pending_restore = pending_restore;
|
||||
let busy = busy;
|
||||
let active_progress = active_progress;
|
||||
let active_task_id = active_task_id;
|
||||
let error = error;
|
||||
async move {
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
let fname = match _pending_for_confirm {
|
||||
Some(f) => f,
|
||||
None => return,
|
||||
};
|
||||
let confirmed = web_sys::window()
|
||||
.and_then(|w| {
|
||||
w.confirm_with_message(&format!(
|
||||
"恢复将覆盖现有数据,确认恢复 {}?\n\n仅本系统生成的备份可恢复。",
|
||||
fname
|
||||
))
|
||||
.ok()
|
||||
})
|
||||
== Some(true);
|
||||
if confirmed {
|
||||
busy.set(true);
|
||||
active_progress.set(None);
|
||||
match restore_backup(fname, true).await {
|
||||
Ok(id) => active_task_id.set(Some(id)),
|
||||
Err(e) => {
|
||||
error.set(Some(e.to_string()));
|
||||
busy.set(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
// 无论确认与否都清空 pending
|
||||
pending_restore.set(None);
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let _ = (pending_restore, busy, active_progress, active_task_id, error);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 任务进度轮询:active_task_id 存在时每 1.5s 拉取进度,Done/Failed 后停止 + 刷新列表
|
||||
let _task_id_for_poll = active_task_id.read().clone();
|
||||
use_future(move || {
|
||||
let active_task_id = active_task_id;
|
||||
let active_progress = active_progress;
|
||||
let backups_f = backups;
|
||||
let busy_f = busy;
|
||||
async move {
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
let tid = match _task_id_for_poll {
|
||||
Some(t) => t,
|
||||
None => return,
|
||||
};
|
||||
loop {
|
||||
wasm_sleep(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 {
|
||||
// 刷新列表(备份完成后新文件出现)并清理任务态
|
||||
match list_backups().await {
|
||||
Ok(list) => 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#[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",
|
||||
button {
|
||||
class: "px-4 py-1.5 text-sm bg-paper-accent text-paper-theme rounded hover:brightness-110 transition disabled:opacity-50",
|
||||
disabled: is_busy,
|
||||
onclick: move |_| {
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
busy.set(true);
|
||||
active_progress.set(None);
|
||||
let 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); }
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
if is_busy { "处理中..." } else { "创建备份" }
|
||||
}
|
||||
button {
|
||||
class: "px-3 py-1.5 text-sm border border-paper-border text-paper-primary rounded hover:bg-paper-entry transition disabled:opacity-50",
|
||||
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,
|
||||
on_restore: move |f| pending_restore.set(Some(f)),
|
||||
on_delete: move |_fname_del| {
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
let fname_del = _fname_del;
|
||||
let confirmed = web_sys::window()
|
||||
.and_then(|w| {
|
||||
w.confirm_with_message(&format!("确认删除 {}?", fname_del))
|
||||
.ok()
|
||||
})
|
||||
== Some(true);
|
||||
if confirmed {
|
||||
let backups = backups;
|
||||
spawn(async move {
|
||||
let _ = delete_backup(fname_del.clone()).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 不冲突)。
|
||||
#[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]
|
||||
fn BackupRow(props: BackupRowProps) -> Element {
|
||||
// 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();
|
||||
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: "text-xs text-amber-600 hover:text-amber-800 dark:text-amber-400 mr-3 disabled:opacity-50",
|
||||
disabled: props.busy,
|
||||
onclick: move |_| on_restore.call(fname_for_restore.clone()),
|
||||
"恢复"
|
||||
}
|
||||
button {
|
||||
class: "text-xs text-red-600 hover:text-red-800 dark:text-red-400 disabled:opacity-50",
|
||||
disabled: props.busy,
|
||||
onclick: move |_| on_delete.call(fname_for_delete.clone()),
|
||||
"删除"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user