fix(database): make src/api/database compile under WASM (web-only) build

The /admin/system feature was never verified against the WASM frontend
target (dx serve builds src/main.rs with --features web and no server).
The entire src/api/database/ tree unconditionally pulled in server-only
crates (axum, sqlparser, dashmap, tokio, tokio_postgres, futures, regex)
and server-gated helpers (get_current_admin_user, parse_session_token,
get_user_by_token), so the frontend build failed with 43 errors.

Root cause + fixes (mirror the established settings.rs pattern):

- Gate server-only  imports behind #[cfg(feature = server)] in
  status/system_status/sql_console/schema/tasks/backup. The #[server] macro
  strips call sites but NOT use statements, so unresolved imports remained.
- Ungate  in main.rs: SystemSnapshot is a shared serde
  type referenced by ServerStatus.host on both targets; only the sampler
  task/SNAPSHOT static are internally server-gated.
- Gate  (pure Axum handler, zero WASM consumers) and the
  download_backup Axum handler in backup.rs.
- Move restore_backup's validation preamble (regex/backup_path/fs) inside
  the #[cfg(feature = server)] block; gate std::path/chrono::Utc imports.
- Add [[bin]] required-features=[server] for generate_highlight_css so the
  syntect-dependent build tool is skipped in web-only builds.

Genuine WASM bugs surfaced once the server bodies stopped masking them:

- system.rs: Closure import path (dioxus::prelude::wasm_bindgen ->
  wasm_bindgen, matching write.rs); setTimeout expects i32 not u32;
  editor_handle() -> editor_handle.read() (Signal is not Fn in 0.7).
- codemirror_bridge.rs: set_schema extern took &SqlSchema but SqlSchema is
  a serde type with no IntoWasmAbi. Changed both set_schema signatures to
  &JsValue; call site now serializes via serde-wasm-bindgen::to_value.
- system.rs: signals .set() inside spawn/use_future/onclick closures need
   bindings (incl. the *_f rebinding copies); added cfg_attr
  allow(unused_mut) on the 4 tab components, matching write.rs convention.

Verified: cargo check (default/server) clean; cargo check --features web
--target wasm32-unknown-unknown clean (0 errors); dx check clean; 411
cargo tests pass.
This commit is contained in:
xfy 2026-06-30 13:41:23 +08:00
parent 37d7ec49eb
commit ecf3c73715
13 changed files with 137 additions and 71 deletions

1
Cargo.lock generated
View File

