1use 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#[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 #[cfg(target_arch = "wasm32")]
25 use dioxus::web::{WebEventExt, WebFileExt};
26 #[cfg(target_arch = "wasm32")]
27 use wasm_bindgen::JsCast;
28
29 let backups = use_signal(Vec::<BackupInfo>::new);
32 let mut loading = use_signal(|| false);
33 let mut error = use_signal(|| Option::<String>::None);
34 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 let mut import_busy = use_signal(|| false);
40
41 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 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 crate::utils::time::sleep_ms(200).await;
89 continue;
90 }
91 };
92 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 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 }
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 BackupSettingsCard {}
136
137 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 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 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 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 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 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 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 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 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#[cfg(target_arch = "wasm32")]
352const IMPORT_MAX_BYTES_CLIENT: u64 = 512 * 1024 * 1024;
353
354fn 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#[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 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 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 let mut open_action = use_signal(|| Option::<String>::None);
422 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 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 {
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 {
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#[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 let view = use_signal(|| Option::<BackupSettingsView>::None); let mut draft_enabled = use_signal(|| false);
573 let mut draft_time = use_signal(String::new); 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 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 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 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 div { class: "text-xs text-paper-secondary py-2", "正在加载设置…" }
648 } else {
649 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 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 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 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 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 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 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
839fn 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}