Skip to main content

yggdrasil/pages/admin/system/
backup.rs

1//! 备份恢复 tab。
2
3use dioxus::prelude::*;
4
5use crate::components::ui::{LoadingButton, BTN_GHOST, BTN_OUTLINE, BTN_TEXT_AMBER, BTN_TEXT_RED};
6
7use crate::utils::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    // 导入备份的文件选择事件取底层 web_sys::File(write.rs L30-34 同款 cfg 门控惯例;
23    // HasFileData 已在 prelude 中,无需显式导入)。
24    #[cfg(target_arch = "wasm32")]
25    use dioxus::web::{WebEventExt, WebFileExt};
26    #[cfg(target_arch = "wasm32")]
27    use wasm_bindgen::JsCast;
28
29    // backups/active_task_id 仅在闭包内的重绑定副本上 .set()(如 backups_f),
30    // 外层绑定本身不改值,故无需 mut。
31    let backups = use_signal(Vec::<BackupInfo>::new);
32    let mut loading = use_signal(|| false);
33    let mut error = use_signal(|| Option::<String>::None);
34    // 当前进行中的任务(备份/恢复)id + 进度
35    let active_task_id: Signal<Option<String>> = use_signal(|| None);
36    let mut active_progress = use_signal(|| Option::<TaskProgress>::None);
37    let mut busy = use_signal(|| false);
38    // 导入上传进行中(同步请求,无后台任务进度,仅按钮不定态)
39    let mut import_busy = use_signal(|| false);
40
41    // 刷新备份列表
42    let mut refresh_list = move || {
43        loading.set(true);
44        #[cfg(target_arch = "wasm32")]
45        {
46            let mut backups = backups;
47            let mut error = error;
48            spawn(async move {
49                match list_backups().await {
50                    Ok(list) => {
51                        backups.set(list);
52                        error.set(None);
53                    }
54                    Err(e) => error.set(Some(e.to_string())),
55                }
56                loading.set(false);
57            });
58        }
59        #[cfg(not(target_arch = "wasm32"))]
60        {
61            loading.set(false);
62        }
63    };
64
65    use_effect(move || {
66        refresh_list();
67    });
68
69    // 任务进度轮询:active_task_id 存在时每 1.5s 拉取进度,Done/Failed 后停止 + 刷新列表。
70    //
71    // 同样用长生命周期 loop + 循环内读 active_task_id() 的模式。原先在挂载时把
72    // active_task_id 快照进 _task_id_for_poll(彼时为 None),use_future 只跑一次
73    // 即 return;用户点"创建备份"后 create_backup 返回 task id 并设置信号,但
74    // future 已结束 → 轮询永不启动,busy 永远为 true(用户报告的 bug)。
75    use_future(move || {
76        let mut active_task_id = active_task_id;
77        let mut active_progress = active_progress;
78        let mut backups_f = backups;
79        let mut busy_f = busy;
80        async move {
81            #[cfg(target_arch = "wasm32")]
82            {
83                loop {
84                    let tid = match active_task_id() {
85                        Some(t) => t,
86                        None => {
87                            // 空闲:短 yield,最多 200ms 后响应新任务。
88                            crate::utils::time::sleep_ms(200).await;
89                            continue;
90                        }
91                    };
92                    // 有任务在途:进入 1.5s 轮询,直到 Done/Failed/出错。
93                    loop {
94                        crate::utils::time::sleep_ms(1500).await;
95                        match get_task_progress(tid.clone()).await {
96                            Ok(p) => {
97                                let done =
98                                    p.status == TaskStatus::Done || p.status == TaskStatus::Failed;
99                                active_progress.set(Some(p));
100                                if done {
101                                    // 刷新列表(备份完成后新文件出现)并清理任务态
102                                    if let Ok(list) = list_backups().await {
103                                        backups_f.set(list);
104                                    }
105                                    active_task_id.set(None);
106                                    busy_f.set(false);
107                                    break;
108                                }
109                            }
110                            Err(_) => {
111                                active_task_id.set(None);
112                                busy_f.set(false);
113                                break;
114                            }
115                        }
116                    }
117                    // 内层 loop 退出后回到外层,继续等待下一个任务或空闲。
118                }
119            }
120            #[cfg(not(target_arch = "wasm32"))]
121            {
122                let _ = (active_task_id, active_progress, backups_f, busy_f);
123            }
124        }
125    });
126
127    let current_backups = backups.read().clone();
128    let current_error = error.read().clone();
129    let current_progress = active_progress.read().clone();
130    let is_busy = busy();
131
132    rsx! {
133        div { class: "space-y-4",
134            // 自动备份设置卡片(顶部,与手动按钮/列表同屏)
135            BackupSettingsCard {}
136
137            // 操作栏
138            div { class: "flex items-center gap-3",
139                LoadingButton {
140                    label: "创建备份".to_string(),
141                    loading: is_busy,
142                    variant: "sm",
143                    onclick: move |_| {
144                        #[cfg(target_arch = "wasm32")]
145                        {
146                            busy.set(true);
147                            active_progress.set(None);
148                            let mut active_task_id = active_task_id;
149                            spawn(async move {
150                                match create_backup().await {
151                                    Ok(id) => active_task_id.set(Some(id)),
152                                    Err(e) => {
153                                        error.set(Some(e.to_string()));
154                                        busy.set(false);
155                                    }
156                                }
157                            });
158                        }
159                    },
160                }
161                button {
162                    class: "{BTN_OUTLINE}",
163                    disabled: loading() || is_busy,
164                    onclick: move |_| refresh_list(),
165                    "刷新列表"
166                }
167                // 导入备份:隐藏 file input 包在 label 内,原生点击唤起文件选择
168                //(asset_picker.rs 同款惯例,无需 JS 触发)。两步式——导入仅入库
169                // backups/ 并刷新列表,恢复仍走列表行的既有管线(见 ADR 0001)。
170                label {
171                    class: "inline-flex cursor-pointer items-center justify-center {BTN_OUTLINE}",
172                    class: if import_busy() || is_busy { "pointer-events-none opacity-60" } else { "" },
173                    if import_busy() { "导入中…" } else { "导入备份" }
174                    input {
175                        r#type: "file",
176                        accept: ".sql",
177                        class: "hidden",
178                        disabled: import_busy() || is_busy,
179                        onchange: move |evt| {
180                            #[cfg(target_arch = "wasm32")]
181                            {
182                                if import_busy() {
183                                    return;
184                                }
185                                let Some(file) = evt.files().into_iter().next() else {
186                                    return;
187                                };
188                                let Some(web_file) = file.get_web_file() else {
189                                    return;
190                                };
191                                // 重置 input value,允许重选同一文件再次触发 change。
192                                if let Some(input) = evt
193                                    .try_as_web_event()
194                                    .and_then(|e| e.target())
195                                    .and_then(|t| t.dyn_into::<web_sys::HtmlInputElement>().ok())
196                                {
197                                    input.set_value("");
198                                }
199                                // 客户端预检(服务端仍兜底):.sql 后缀 + 体积上限。
200                                if !web_file.name().ends_with(".sql") {
201                                    error.set(Some("仅接受 .sql 备份文件".to_string()));
202                                    return;
203                                }
204                                if web_file.size() as u64 > IMPORT_MAX_BYTES_CLIENT {
205                                    error.set(Some(format!(
206                                        "文件超过导入上限({}MB)",
207                                        IMPORT_MAX_BYTES_CLIENT / 1024 / 1024
208                                    )));
209                                    return;
210                                }
211                                import_busy.set(true);
212                                error.set(None);
213                                let mut backups_f = backups;
214                                spawn(async move {
215                                    match crate::utils::web_upload::post_multipart_file(
216                                        "/api/database/backups/import",
217                                        "file",
218                                        &web_file,
219                                    )
220                                    .await
221                                    {
222                                        Ok(_) => {
223                                            if let Ok(list) = list_backups().await {
224                                                backups_f.set(list);
225                                            }
226                                        }
227                                        Err(msg) => error.set(Some(msg)),
228                                    }
229                                    import_busy.set(false);
230                                });
231                            }
232                        },
233                    }
234                }
235            }
236
237            // 进度
238            if let Some(p) = current_progress {
239                div { class: "{ADMIN_CARD_CLASS} p-4",
240                    div { class: "flex items-center justify-between mb-2",
241                        span { class: "text-sm font-medium text-paper-primary", "{p.stage}" }
242                        span { class: "text-sm text-paper-secondary", "{p.percent}%" }
243                    }
244                    div { class: "w-full bg-paper-entry rounded-full h-2 overflow-hidden",
245                        div {
246                            class: "bg-paper-accent h-full transition-all",
247                            style: "width: {p.percent}%",
248                        }
249                    }
250                    if let Some(detail) = p.detail {
251                        p { class: "text-xs text-paper-secondary mt-2", "{detail}" }
252                    }
253                    if let Some(err) = p.error {
254                        p { class: "text-xs text-red-600 dark:text-red-400 mt-2",
255                            "错误:{err}"
256                        }
257                    }
258                }
259            }
260
261            // 错误
262            if let Some(err) = current_error {
263                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",
264                    "{err}"
265                }
266            }
267
268            // 备份列表
269            if !current_backups.is_empty() {
270                div { class: "{ADMIN_TABLE_CLASS}",
271                    div { class: "overflow-x-auto",
272                        table { class: "w-full text-sm",
273                            thead {
274                                tr { class: "border-b border-paper-border text-left text-paper-secondary",
275                                    th { class: "px-4 py-2 font-medium", "文件名" }
276                                    th { class: "px-4 py-2 font-medium", "来源" }
277                                    th { class: "px-4 py-2 font-medium", "模式" }
278                                    th { class: "px-4 py-2 font-medium text-right",
279                                        "大小"
280                                    }
281                                    th { class: "px-4 py-2 font-medium text-right",
282                                        "操作"
283                                    }
284                                }
285                            }
286                            tbody {
287                                for b in current_backups.iter() {
288                                    BackupRow {
289                                        key: "{b.filename}",
290                                        info: b.clone(),
291                                        busy: is_busy,
292                                        // 恢复:确认已在 BackupRow 的 Popover 内完成,
293                                        // 这里直接发起 restore_backup 并交由轮询 use_future 接管。
294                                        // pending_restore signal + 确认 use_future 链路已移除
295                                        //(原生 confirm 是阻塞式才需要那套间接机制)。
296                                        on_restore: move |f: String| {
297                                            #[cfg(target_arch = "wasm32")]
298                                            {
299                                                let mut busy = busy;
300                                                let mut active_progress = active_progress;
301                                                let mut active_task_id = active_task_id;
302                                                let mut error = error;
303                                                spawn(async move {
304                                                    busy.set(true);
305                                                    active_progress.set(None);
306                                                    match restore_backup(f, true).await {
307                                                        Ok(id) => active_task_id.set(Some(id)),
308                                                        Err(e) => {
309                                                            error.set(Some(e.to_string()));
310                                                            busy.set(false);
311                                                        }
312                                                    }
313                                                });
314                                            }
315                                        },
316                                        // 删除:确认已在 BackupRow 的 Popover 内完成,
317                                        // 直接执行 delete_backup + 刷新列表。
318                                        on_delete: move |fname_del: String| {
319                                            #[cfg(target_arch = "wasm32")]
320                                            {
321                                                let mut backups = backups;
322                                                spawn(async move {
323                                                    let _ = delete_backup(fname_del).await;
324                                                    if let Ok(list) = list_backups().await {
325                                                        backups.set(list);
326                                                    }
327                                                });
328                                            }
329                                        },
330                                    }
331                                }
332                            }
333                        }
334                    }
335                }
336            } else if !loading() {
337                div { class: "text-paper-secondary text-sm py-4", "暂无备份文件" }
338            }
339
340            p { class: "text-xs text-paper-secondary",
341                "备份优先用 pg_dump(含 schema),不可用时回退纯 SQL(仅数据,且不可经 psql 恢复)。"
342                "恢复仅接受本系统生成的备份,且只恢复数据库;uploads 素材需从配对 tar.gz 手动还原。"
343                "导入仅接受本系统生成的 .sql 备份(签名校验,单文件默认上限 512MB);"
344                "以 auto_ 前缀导入的文件会参与自动轮转。"
345            }
346        }
347    }
348}
349/// 客户端导入体积预检(字节):镜像服务端 `BACKUP_IMPORT_MAX_MB` 默认值 512MB。
350/// 仅为早失败;权威校验在服务端(env 可调大,此处不追求同步)。
351#[cfg(target_arch = "wasm32")]
352const IMPORT_MAX_BYTES_CLIENT: u64 = 512 * 1024 * 1024;
353
354/// 下载链接用的 URL 编码(wasm32 才编码,server 端原样返回——rsx 构造 dl_url 时两端都调)。
355/// 自包含实现,不跨文件依赖 export.rs 的 urlencode。
356fn urlencode_dl(s: &str) -> String {
357    #[cfg(target_arch = "wasm32")]
358    {
359        let mut out = String::with_capacity(s.len());
360        for b in s.bytes() {
361            match b {
362                b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
363                    out.push(b as char);
364                }
365                b' ' => out.push('+'),
366                _ => out.push_str(&format!("%{:02X}", b)),
367            }
368        }
369        out
370    }
371    #[cfg(not(target_arch = "wasm32"))]
372    {
373        s.to_string()
374    }
375}
376/// 备份列表单行(抽取为子组件:各自 scope 内 let/clone 不冲突)。
377///
378/// 删除/恢复不再用浏览器原生 confirm(),改用 [`Popover`](crate::components::ui::Popover) 确认框(`position:fixed`
379/// 逃出表格 `overflow-hidden`)。点击按钮读 `MouseEvent::client_coordinates()` 作为
380/// popover 锚点,`confirm` 按钮回调父组件的 `on_delete`/`on_restore`。
381#[derive(Props, Clone, PartialEq)]
382struct BackupRowProps {
383    info: crate::api::database::backup::BackupInfo,
384    busy: bool,
385    on_restore: Callback<String>,
386    on_delete: Callback<String>,
387}
388
389#[component]
390#[cfg_attr(not(target_arch = "wasm32"), allow(unused_mut, unused_variables))]
391fn BackupRow(props: BackupRowProps) -> Element {
392    use crate::components::ui::Popover;
393    use crate::components::ui::BTN_DANGER_OUTLINE;
394
395    // Callback 是 Copy,直接复用;filename 需 clone(确认框闭包各取一份)。
396    let on_restore = props.on_restore;
397    let on_delete = props.on_delete;
398    let fname_for_restore = props.info.filename.clone();
399    let fname_for_delete = props.info.filename.clone();
400
401    // 展示用派生值(纯计算,render 内联合法)。
402    let dl_url = format!(
403        "/api/database/backups/{}",
404        urlencode_dl(&props.info.filename)
405    );
406    let size_str = format_bytes(props.info.size_bytes as i64);
407    let (origin_label, origin_class) = if props.info.origin == "auto" {
408        ("自动", "text-paper-accent border-paper-accent/40")
409    } else {
410        ("手动", "text-paper-secondary border-paper-border")
411    };
412    let uploads_dl = props.info.uploads_filename.as_ref().map(|f| {
413        (
414            format_bytes(props.info.uploads_size_bytes.unwrap_or(0) as i64),
415            format!("/api/database/backups/{}", urlencode_dl(f)),
416        )
417    });
418
419    // Popover 状态:哪个动作的确认框打开 + 锚点坐标。None = 都关闭。
420    // 用一个 String("delete"/"restore") 而非两个 bool,避免同时开两个 popover。
421    let mut open_action = use_signal(|| Option::<String>::None);
422    // 锚点坐标:按钮点击的视口坐标(client_coordinates)。
423    let mut anchor_x = use_signal(|| 0i32);
424    let mut anchor_y = use_signal(|| 0i32);
425
426    rsx! {
427        tr { class: "border-b border-paper-border last:border-0 hover:bg-paper-entry transition-colors",
428            td { class: "px-4 py-2 font-mono text-xs text-paper-primary", "{props.info.filename}" }
429            td { class: "px-4 py-2",
430                span { class: "inline-block px-1.5 py-0.5 text-xs rounded border {origin_class}",
431                    "{origin_label}"
432                }
433            }
434            td { class: "px-4 py-2 text-paper-secondary", "{props.info.mode}" }
435            td { class: "px-4 py-2 text-right text-paper-secondary",
436                "{size_str}"
437                if let Some((up_size, _)) = &uploads_dl {
438                    div { class: "text-xs text-paper-tertiary", "素材 {up_size}" }
439                }
440            }
441            td { class: "px-4 py-2 text-right whitespace-nowrap",
442                a {
443                    class: "text-xs text-paper-accent hover:underline mr-3",
444                    href: "{dl_url}",
445                    download: "",
446                    "下载"
447                }
448                if let Some((_, up_url)) = &uploads_dl {
449                    a {
450                        class: "text-xs text-paper-accent hover:underline mr-3",
451                        href: "{up_url}",
452                        download: "",
453                        "素材"
454                    }
455                }
456                button {
457                    class: "{BTN_TEXT_AMBER} mr-3 disabled:opacity-50",
458                    disabled: props.busy,
459                    // 点击记录坐标并打开恢复确认 popover。client_coordinates 两端编译。
460                    onclick: move |e| {
461                        let c = e.client_coordinates();
462                        anchor_x.set(c.x as i32);
463                        anchor_y.set(c.y as i32);
464                        open_action.set(Some("restore".to_string()));
465                    },
466                    "恢复"
467                }
468                button {
469                    class: "{BTN_TEXT_RED} disabled:opacity-50",
470                    disabled: props.busy,
471                    onclick: move |e| {
472                        let c = e.client_coordinates();
473                        anchor_x.set(c.x as i32);
474                        anchor_y.set(c.y as i32);
475                        open_action.set(Some("delete".to_string()));
476                    },
477                    "删除"
478                }
479            }
480
481            // 恢复确认 popover。操作列贴视口右缘,居中面板会越界——
482            // align="end" 让面板右缘贴点击点、向左延伸进视口。
483            Popover {
484                open: open_action().as_deref() == Some("restore"),
485                anchor_x: anchor_x(),
486                anchor_y: anchor_y(),
487                placement: "bottom",
488                align: "end",
489                on_close: move |_| open_action.set(None),
490                div { class: "w-64 space-y-3",
491                    p { class: "text-sm text-paper-primary leading-relaxed",
492                        "恢复将覆盖现有数据,确认恢复 "
493                        span { class: "font-mono text-xs break-all", "{props.info.filename}" }
494                        "?"
495                    }
496                    p { class: "text-xs text-paper-secondary",
497                        "仅恢复数据库;uploads 素材不随恢复还原,需手动从配对压缩包恢复。"
498                    }
499                    div { class: "flex justify-end gap-2 pt-1",
500                        button {
501                            class: "{BTN_GHOST}",
502                            onclick: move |_| open_action.set(None),
503                            "取消"
504                        }
505                        button {
506                            class: "{BTN_DANGER_OUTLINE}",
507                            onclick: move |_| {
508                                open_action.set(None);
509                                on_restore.call(fname_for_restore.clone());
510                            },
511                            "确认恢复"
512                        }
513                    }
514                }
515            }
516
517            // 删除确认 popover(同 align="end",防右缘越界)
518            Popover {
519                open: open_action().as_deref() == Some("delete"),
520                anchor_x: anchor_x(),
521                anchor_y: anchor_y(),
522                placement: "bottom",
523                align: "end",
524                on_close: move |_| open_action.set(None),
525                div { class: "w-64 space-y-3",
526                    p { class: "text-sm text-paper-primary",
527                        "确认删除 "
528                        span { class: "font-mono text-xs break-all", "{props.info.filename}" }
529                        "?"
530                    }
531                    div { class: "flex justify-end gap-2 pt-1",
532                        button {
533                            class: "{BTN_GHOST}",
534                            onclick: move |_| open_action.set(None),
535                            "取消"
536                        }
537                        button {
538                            class: "{BTN_DANGER_OUTLINE}",
539                            onclick: move |_| {
540                                open_action.set(None);
541                                on_delete.call(fname_for_delete.clone());
542                            },
543                            "确认删除"
544                        }
545                    }
546                }
547            }
548        }
549    }
550}
551
552/// 自动备份设置卡片:启用开关 / 执行时间 / 保留份数 / 包含 uploads,
553/// 以及上次自动备份结果与下次执行时间。
554///
555/// 时区模型:服务端只存 UTC "HH:MM";卡片用 js_sys 在挂载回填与保存时
556/// 做浏览器本地时区转换。SSR 与客户端首帧都是 view=None 的占位态,
557/// 数据在 hydration 后落地,不存在两端渲染不一致。
558#[component]
559#[cfg_attr(not(target_arch = "wasm32"), allow(unused_mut, unused_variables))]
560fn BackupSettingsCard() -> Element {
561    #[cfg(target_arch = "wasm32")]
562    use crate::api::settings::{get_backup_settings, update_backup_settings};
563    use crate::components::forms::{TimePicker, ToggleSwitch};
564    use crate::components::ui::{CollapsibleSettingsCard, BTN_ICON};
565    use crate::models::settings::BackupSettingsView;
566    #[cfg(target_arch = "wasm32")]
567    use crate::utils::time::local_hhmm_to_utc;
568    use crate::utils::time::utc_hhmm_to_local;
569
570    // 服务端视图(设置 + 上次结果 + 下次执行)。SSR/首帧为 None(占位态)。
571    let view = use_signal(|| Option::<BackupSettingsView>::None); // 表单草稿:与已存值天然分叉,是独立状态(非镜像)。
572    let mut draft_enabled = use_signal(|| false);
573    let mut draft_time = use_signal(String::new); // 浏览器本地 "HH:MM"
574    let mut draft_retention = use_signal(|| "30".to_string());
575    let mut draft_include_uploads = use_signal(|| true);
576    let mut saving = use_signal(|| false);
577    let mut just_saved = use_signal(|| false);
578    let mut card_error = use_signal(|| Option::<String>::None);
579
580    // 挂载拉取并回填草稿(一次性种子回填,合法 effect)。
581    use_effect(move || {
582        #[cfg(target_arch = "wasm32")]
583        spawn(async move {
584            let mut view = view;
585            match get_backup_settings().await {
586                Ok(v) => {
587                    draft_enabled.set(v.settings.auto_enabled);
588                    draft_time.set(utc_hhmm_to_local(&v.settings.time_utc));
589                    draft_retention.set(v.settings.retention_count.to_string());
590                    draft_include_uploads.set(v.settings.include_uploads);
591                    view.set(Some(v));
592                }
593                Err(e) => card_error.set(Some(e.to_string())),
594            }
595        });
596    });
597
598    // 派生:草稿是否与已存设置分叉(memo 避免每次渲染重复 parse)。
599    let dirty = use_memo(move || {
600        let guard = view.read();
601        let Some(v) = guard.as_ref() else {
602            return false;
603        };
604        let retention_changed = draft_retention()
605            .trim()
606            .parse::<i32>()
607            .map(|d| d != v.settings.retention_count)
608            .unwrap_or(true);
609        draft_enabled() != v.settings.auto_enabled
610            || retention_changed
611            || draft_include_uploads() != v.settings.include_uploads
612            || draft_time() != utc_hhmm_to_local(&v.settings.time_utc)
613    });
614
615    // 预格式化状态行文本(render 内联算派生值,不存 signal)。
616    let (last_run_label, next_run_label) = {
617        let v = view.read();
618        let last = v.as_ref().and_then(|v| v.last_run.as_ref()).map(|r| {
619            let at = rfc3339_to_local_display(&r.at);
620            if r.ok {
621                match &r.file {
622                    Some(f) => format!("{at} 成功({f})"),
623                    None => format!("{at} 成功"),
624                }
625            } else {
626                format!("{at} 失败:{}", r.error.clone().unwrap_or_default())
627            }
628        });
629        let next = v
630            .as_ref()
631            .and_then(|v| v.next_run_at.as_ref())
632            .map(|t| rfc3339_to_local_display(t));
633        (last, next)
634    };
635    let loaded = view.read().is_some();
636
637    rsx! {
638        CollapsibleSettingsCard {
639            title: "自动备份".to_string(),
640            summary: if !loaded { "正在加载设置…".to_string() } else if draft_enabled() { format!("已开启 · 每天 {} 执行", draft_time()) } else { "已关闭".to_string() },
641            enabled: draft_enabled(),
642            on_toggle: move |_| just_saved.set(false),
643            div { class: "border-t border-paper-border p-5 space-y-5",
644
645                if !loaded {
646                    // SSR/首帧占位:与客户端初始渲染一致,fetch 落地后替换。
647                    div { class: "text-xs text-paper-secondary py-2", "正在加载设置…" }
648                } else {
649                    // 启用开关行(toggle 样式镜像回收站设置卡片)
650                    div { class: "flex items-center justify-between gap-4",
651                        div { class: "min-w-0",
652                            div { class: "text-sm font-medium text-paper-primary", "启用自动备份" }
653                            div { class: "text-xs text-paper-secondary mt-1",
654                                "到达设定时间后由后台任务执行"
655                            }
656                        }
657                        ToggleSwitch {
658                            checked: draft_enabled(),
659                            ontoggle: move |_| {
660                                draft_enabled.set(!draft_enabled());
661                                just_saved.set(false);
662                            },
663                        }
664                    }
665
666                    // 执行时间(本地时间显示/编辑,存 UTC)
667                    div { class: "space-y-3",
668                        div { class: "min-w-0",
669                            div { class: "text-sm font-medium text-paper-primary", "执行时间" }
670                            div { class: "text-xs text-paper-secondary mt-1",
671                                "按浏览器本地时间显示与编辑,服务端以 UTC 存储"
672                            }
673                        }
674                        div { class: "flex items-center gap-3",
675                            TimePicker {
676                                value: draft_time(),
677                                onchange: move |v: String| {
678                                    draft_time.set(v);
679                                    just_saved.set(false);
680                                },
681                            }
682                            span { class: "text-xs text-paper-secondary", "本地时间" }
683                        }
684                    }
685
686                    // 保留份数(步进器镜像回收站保留天数)
687                    div { class: "space-y-3",
688                        div { class: "min-w-0",
689                            div { class: "text-sm font-medium text-paper-primary", "保留份数" }
690                            div { class: "text-xs text-paper-secondary mt-1",
691                                "自动备份保留的最近份数,超出后最旧的连配对素材包一起删除(1–365)"
692                            }
693                        }
694                        div { class: "flex items-center gap-3",
695                            div { class: "flex items-center rounded-lg border border-paper-border bg-paper-entry overflow-hidden",
696                                button {
697                                    class: "{BTN_ICON}",
698                                    r#type: "button",
699                                    aria_label: "减少保留份数",
700                                    onclick: move |_| {
701                                        let cur: i32 = draft_retention().trim().parse().unwrap_or(30);
702                                        let next = cur.saturating_sub(1).max(1);
703                                        draft_retention.set(next.to_string());
704                                        just_saved.set(false);
705                                    },
706                                    "−"
707                                }
708                                input {
709                                    r#type: "number",
710                                    min: "1",
711                                    max: "365",
712                                    class: "w-14 h-9 px-1 text-center text-sm tabular-nums text-paper-primary bg-transparent border-0 focus:outline-none [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none",
713                                    value: "{draft_retention()}",
714                                    oninput: move |e| {
715                                        draft_retention.set(e.value());
716                                        just_saved.set(false);
717                                    },
718                                }
719                                button {
720                                    class: "{BTN_ICON}",
721                                    r#type: "button",
722                                    aria_label: "增加保留份数",
723                                    onclick: move |_| {
724                                        let cur: i32 = draft_retention().trim().parse().unwrap_or(30);
725                                        let next = cur.saturating_add(1).min(365);
726                                        draft_retention.set(next.to_string());
727                                        just_saved.set(false);
728                                    },
729                                    "+"
730                                }
731                            }
732                            span { class: "text-xs text-paper-secondary", "份" }
733                        }
734                    }
735
736                    // 包含 uploads 开关
737                    div { class: "flex items-center justify-between gap-4",
738                        div { class: "min-w-0",
739                            div { class: "text-sm font-medium text-paper-primary",
740                                "包含 uploads 素材"
741                            }
742                            div { class: "text-xs text-paper-secondary mt-1",
743                                "每次备份附带 uploads/ 打包(tar.gz,排除可重建的转码缓存)"
744                            }
745                        }
746                        ToggleSwitch {
747                            checked: draft_include_uploads(),
748                            ontoggle: move |_| {
749                                draft_include_uploads.set(!draft_include_uploads());
750                                just_saved.set(false);
751                            },
752                        }
753                    }
754
755                    // 状态行:上次结果 / 下次执行
756                    if last_run_label.is_some() || next_run_label.is_some() {
757                        div { class: "text-xs text-paper-secondary space-y-1 border-t border-paper-border pt-3",
758                            if let Some(label) = last_run_label {
759                                div { "上次自动备份:{label}" }
760                            }
761                            if let Some(label) = next_run_label {
762                                div { "下次执行:{label}(本地时间)" }
763                            }
764                        }
765                    }
766
767                    if let Some(err) = card_error() {
768                        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",
769                            "{err}"
770                        }
771                    }
772
773                    // 底部操作行
774                    div { class: "flex items-center justify-between gap-4 pt-1",
775                        if just_saved() {
776                            span { class: "inline-flex items-center gap-1.5 text-xs text-paper-accent",
777                                svg {
778                                    class: "w-3.5 h-3.5",
779                                    view_box: "0 0 24 24",
780                                    fill: "none",
781                                    stroke: "currentColor",
782                                    stroke_width: "2.5",
783                                    path {
784                                        stroke_linecap: "round",
785                                        stroke_linejoin: "round",
786                                        d: "M5 13l4 4L19 7",
787                                    }
788                                }
789                                "已保存"
790                            }
791                        } else if dirty() {
792                            span { class: "text-xs text-paper-secondary", "有未保存的更改" }
793                        } else {
794                            span { class: "text-xs text-transparent select-none", "·" }
795                        }
796                        LoadingButton {
797                            label: "保存设置".to_string(),
798                            loading: saving(),
799                            disabled: just_saved() || !dirty(),
800                            variant: "sm",
801                            onclick: move |_| {
802                                #[cfg(target_arch = "wasm32")]
803                                {
804                                    let enabled = draft_enabled();
805                                    let time_utc = local_hhmm_to_utc(&draft_time());
806                                    let retention: i32 =
807                                        draft_retention().trim().parse().unwrap_or(30);
808                                    let include = draft_include_uploads();
809                                    saving.set(true);
810                                    spawn(async move {
811                                        let mut view = view;
812                                        match update_backup_settings(enabled, time_utc, retention, include).await
813                                        {
814                                            Ok(v) => {
815                                                // 以服务端正典值回填草稿(normalize/clamp 后的值)。
816                                                draft_enabled.set(v.settings.auto_enabled);
817                                                draft_time.set(utc_hhmm_to_local(&v.settings.time_utc));
818                                                draft_retention
819                                                    .set(v.settings.retention_count.to_string());
820                                                draft_include_uploads.set(v.settings.include_uploads);
821                                                view.set(Some(v));
822                                                just_saved.set(true);
823                                                card_error.set(None);
824                                            }
825                                            Err(e) => card_error.set(Some(e.to_string())),
826                                        }
827                                        saving.set(false);
828                                    });
829                                }
830                            },
831                        }
832                    }
833                }
834            }
835        }
836    }
837}
838
839/// RFC3339 → 浏览器本地「YYYY-MM-DD HH:MM」。解析失败原样返回。
840/// 仅在 view 已落地(hydration 后)的渲染路径出现,无 SSR 不一致问题。
841fn rfc3339_to_local_display(iso: &str) -> String {
842    #[cfg(target_arch = "wasm32")]
843    {
844        let ms = js_sys::Date::parse(iso);
845        if ms.is_nan() {
846            return iso.to_string();
847        }
848        let d = js_sys::Date::new(&ms.into());
849        format!(
850            "{:04}-{:02}-{:02} {:02}:{:02}",
851            d.get_full_year(),
852            d.get_month() + 1,
853            d.get_date(),
854            d.get_hours(),
855            d.get_minutes()
856        )
857    }
858    #[cfg(not(target_arch = "wasm32"))]
859    {
860        iso.to_string()
861    }
862}