@ -5496,6 +5496,7 @@ dependencies = [
"rand 0.8.6", "rand 0.8.6",
"regex", "regex",
"serde", "serde",
"serde-wasm-bindgen",
"serde_json", "serde_json",
"serial_test", "serial_test",
"sha2 0.10.9", "sha2 0.10.9",

View File

@ -3,6 +3,13 @@ name = "yggdrasil"
version = "0.3.0" version = "0.3.0"
edition = "2021" edition = "2021"
# 构建时工具二进制:用 syntectserver-only 依赖)生成 highlight.css。
# 仅在启用 server feature 时编译——WASM 前端构建(--no-default-features --features web
# 不会拉入 syntect此二进制若也参与编译会因找不到 syntect 报错。
[[bin]]
name = "generate_highlight_css"
required-features = ["server"]
[dependencies] [dependencies]
dioxus = { version = "0.7.9", features = ["fullstack", "router"] } dioxus = { version = "0.7.9", features = ["fullstack", "router"] }
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }
@ -47,6 +54,8 @@ web-sys = { version = "0.3", features = ["Document", "Window", "Storage", "Eleme
wasm-bindgen = "0.2" wasm-bindgen = "0.2"
wasm-bindgen-futures = "0.4" wasm-bindgen-futures = "0.4"
js-sys = "0.3" js-sys = "0.3"
# 把 serde 类型(如 SqlSchema序列化为 JsValue供 wasm-bindgen extern 传递。
serde-wasm-bindgen = "0.6"
[dev-dependencies] [dev-dependencies]
tokio = { version = "1.52", features = ["rt-multi-thread", "macros", "fs", "time", "sync"] } tokio = { version = "1.52", features = ["rt-multi-thread", "macros", "fs", "time", "sync"] }

View File

@ -7,14 +7,21 @@
//! 恢复:仅接受本系统生成的备份(签名校验)+ 二次确认 + 路径穿越防护。 //! 恢复:仅接受本系统生成的备份(签名校验)+ 二次确认 + 路径穿越防护。
//! 长耗时操作走后台任务 + 进度轮询(见 [`super::tasks`])。 //! 长耗时操作走后台任务 + 进度轮询(见 [`super::tasks`])。
// Component/PathBuf/chrono::Utc 仅 server 构建的备份逻辑用到。
#[cfg(feature = "server")]
use std::path::{Component, PathBuf}; use std::path::{Component, PathBuf};
#[cfg(feature = "server")]
use chrono::Utc; use chrono::Utc;
use dioxus::prelude::*; use dioxus::prelude::*;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
// admin 鉴权 + AppError + tasks 进度表仅在 server 构建里被 server function 体引用。
#[cfg(feature = "server")]
use crate::api::auth::get_current_admin_user; use crate::api::auth::get_current_admin_user;
#[cfg(feature = "server")]
use crate::api::database::tasks::{self, TaskKind, TaskStatus}; use crate::api::database::tasks::{self, TaskKind, TaskStatus};
#[cfg(feature = "server")]
use crate::api::error::AppError; use crate::api::error::AppError;
/// 备份目录(项目根,与 uploads/ 平级gitignored /// 备份目录(项目根,与 uploads/ 平级gitignored
@ -316,6 +323,10 @@ async fn run_sql_fallback_backup(task_id: &str, timestamp: &str) {
pub async fn restore_backup(filename: String, confirm: bool) -> Result<String, ServerFnError> { pub async fn restore_backup(filename: String, confirm: bool) -> Result<String, ServerFnError> {
let _user = get_current_admin_user().await?; let _user = get_current_admin_user().await?;
// 全部校验都在 server cfg 块内confirm/regex/backup_path/std::fs 都是 server-only。
// WASM 侧的 server-function 客户端桩只返回 Ok(String::new())。
#[cfg(feature = "server")]
{
if !confirm { if !confirm {
return Err(AppError::BadRequest("需确认恢复(会覆盖现有数据)".to_string()).into()); return Err(AppError::BadRequest("需确认恢复(会覆盖现有数据)".to_string()).into());
} }
@ -338,8 +349,6 @@ pub async fn restore_backup(filename: String, confirm: bool) -> Result<String, S
return Err(AppError::BadRequest("非本系统生成的备份文件,拒绝恢复".to_string()).into()); return Err(AppError::BadRequest("非本系统生成的备份文件,拒绝恢复".to_string()).into());
} }
#[cfg(feature = "server")]
{
let task_id = uuid::Uuid::new_v4().to_string(); let task_id = uuid::Uuid::new_v4().to_string();
tasks::insert(task_id.clone(), TaskKind::Restore); tasks::insert(task_id.clone(), TaskKind::Restore);
let tid = task_id.clone(); let tid = task_id.clone();
@ -351,6 +360,8 @@ pub async fn restore_backup(filename: String, confirm: bool) -> Result<String, S
} }
#[cfg(not(feature = "server"))] #[cfg(not(feature = "server"))]
{ {
// WASM 客户端桩:忽略参数,返回空 task_id。
let _ = (filename, confirm);
Ok(String::new()) Ok(String::new())
} }
} }
@ -525,6 +536,8 @@ fn backup_path(filename: &str) -> PathBuf {
} }
/// Axum 处理器下载备份文件admin 鉴权 + 路径白名单)。 /// Axum 处理器下载备份文件admin 鉴权 + 路径白名单)。
/// 仅 server 构建:纯 Axum 路由(在 main.rs 注册),无 WASM 消费者。
#[cfg(feature = "server")]
pub async fn download_backup( pub async fn download_backup(
axum::extract::Path(filename): axum::extract::Path<String>, axum::extract::Path(filename): axum::extract::Path<String>,
headers: axum::http::HeaderMap, headers: axum::http::HeaderMap,

View File

@ -14,7 +14,8 @@ pub mod system_status;
pub mod sql_console; pub mod sql_console;
/// SQL 补全用 schema 拉取。 /// SQL 补全用 schema 拉取。
pub mod schema; pub mod schema;
/// 数据导出 Axum 流式处理器。 /// 数据导出 Axum 流式处理器(仅 server纯 Axum 路由,无 WASM 消费者)。
#[cfg(feature = "server")]
pub mod export; pub mod export;
/// 备份/恢复(双模式 + 任务进度)。 /// 备份/恢复(双模式 + 任务进度)。
pub mod backup; pub mod backup;

View File

@ -4,10 +4,15 @@
use dioxus::prelude::*; use dioxus::prelude::*;
// admin 鉴权 + DB 查询仅在 server 构建里被 server function 体引用。
#[cfg(feature = "server")]
use crate::api::auth::get_current_admin_user; use crate::api::auth::get_current_admin_user;
#[cfg(feature = "server")]
use crate::api::error::AppError; use crate::api::error::AppError;
use crate::codemirror_bridge::SqlSchema; #[cfg(feature = "server")]
use crate::db::pool::get_conn; use crate::db::pool::get_conn;
// SqlSchema/SqlTable 是两端共享的纯数据类型(定义在 codemirror_bridge
use crate::codemirror_bridge::SqlSchema;
/// 拉取数据库 schema表名 + 列名),供 CodeMirror SQL 补全。 /// 拉取数据库 schema表名 + 列名),供 CodeMirror SQL 补全。
#[server(GetDbSchema, "/api")] #[server(GetDbSchema, "/api")]

View File

@ -14,8 +14,12 @@
use dioxus::prelude::*; use dioxus::prelude::*;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
// admin 鉴权 + DB 查询仅在 server 构建里被 server function 体引用。
#[cfg(feature = "server")]
use crate::api::auth::get_current_admin_user; use crate::api::auth::get_current_admin_user;
#[cfg(feature = "server")]
use crate::api::error::AppError; use crate::api::error::AppError;
#[cfg(feature = "server")]
use crate::db::pool::get_conn; use crate::db::pool::get_conn;
#[derive(Deserialize, Serialize, Debug, Clone, Copy)] #[derive(Deserialize, Serialize, Debug, Clone, Copy)]

View File

@ -9,8 +9,13 @@ use chrono::{DateTime, Utc};
use dioxus::prelude::*; use dioxus::prelude::*;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
// 仅 server 构建用到admin 鉴权 + DB 查询。WASM 侧的 server-function 客户端桩
// 不解析这些符号,必须 gate 以避免在非 server 构建里找不到 server-only 符号。
#[cfg(feature = "server")]
use crate::api::auth::get_current_admin_user; use crate::api::auth::get_current_admin_user;
#[cfg(feature = "server")]
use crate::api::error::AppError; use crate::api::error::AppError;
#[cfg(feature = "server")]
use crate::db::pool::get_conn; use crate::db::pool::get_conn;
/// 数据库状态聚合数据。 /// 数据库状态聚合数据。

View File

@ -8,7 +8,10 @@
use dioxus::prelude::*; use dioxus::prelude::*;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
// admin 鉴权 + AppError 仅在 server 构建里被 server function 体引用。
#[cfg(feature = "server")]
use crate::api::auth::get_current_admin_user; use crate::api::auth::get_current_admin_user;
#[cfg(feature = "server")]
use crate::api::error::AppError; use crate::api::error::AppError;
/// 单个缓存的统计快照(前端展示用)。 /// 单个缓存的统计快照(前端展示用)。

View File

@ -6,14 +6,18 @@
//! [`update`] 更新进度,前端轮询 [`get_task_progress`]。 //! [`update`] 更新进度,前端轮询 [`get_task_progress`]。
//! 已完成超过 1 小时的任务惰性清理,避免内存累积。 //! 已完成超过 1 小时的任务惰性清理,避免内存累积。
use std::sync::LazyLock;
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use dashmap::DashMap;
use dioxus::prelude::*; use dioxus::prelude::*;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
// admin 鉴权仅 server 构建用到。
#[cfg(feature = "server")]
use crate::api::auth::get_current_admin_user; use crate::api::auth::get_current_admin_user;
// DashMap / LazyLock 仅 server 构建持有任务进度表WASM 端只序列化 TaskProgress。
#[cfg(feature = "server")]
use std::sync::LazyLock;
#[cfg(feature = "server")]
use dashmap::DashMap;
#[derive(Clone, Serialize, Deserialize, Debug, PartialEq)] #[derive(Clone, Serialize, Deserialize, Debug, PartialEq)]
pub enum TaskKind { pub enum TaskKind {

View File

@ -32,7 +32,6 @@ pub struct SqlTable {
// ============================================================================ // ============================================================================
#[cfg(target_arch = "wasm32")] #[cfg(target_arch = "wasm32")]
pub mod wasm { pub mod wasm {
use super::SqlSchema;
use wasm_bindgen::prelude::*; use wasm_bindgen::prelude::*;
use wasm_bindgen::JsCast; use wasm_bindgen::JsCast;
@ -91,8 +90,10 @@ pub mod wasm {
pub fn set_theme(this: &EditorInstance, theme: &str); pub fn set_theme(this: &EditorInstance, theme: &str);
/// 更新 SQL 补全 schemaCompartment.reconfigure /// 更新 SQL 补全 schemaCompartment.reconfigure
/// 参数为 serde_wasm_bindgen::to_value 序列化后的 JsValue
///SqlSchema 是 serde 类型,非 wasm-bindgen 类型,故不能直接传 &SqlSchema
#[wasm_bindgen(method, js_name = setSchema)] #[wasm_bindgen(method, js_name = setSchema)]
pub fn set_schema(this: &EditorInstance, schema: &SqlSchema); pub fn set_schema(this: &EditorInstance, schema: &wasm_bindgen::JsValue);
/// 让编辑器获取焦点。 /// 让编辑器获取焦点。
#[wasm_bindgen(method)] #[wasm_bindgen(method)]
@ -126,9 +127,9 @@ pub mod wasm {
#[wasm_bindgen(method, setter, js_name = vim)] #[wasm_bindgen(method, setter, js_name = vim)]
pub fn set_vim(this: &EditorOptions, v: bool); pub fn set_vim(this: &EditorOptions, v: bool);
/// SQL 补全 schema表/列数据)。 /// SQL 补全 schema表/列数据)。v 为 serde_wasm_bindgen::to_value 序列化结果。
#[wasm_bindgen(method, setter, js_name = schema)] #[wasm_bindgen(method, setter, js_name = schema)]
pub fn set_schema(this: &EditorOptions, v: &SqlSchema); pub fn set_schema(this: &EditorOptions, v: &wasm_bindgen::JsValue);
/// 初始文档内容。 /// 初始文档内容。
#[wasm_bindgen(method, setter, js_name = value)] #[wasm_bindgen(method, setter, js_name = value)]

View File

@ -23,8 +23,9 @@ mod hooks;
mod models; mod models;
mod pages; mod pages;
mod router; mod router;
// sysinfo_sampler 仅在 server feature 启用时编译:主机指标后台采样 + RwLock 快照。 // sysinfo_sampler主机指标快照。
#[cfg(feature = "server")] // SystemSnapshot 结构体两端都编译(被 system_status 的 ServerStatus 字段引用);
// 真正的采样任务 / RwLock / read_snapshot 实现在本模块内部自行 #[cfg(feature = "server")] gate。
mod sysinfo_sampler; mod sysinfo_sampler;
// ssr_cache 仅在 server feature 启用时编译;保存 SSR 世代号失效状态。 // ssr_cache 仅在 server feature 启用时编译;保存 SSR 世代号失效状态。
#[cfg(feature = "server")] #[cfg(feature = "server")]

View File

@ -97,7 +97,7 @@ async fn wasm_sleep(ms: u32) {
.expect("no window") .expect("no window")
.set_timeout_with_callback_and_timeout_and_arguments_0( .set_timeout_with_callback_and_timeout_and_arguments_0(
&resolve.unchecked_into(), &resolve.unchecked_into(),
ms, ms.try_into().unwrap(),
) )
.expect("set_timeout failed"); .expect("set_timeout failed");
}); });
@ -107,6 +107,9 @@ async fn wasm_sleep(ms: u32) {
/// 数据库状态 tab概览卡片 + 表清单 + 索引 Top + 活跃连接。 /// 数据库状态 tab概览卡片 + 表清单 + 索引 Top + 活跃连接。
/// 手动刷新按钮 + 自动刷新开关1s/2s/5s/30s/手动,默认手动)。 /// 手动刷新按钮 + 自动刷新开关1s/2s/5s/30s/手动,默认手动)。
#[allow(non_snake_case)] #[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 { fn DbStatusTab() -> Element {
use crate::api::database::status::DbStatus; use crate::api::database::status::DbStatus;
// get_db_status 只在 WASM 前端调用server 构建时该 server function 的客户端桩不需要导入。 // get_db_status 只在 WASM 前端调用server 构建时该 server function 的客户端桩不需要导入。
@ -115,9 +118,9 @@ fn DbStatusTab() -> Element {
use crate::components::ui::{ADMIN_CARD_CLASS, ADMIN_TABLE_CLASS}; use crate::components::ui::{ADMIN_CARD_CLASS, ADMIN_TABLE_CLASS};
// Signal 是 Copy可在多个 spawn/effect 中捕获同一副本set 走内部可变(&self // Signal 是 Copy可在多个 spawn/effect 中捕获同一副本set 走内部可变(&self
let status = use_signal(|| Option::<DbStatus>::None); let mut status = use_signal(|| Option::<DbStatus>::None);
let mut loading = use_signal(|| true); let mut loading = use_signal(|| true);
let error = use_signal(|| Option::<String>::None); let mut error = use_signal(|| Option::<String>::None);
// 自动刷新间隔None = 手动。DB 查询有成本,最低 1s。 // 自动刷新间隔None = 手动。DB 查询有成本,最低 1s。
let mut refresh_interval: Signal<Option<u32>> = use_signal(|| None); let mut refresh_interval: Signal<Option<u32>> = use_signal(|| None);
@ -155,9 +158,9 @@ fn DbStatusTab() -> Element {
use_future(move || { use_future(move || {
// 在依赖闭包里读 interval使 use_future 在它变化时重新执行。 // 在依赖闭包里读 interval使 use_future 在它变化时重新执行。
let interval_secs = refresh_interval(); let interval_secs = refresh_interval();
let status_f = status; let mut status_f = status;
let loading_f = loading; let mut loading_f = loading;
let error_f = error; let mut error_f = error;
async move { async move {
#[cfg(target_arch = "wasm32")] #[cfg(target_arch = "wasm32")]
{ {
@ -397,15 +400,16 @@ fn format_uptime(secs: u64) -> String {
/// 手动刷新 + 自动刷新开关500ms/1s/2s/5s/手动,默认手动)。 /// 手动刷新 + 自动刷新开关500ms/1s/2s/5s/手动,默认手动)。
/// 主机层数据由后台 500ms 采样,前端轮询只读快照零成本,故可高频。 /// 主机层数据由后台 500ms 采样,前端轮询只读快照零成本,故可高频。
#[allow(non_snake_case)] #[allow(non_snake_case)]
#[cfg_attr(not(target_arch = "wasm32"), allow(unused_mut, unused_variables))]
fn ServerStatusTab() -> Element { fn ServerStatusTab() -> Element {
use crate::api::database::system_status::ServerStatus; use crate::api::database::system_status::ServerStatus;
#[cfg(target_arch = "wasm32")] #[cfg(target_arch = "wasm32")]
use crate::api::database::system_status::get_server_status; use crate::api::database::system_status::get_server_status;
use crate::components::ui::{ADMIN_CARD_CLASS, ADMIN_TABLE_CLASS}; use crate::components::ui::{ADMIN_CARD_CLASS, ADMIN_TABLE_CLASS};
let status = use_signal(|| Option::<ServerStatus>::None); let mut status = use_signal(|| Option::<ServerStatus>::None);
let mut loading = use_signal(|| true); let mut loading = use_signal(|| true);
let error = use_signal(|| Option::<String>::None); let mut error = use_signal(|| Option::<String>::None);
// 自动刷新间隔毫秒None = 手动。主机层后台采样,前端可高频轮询。 // 自动刷新间隔毫秒None = 手动。主机层后台采样,前端可高频轮询。
let mut refresh_ms: Signal<Option<u32>> = use_signal(|| None); let mut refresh_ms: Signal<Option<u32>> = use_signal(|| None);
@ -437,9 +441,9 @@ fn ServerStatusTab() -> Element {
// 自动刷新refresh_ms 变化时重建 future。 // 自动刷新refresh_ms 变化时重建 future。
use_future(move || { use_future(move || {
let interval_ms = refresh_ms(); let interval_ms = refresh_ms();
let status_f = status; let mut status_f = status;
let loading_f = loading; let mut loading_f = loading;
let error_f = error; let mut error_f = error;
async move { async move {
#[cfg(target_arch = "wasm32")] #[cfg(target_arch = "wasm32")]
{ {
@ -639,6 +643,7 @@ fn ServerStatusTab() -> Element {
/// ///
/// 护栏 4前端二次确认提交写操作前弹窗确认。 /// 护栏 4前端二次确认提交写操作前弹窗确认。
#[allow(non_snake_case)] #[allow(non_snake_case)]
#[cfg_attr(not(target_arch = "wasm32"), allow(unused_mut, unused_variables))]
fn SqlConsoleTab() -> Element { fn SqlConsoleTab() -> Element {
use crate::api::database::sql_console::SqlResult; use crate::api::database::sql_console::SqlResult;
#[cfg(target_arch = "wasm32")] #[cfg(target_arch = "wasm32")]
@ -654,7 +659,7 @@ fn SqlConsoleTab() -> Element {
let theme = use_theme(); let theme = use_theme();
let sql_text = use_signal(String::new); let sql_text = use_signal(String::new);
let result = use_signal(|| Option::<SqlResult>::None); let mut result = use_signal(|| Option::<SqlResult>::None);
let mut error = use_signal(|| Option::<String>::None); let mut error = use_signal(|| Option::<String>::None);
let mut running = use_signal(|| false); let mut running = use_signal(|| false);
// 选项 toggles // 选项 toggles
@ -672,9 +677,9 @@ fn SqlConsoleTab() -> Element {
// 初始化 CodeMirror + 拉取 schema 注入补全。仅 WASM。 // 初始化 CodeMirror + 拉取 schema 注入补全。仅 WASM。
#[cfg(target_arch = "wasm32")] #[cfg(target_arch = "wasm32")]
{ {
use dioxus::prelude::wasm_bindgen::closure::Closure; use wasm_bindgen::closure::Closure;
use_effect(move || { use_effect(move || {
if editor_handle().is_some() { if editor_handle.read().is_some() {
return; return;
} }
let mut text = sql_text; let mut text = sql_text;
@ -702,8 +707,12 @@ fn SqlConsoleTab() -> Element {
// 异步拉取 schema 注入补全 // 异步拉取 schema 注入补全
spawn(async move { spawn(async move {
if let Ok(schema) = get_db_schema().await { 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() { if let Some(h) = editor_handle.read().as_ref() {
h.instance().set_schema(&schema); h.instance().set_schema(&js);
}
} }
} }
}); });
@ -1040,6 +1049,7 @@ fn urlencode(s: &str) -> String {
/// 备份恢复 tab备份按钮 + 进度轮询 + 备份列表(下载/恢复/删除)。 /// 备份恢复 tab备份按钮 + 进度轮询 + 备份列表(下载/恢复/删除)。
#[allow(non_snake_case)] #[allow(non_snake_case)]
#[cfg_attr(not(target_arch = "wasm32"), allow(unused_mut, unused_variables))]
fn BackupTab() -> Element { fn BackupTab() -> Element {
use crate::api::database::backup::BackupInfo; use crate::api::database::backup::BackupInfo;
use crate::api::database::tasks::TaskProgress; use crate::api::database::tasks::TaskProgress;
@ -1049,23 +1059,25 @@ fn BackupTab() -> Element {
use crate::api::database::tasks::{get_task_progress, TaskStatus}; use crate::api::database::tasks::{get_task_progress, TaskStatus};
use crate::components::ui::{ADMIN_CARD_CLASS, ADMIN_TABLE_CLASS}; 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 backups = use_signal(Vec::<BackupInfo>::new);
let mut loading = use_signal(|| false); let mut loading = use_signal(|| false);
let error = use_signal(|| Option::<String>::None); let mut error = use_signal(|| Option::<String>::None);
// 当前进行中的任务(备份/恢复id + 进度 // 当前进行中的任务(备份/恢复id + 进度
let active_task_id: Signal<Option<String>> = use_signal(|| None); let active_task_id: Signal<Option<String>> = use_signal(|| None);
let active_progress = use_signal(|| Option::<TaskProgress>::None); let mut active_progress = use_signal(|| Option::<TaskProgress>::None);
// 待恢复的文件名(确认对话框用) // 待恢复的文件名(确认对话框用)
let mut pending_restore: Signal<Option<String>> = use_signal(|| None); let mut pending_restore: Signal<Option<String>> = use_signal(|| None);
let busy = use_signal(|| false); let mut busy = use_signal(|| false);
// 刷新备份列表 // 刷新备份列表
let mut refresh_list = move || { let mut refresh_list = move || {
loading.set(true); loading.set(true);
#[cfg(target_arch = "wasm32")] #[cfg(target_arch = "wasm32")]
{ {
let backups = backups; let mut backups = backups;
let error = error; let mut error = error;
spawn(async move { spawn(async move {
match list_backups().await { match list_backups().await {
Ok(list) => { Ok(list) => {
@ -1089,17 +1101,19 @@ fn BackupTab() -> Element {
// 恢复确认pending_restore 被设置时弹出原生 confirm确认后发起 restore_backup // 恢复确认pending_restore 被设置时弹出原生 confirm确认后发起 restore_backup
// 并开始进度轮询。用 use_future 响应 pending_restore 变化。 // 并开始进度轮询。用 use_future 响应 pending_restore 变化。
// _pending_for_confirm 被 clone 进 async moveFnMut 可重入),用 Option::clone 兜底。
let _pending_for_confirm = pending_restore.read().clone(); let _pending_for_confirm = pending_restore.read().clone();
use_future(move || { use_future(move || {
let pending_restore = pending_restore; let mut pending_restore = pending_restore;
let busy = busy; let mut busy = busy;
let active_progress = active_progress; let mut active_progress = active_progress;
let active_task_id = active_task_id; let mut active_task_id = active_task_id;
let error = error; let mut error = error;
let pending_for_confirm = _pending_for_confirm.clone();
async move { async move {
#[cfg(target_arch = "wasm32")] #[cfg(target_arch = "wasm32")]
{ {
let fname = match _pending_for_confirm { let fname = match pending_for_confirm {
Some(f) => f, Some(f) => f,
None => return, None => return,
}; };
@ -1128,22 +1142,24 @@ fn BackupTab() -> Element {
} }
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
{ {
let _ = (pending_restore, busy, active_progress, active_task_id, error); let _ = (pending_restore, busy, active_progress, active_task_id, error, pending_for_confirm);
} }
} }
}); });
// 任务进度轮询active_task_id 存在时每 1.5s 拉取进度Done/Failed 后停止 + 刷新列表 // 任务进度轮询active_task_id 存在时每 1.5s 拉取进度Done/Failed 后停止 + 刷新列表
// _task_id_for_poll 被 clone 进 async moveFnMut 可重入)。
let _task_id_for_poll = active_task_id.read().clone(); let _task_id_for_poll = active_task_id.read().clone();
use_future(move || { use_future(move || {
let active_task_id = active_task_id; let mut active_task_id = active_task_id;
let active_progress = active_progress; let mut active_progress = active_progress;
let backups_f = backups; let mut backups_f = backups;
let busy_f = busy; let mut busy_f = busy;
let task_id_for_poll = _task_id_for_poll.clone();
async move { async move {
#[cfg(target_arch = "wasm32")] #[cfg(target_arch = "wasm32")]
{ {
let tid = match _task_id_for_poll { let tid = match task_id_for_poll {
Some(t) => t, Some(t) => t,
None => return, None => return,
}; };
@ -1174,7 +1190,7 @@ fn BackupTab() -> Element {
} }
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
{ {
let _ = (active_task_id, active_progress, backups_f, busy_f); let _ = (active_task_id, active_progress, backups_f, busy_f, task_id_for_poll);
} }
} }
}); });
@ -1209,7 +1225,7 @@ fn BackupTab() -> Element {
{ {
busy.set(true); busy.set(true);
active_progress.set(None); active_progress.set(None);
let active_task_id = active_task_id; let mut active_task_id = active_task_id;
spawn(async move { spawn(async move {
match create_backup().await { match create_backup().await {
Ok(id) => active_task_id.set(Some(id)), Ok(id) => active_task_id.set(Some(id)),
@ -1276,8 +1292,8 @@ fn BackupTab() -> Element {
size_str: size_str.clone(), size_str: size_str.clone(),
dl_url: dl_url.clone(), dl_url: dl_url.clone(),
busy: is_busy, busy: is_busy,
on_restore: move |f| pending_restore.set(Some(f)), on_restore: move |f: String| pending_restore.set(Some(f)),
on_delete: move |_fname_del| { on_delete: move |_fname_del: String| {
#[cfg(target_arch = "wasm32")] #[cfg(target_arch = "wasm32")]
{ {
let fname_del = _fname_del; let fname_del = _fname_del;
@ -1288,7 +1304,7 @@ fn BackupTab() -> Element {
}) })
== Some(true); == Some(true);
if confirmed { if confirmed {
let backups = backups; let mut backups = backups;
spawn(async move { spawn(async move {
let _ = delete_backup(fname_del.clone()).await; let _ = delete_backup(fname_del.clone()).await;
if let Ok(list) = list_backups().await { if let Ok(list) = list_backups().await {

View File

@ -5,7 +5,10 @@
//! //!
//! 采样间隔由环境变量 `SYSINFO_SAMPLE_SECS` 配置(默认 0.5 秒,支持小数如 0.1)。 //! 采样间隔由环境变量 `SYSINFO_SAMPLE_SECS` 配置(默认 0.5 秒,支持小数如 0.1)。
// LazyLock / Duration 仅 server 构建的采样任务用到WASM 端只序列化 SystemSnapshot。
#[cfg(feature = "server")]
use std::sync::LazyLock; use std::sync::LazyLock;
#[cfg(feature = "server")]
use std::time::Duration; use std::time::Duration;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};