Skip to main content

yggdrasil/pages/admin/system/
backup.rs

1//! 备份恢复 tab。
2
3use dioxus::prelude::*;
4
5use crate::components::ui::{LoadingButton, BTN_OUTLINE, BTN_TEXT_AMBER, BTN_TEXT_RED};
6
7use super::format_bytes;
8
9/// 备份恢复 tab:备份按钮 + 进度轮询 + 备份列表(下载/恢复/删除)。
10#[allow(non_snake_case)]
11#[cfg_attr(not(target_arch = "wasm32"), allow(unused_mut, unused_variables))]
12pub(super) fn BackupTab() -> Element {
13    use crate::api::database::backup::BackupInfo;
14    #[cfg(target_arch = "wasm32")]
15    use crate::api::database::backup::{
16        create_backup, delete_backup, list_backups, restore_backup,
17    };
18    use crate::api::database::tasks::TaskProgress;
19    #[cfg(target_arch = "wasm32")]
20    use crate::api::database::tasks::{get_task_progress, TaskStatus};
21    use crate::components::ui::{ADMIN_CARD_CLASS, ADMIN_TABLE_CLASS};
22
23    // backups/active_task_id 仅在闭包内的重绑定副本上 .set()(如 backups_f),
24    // 外层绑定本身不改值,故无需 mut。
25    let backups = use_signal(Vec::<BackupInfo>::new);
26    let mut loading = use_signal(|| false);
27    let mut error = use_signal(|| Option::<String>::None);
28    // 当前进行中的任务(备份/恢复)id + 进度
29    let active_task_id: Signal<Option<String>> = use_signal(|| None);
30    let mut active_progress = use_signal(|| Option::<TaskProgress>::None);
31    let mut busy = use_signal(|| false);
32
33    // 刷新备份列表
34    let mut refresh_list = move || {
35        loading.set(true);
36        #[cfg(target_arch = "wasm32")]
37        {
38            let mut backups = backups;
39            let mut error = error;
40            spawn(async move {
41                match list_backups().await {
42                    Ok(list) => {
43                        backups.set(list);
44                        error.set(None);
45                    }
46                    Err(e) => error.set(Some(e.to_string())),
47                }
48                loading.set(false);
49            });
50        }
51        #[cfg(not(target_arch = "wasm32"))]
52        {
53            loading.set(false);
54        }
55    };
56
57    use_effect(move || {
58        refresh_list();
59    });
60
61    // 任务进度轮询:active_task_id 存在时每 1.5s 拉取进度,Done/Failed 后停止 + 刷新列表。
62    //
63    // 同样用长生命周期 loop + 循环内读 active_task_id() 的模式。原先在挂载时把
64    // active_task_id 快照进 _task_id_for_poll(彼时为 None),use_future 只跑一次
65    // 即 return;用户点"创建备份"后 create_backup 返回 task id 并设置信号,但
66    // future 已结束 → 轮询永不启动,busy 永远为 true(用户报告的 bug)。
67    use_future(move || {
68        let mut active_task_id = active_task_id;
69        let mut active_progress = active_progress;
70        let mut backups_f = backups;
71        let mut busy_f = busy;
72        async move {
73            #[cfg(target_arch = "wasm32")]
74            {
75                loop {
76                    let tid = match active_task_id() {
77                        Some(t) => t,
78                        None => {
79                            // 空闲:短 yield,最多 200ms 后响应新任务。
80                            crate::utils::time::sleep_ms(200).await;
81                            continue;
82                        }
83                    };
84                    // 有任务在途:进入 1.5s 轮询,直到 Done/Failed/出错。
85                    loop {
86                        crate::utils::time::sleep_ms(1500).await;
87                        match get_task_progress(tid.clone()).await {
88                            Ok(p) => {
89                                let done =
90                                    p.status == TaskStatus::Done || p.status == TaskStatus::Failed;
91                                active_progress.set(Some(p));
92                                if done {
93                                    // 刷新列表(备份完成后新文件出现)并清理任务态
94                                    if let Ok(list) = list_backups().await {
95                                        backups_f.set(list);
96                                    }
97                                    active_task_id.set(None);
98                                    busy_f.set(false);
99                                    break;
100                                }
101                            }
102                            Err(_) => {
103                                active_task_id.set(None);
104                                busy_f.set(false);
105                                break;
106                            }
107                        }
108                    }
109                    // 内层 loop 退出后回到外层,继续等待下一个任务或空闲。
110                }
111            }
112            #[cfg(not(target_arch = "wasm32"))]
113            {
114                let _ = (active_task_id, active_progress, backups_f, busy_f);
115            }
116        }
117    });
118
119    let current_backups = backups.read().clone();
120    let current_error = error.read().clone();
121    let current_progress = active_progress.read().clone();
122    let is_busy = busy();
123    // 预格式化备份行(避免在 rsx for 循环体内 let / 格式化)。
124    // 每行:(filename, mode, size_str, dl_url)
125    let backup_rows: Vec<(String, String, String, String)> = current_backups
126        .iter()
127        .map(|b| {
128            (
129                b.filename.clone(),
130                b.mode.clone(),
131                format_bytes(b.size_bytes as i64),
132                format!("/api/database/backups/{}", urlencode_dl(&b.filename)),
133            )
134        })
135        .collect();
136
137    rsx! {
138        div { class: "space-y-4",
139            // 操作栏
140            div { class: "flex items-center gap-3",
141                LoadingButton {
142                    label: "创建备份".to_string(),
143                    loading: is_busy,
144                    variant: "sm",
145                    onclick: move |_| {
146                        #[cfg(target_arch = "wasm32")]
147                        {
148                            busy.set(true);
149                            active_progress.set(None);
150                            let mut active_task_id = active_task_id;
151                            spawn(async move {
152                                match create_backup().await {
153                                    Ok(id) => active_task_id.set(Some(id)),
154                                    Err(e) => {
155                                        error.set(Some(e.to_string()));
156                                        busy.set(false);
157                                    }
158                                }
159                            });
160                        }
161                    },
162                }
163                button {
164                    class: "{BTN_OUTLINE}",
165                    disabled: loading() || is_busy,
166                    onclick: move |_| refresh_list(),
167                    "刷新列表"
168                }
169            }
170
171            // 进度
172            if let Some(p) = current_progress {
173                div { class: "{ADMIN_CARD_CLASS} p-4",
174                    div { class: "flex items-center justify-between mb-2",
175                        span { class: "text-sm font-medium text-paper-primary", "{p.stage}" }
176                        span { class: "text-sm text-paper-secondary", "{p.percent}%" }
177                    }
178                    div { class: "w-full bg-paper-entry rounded-full h-2 overflow-hidden",
179                        div {
180                            class: "bg-paper-accent h-full transition-all",
181                            style: "width: {p.percent}%",
182                        }
183                    }
184                    if let Some(detail) = p.detail {
185                        p { class: "text-xs text-paper-secondary mt-2", "{detail}" }
186                    }
187                    if let Some(err) = p.error {
188                        p { class: "text-xs text-red-600 dark:text-red-400 mt-2",
189                            "错误:{err}"
190                        }
191                    }
192                }
193            }
194
195            // 错误
196            if let Some(err) = current_error {
197                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",
198                    "{err}"
199                }
200            }
201
202            // 备份列表
203            if !current_backups.is_empty() {
204                div { class: "{ADMIN_TABLE_CLASS}",
205                    div { class: "overflow-x-auto",
206                        table { class: "w-full text-sm",
207                            thead {
208                                tr { class: "border-b border-paper-border text-left text-paper-secondary",
209                                    th { class: "px-4 py-2 font-medium", "文件名" }
210                                    th { class: "px-4 py-2 font-medium", "模式" }
211                                    th { class: "px-4 py-2 font-medium text-right",
212                                        "大小"
213                                    }
214                                    th { class: "px-4 py-2 font-medium text-right",
215                                        "操作"
216                                    }
217                                }
218                            }
219                            tbody {
220                                for (fname, mode, size_str, dl_url) in backup_rows.iter() {
221                                    BackupRow {
222                                        key: "{fname}",
223                                        filename: fname.clone(),
224                                        mode: mode.clone(),
225                                        size_str: size_str.clone(),
226                                        dl_url: dl_url.clone(),
227                                        busy: is_busy,
228                                        // 恢复:确认已在 BackupRow 的 Popover 内完成,
229                                        // 这里直接发起 restore_backup 并交由轮询 use_future 接管。
230                                        // pending_restore signal + 确认 use_future 链路已移除
231                                        //(原生 confirm 是阻塞式才需要那套间接机制)。
232                                        on_restore: move |f: String| {
233                                            #[cfg(target_arch = "wasm32")]
234                                            {
235                                                let mut busy = busy;
236                                                let mut active_progress = active_progress;
237                                                let mut active_task_id = active_task_id;
238                                                let mut error = error;
239                                                spawn(async move {
240                                                    busy.set(true);
241                                                    active_progress.set(None);
242                                                    match restore_backup(f, true).await {
243                                                        Ok(id) => active_task_id.set(Some(id)),
244                                                        Err(e) => {
245                                                            error.set(Some(e.to_string()));
246                                                            busy.set(false);
247                                                        }
248                                                    }
249                                                });
250                                            }
251                                        },
252                                        // 删除:确认已在 BackupRow 的 Popover 内完成,
253                                        // 直接执行 delete_backup + 刷新列表。
254                                        on_delete: move |fname_del: String| {
255                                            #[cfg(target_arch = "wasm32")]
256                                            {
257                                                let mut backups = backups;
258                                                spawn(async move {
259                                                    let _ = delete_backup(fname_del).await;
260                                                    if let Ok(list) = list_backups().await {
261                                                        backups.set(list);
262                                                    }
263                                                });
264                                            }
265                                        },
266                                    }
267                                }
268                            }
269                        }
270                    }
271                }
272            } else if !loading() {
273                div { class: "text-paper-secondary text-sm py-4", "暂无备份文件" }
274            }
275
276            p { class: "text-xs text-paper-secondary",
277                "备份优先用 pg_dump(含 schema),不可用时回退纯 SQL(仅数据)。"
278                "恢复仅接受本系统生成的备份,且会覆盖现有数据。"
279            }
280        }
281    }
282}
283/// 下载链接用的 URL 编码(wasm32 才编码,server 端原样返回——rsx 构造 dl_url 时两端都调)。
284/// 自包含实现,不跨文件依赖 export.rs 的 urlencode。
285fn urlencode_dl(s: &str) -> String {
286    #[cfg(target_arch = "wasm32")]
287    {
288        let mut out = String::with_capacity(s.len());
289        for b in s.bytes() {
290            match b {
291                b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
292                    out.push(b as char);
293                }
294                b' ' => out.push('+'),
295                _ => out.push_str(&format!("%{:02X}", b)),
296            }
297        }
298        out
299    }
300    #[cfg(not(target_arch = "wasm32"))]
301    {
302        s.to_string()
303    }
304}
305/// 备份列表单行(抽取为子组件:各自 scope 内 let/clone 不冲突)。
306///
307/// 删除/恢复不再用浏览器原生 confirm(),改用 [`Popover`](crate::components::ui::Popover) 确认框(`position:fixed`
308/// 逃出表格 `overflow-hidden`)。点击按钮读 `MouseEvent::client_coordinates()` 作为
309/// popover 锚点,`confirm` 按钮回调父组件的 `on_delete`/`on_restore`。
310#[derive(Props, Clone, PartialEq)]
311struct BackupRowProps {
312    filename: String,
313    mode: String,
314    size_str: String,
315    dl_url: String,
316    busy: bool,
317    on_restore: Callback<String>,
318    on_delete: Callback<String>,
319}
320
321#[component]
322#[cfg_attr(not(target_arch = "wasm32"), allow(unused_mut, unused_variables))]
323fn BackupRow(props: BackupRowProps) -> Element {
324    use crate::components::ui::Popover;
325    use crate::components::ui::BTN_DANGER_OUTLINE;
326
327    // Callback 是 Copy,直接复用;filename 需 clone(确认框闭包各取一份)。
328    let on_restore = props.on_restore;
329    let on_delete = props.on_delete;
330    let fname_for_restore = props.filename.clone();
331    let fname_for_delete = props.filename.clone();
332
333    // Popover 状态:哪个动作的确认框打开 + 锚点坐标。None = 都关闭。
334    // 用一个 String("delete"/"restore") 而非两个 bool,避免同时开两个 popover。
335    let mut open_action = use_signal(|| Option::<String>::None);
336    // 锚点坐标:按钮点击的视口坐标(client_coordinates)。
337    let mut anchor_x = use_signal(|| 0i32);
338    let mut anchor_y = use_signal(|| 0i32);
339
340    rsx! {
341        tr { class: "border-b border-paper-border last:border-0 hover:bg-paper-entry transition-colors",
342            td { class: "px-4 py-2 font-mono text-xs text-paper-primary", "{props.filename}" }
343            td { class: "px-4 py-2 text-paper-secondary", "{props.mode}" }
344            td { class: "px-4 py-2 text-right text-paper-secondary", "{props.size_str}" }
345            td { class: "px-4 py-2 text-right whitespace-nowrap",
346                a {
347                    class: "text-xs text-paper-accent hover:underline mr-3",
348                    href: "{props.dl_url}",
349                    download: "",
350                    "下载"
351                }
352                button {
353                    class: "{BTN_TEXT_AMBER} mr-3 disabled:opacity-50",
354                    disabled: props.busy,
355                    // 点击记录坐标并打开恢复确认 popover。client_coordinates 两端编译。
356                    onclick: move |e| {
357                        let c = e.client_coordinates();
358                        anchor_x.set(c.x as i32);
359                        anchor_y.set(c.y as i32);
360                        open_action.set(Some("restore".to_string()));
361                    },
362                    "恢复"
363                }
364                button {
365                    class: "{BTN_TEXT_RED} disabled:opacity-50",
366                    disabled: props.busy,
367                    onclick: move |e| {
368                        let c = e.client_coordinates();
369                        anchor_x.set(c.x as i32);
370                        anchor_y.set(c.y as i32);
371                        open_action.set(Some("delete".to_string()));
372                    },
373                    "删除"
374                }
375            }
376
377            // 恢复确认 popover
378            Popover {
379                open: open_action().as_deref() == Some("restore"),
380                anchor_x: anchor_x(),
381                anchor_y: anchor_y(),
382                placement: "bottom",
383                on_close: move |_| open_action.set(None),
384                div { class: "w-64 space-y-3",
385                    p { class: "text-sm text-paper-primary leading-relaxed",
386                        "恢复将覆盖现有数据,确认恢复 "
387                        span { class: "font-mono text-xs break-all", "{props.filename}" }
388                        "?"
389                    }
390                    p { class: "text-xs text-paper-secondary",
391                        "仅本系统生成的备份可恢复。"
392                    }
393                    div { class: "flex justify-end gap-2 pt-1",
394                        button {
395                            class: "px-3 py-1.5 text-xs text-paper-secondary hover:text-paper-primary transition-colors cursor-pointer",
396                            onclick: move |_| open_action.set(None),
397                            "取消"
398                        }
399                        button {
400                            class: "{BTN_DANGER_OUTLINE}",
401                            onclick: move |_| {
402                                open_action.set(None);
403                                on_restore.call(fname_for_restore.clone());
404                            },
405                            "确认恢复"
406                        }
407                    }
408                }
409            }
410
411            // 删除确认 popover
412            Popover {
413                open: open_action().as_deref() == Some("delete"),
414                anchor_x: anchor_x(),
415                anchor_y: anchor_y(),
416                placement: "bottom",
417                on_close: move |_| open_action.set(None),
418                div { class: "w-64 space-y-3",
419                    p { class: "text-sm text-paper-primary",
420                        "确认删除 "
421                        span { class: "font-mono text-xs break-all", "{props.filename}" }
422                        "?"
423                    }
424                    div { class: "flex justify-end gap-2 pt-1",
425                        button {
426                            class: "px-3 py-1.5 text-xs text-paper-secondary hover:text-paper-primary transition-colors cursor-pointer",
427                            onclick: move |_| open_action.set(None),
428                            "取消"
429                        }
430                        button {
431                            class: "{BTN_DANGER_OUTLINE}",
432                            onclick: move |_| {
433                                open_action.set(None);
434                                on_delete.call(fname_for_delete.clone());
435                            },
436                            "确认删除"
437                        }
438                    }
439                }
440            }
441        }
442    }
443}