From 5888f1fc4134940388210e2fdf3883c15f177521 Mon Sep 17 00:00:00 2001 From: xfy Date: Mon, 13 Jul 2026 15:08:24 +0800 Subject: [PATCH] =?UTF-8?q?feat(system):=20=E5=A4=87=E4=BB=BD=E6=81=A2?= =?UTF-8?q?=E5=A4=8D=E5=88=A0=E9=99=A4/=E6=81=A2=E5=A4=8D=E6=94=B9?= =?UTF-8?q?=E7=94=A8=20Popover=20=E7=A1=AE=E8=AE=A4=E6=A1=86=E6=9B=BF?= =?UTF-8?q?=E4=BB=A3=E6=B5=8F=E8=A7=88=E5=99=A8=20confirm?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增通用 Popover 组件(src/components/ui.rs,与 Tooltip 对称),定位用 position:fixed + MouseEvent::client_coordinates() 视口坐标锚定,逃出表格 overflow-hidden;透明遮罩 z-40 兜底点击外部关闭,组件内 use_effect 注册 全局 keydown 监听 Esc 关闭(use_drop 清理),150ms 淡入缩放动画 (input.css 新增 popover-enter keyframes)。 BackupRow 删除/恢复按钮 onclick 读点击坐标打开对应确认 popover,确认 按钮回调父组件。顺带简化恢复链路:原生 confirm 是阻塞式才需要 pending_restore signal + 确认 use_future 的间接机制,popover 非阻塞后 on_restore 直接 busy + restore_backup + active_task_id,移除 pending_restore signal 及其 use_future(进度轮询 use_future 不变,仍服务创建备份+恢复)。 --- input.css | 16 +++ src/components/ui.rs | 124 +++++++++++++++++++++++ src/pages/admin/system.rs | 207 +++++++++++++++++++++++--------------- 3 files changed, 266 insertions(+), 81 deletions(-) diff --git a/input.css b/input.css index c9be2fd..ba12f9c 100644 --- a/input.css +++ b/input.css @@ -63,6 +63,22 @@ animation: page-enter 400ms cubic-bezier(0.22, 1, 0.36, 1) both; } + /* Popover / 确认框淡入缩放(比 page-enter 更轻、更快) */ + @keyframes popover-enter { + 0% { + opacity: 0; + transform: translateX(-50%) scale(0.95); + } + 100% { + opacity: 1; + transform: translateX(-50%) scale(1); + } + } + + .animate-popover-enter { + animation: popover-enter 150ms cubic-bezier(0.22, 1, 0.36, 1) both; + } + /* Minimalist custom scrollbars */ * { scrollbar-width: thin; diff --git a/src/components/ui.rs b/src/components/ui.rs index daa1e08..b244fa4 100644 --- a/src/components/ui.rs +++ b/src/components/ui.rs @@ -257,6 +257,130 @@ pub fn Tooltip( } } +/// Popover 遮罩与面板的层级(z-40 遮罩 < z-50 面板),与 Tooltip/lightbox 同 z-50。 +const POPOVER_OVERLAY_CLASS: &str = "fixed inset-0 z-40"; +/// Popover 面板:卡片化大圆角 + 阴影 + 淡入缩放动画。 +const POPOVER_PANEL_CLASS: &str = + "fixed z-50 bg-[var(--color-paper-entry)] rounded-2xl shadow-lg border border-[var(--color-paper-border)] p-4 animate-popover-enter"; + +/// 受控式通用 Popover(浮层)组件。 +/// +/// 与 [`Tooltip`] 对称——但 Tooltip 是纯 CSS hover、无状态;Popover 是点击触发、 +/// 受控开关,用于承载确认框、轻量表单等交互内容。 +/// +/// ## 定位策略 +/// +/// 父容器常有 `overflow-hidden`(如 `ADMIN_TABLE_CLASS`),`position:absolute` 子节点 +/// 会被裁掉,故面板用 **`position:fixed`**,以触发点击的**视口坐标**(`MouseEvent:: +/// client_coordinates()`)作为锚点(参照 `theme.rs` 圆形展开动画的坐标用法)。无需 +/// `getBoundingClientRect`/`node_ref`。 +/// +/// - `placement: "top"`(默认):面板底边贴点击点上方(`bottom: 100vh - y + gap`)。 +/// - `placement: "bottom"`:面板顶边贴点击点下方(`top: y + gap`)。 +/// - 水平:面板中心对齐点击点(`left: x` + `-translate-x-1/2`)。 +/// +/// ## 关闭路径(三条) +/// +/// 1. 点遮罩(透明,仅作点击兜底)→ `on_close`。 +/// 2. Escape 键 → `on_close`(组件内 `use_effect` 注册全局 keydown 监听,`use_drop` 清理)。 +/// 3. 面板内确认/取消按钮调用 `on_close`。 +/// +/// ## Props +/// +/// - `open`:受控开关;`false` 时组件不渲染任何内容(SSR 安全)。 +/// - `anchor_x` / `anchor_y`:触发点击的视口坐标。 +/// - `placement`:`"top"`(默认)/ `"bottom"`。 +/// - `children`:面板内容(确认框等)。 +/// - `on_close`:任一关闭路径触发。 +#[component] +#[cfg_attr(not(target_arch = "wasm32"), allow(unused_variables))] +pub fn Popover( + open: bool, + anchor_x: i32, + anchor_y: i32, + children: Element, + on_close: EventHandler<()>, + #[props(default = "top")] placement: &'static str, +) -> Element { + // Escape 关闭:组件 open 时注册全局 keydown 监听,关闭/卸载时移除。 + // 手写最小 listener 而非复用 use_event_listener——后者 handler 无参,拿不到 + // KeyboardEvent 的 key()。用 use_hook 持有 Closure,use_effect 注册,use_drop 清理。 + #[cfg(target_arch = "wasm32")] + { + use dioxus::prelude::{use_drop, use_effect, use_hook}; + use std::cell::RefCell; + use std::rc::Rc; + type EscState = Rc>>>; + let state: EscState = use_hook(|| Rc::new(RefCell::new(None))); + let state_for_drop = state.clone(); + let open_for_effect = open; + let on_close_for_esc = on_close; + use_effect(move || { + if !open_for_effect { + return; + } + let Some(window) = web_sys::window() else { + return; + }; + let on_close_for_esc = on_close_for_esc; + // Closure 带 KeyboardEvent 参数:浏览器调用 handler 时传入事件对象, + // 无需依赖已废弃的 window.event()。as_ref + unchecked_ref 转成 JS Function。 + let closure = wasm_bindgen::prelude::Closure::wrap(Box::new(move |ev: web_sys::KeyboardEvent| { + if ev.key() == "Escape" { + on_close_for_esc.call(()); + } + }) + as Box); + let _ = window.add_event_listener_with_callback( + "keydown", + wasm_bindgen::JsCast::unchecked_ref(closure.as_ref()), + ); + *state.borrow_mut() = Some(closure); + }); + use_drop(move || { + if let Some(closure) = state_for_drop.borrow_mut().take() { + if let Some(window) = web_sys::window() { + let _ = window.remove_event_listener_with_callback( + "keydown", + wasm_bindgen::JsCast::unchecked_ref(closure.as_ref()), + ); + } + } + }); + } + + if !open { + return rsx! {}; + } + + // 面板定位:top/bottom 决定垂直方向;水平统一居中于点击点。 + let style = if placement == "bottom" { + format!("top: {y}px; left: {x}px; transform: translateX(-50%);", x = anchor_x, y = anchor_y + 8) + } else { + // top:面板在点击点上方——用 bottom 锚定 viewport 底,差值即视口高度 - y + 间隙。 + // 视口高度用 100vh,纯 CSS 无需 JS 读取 scrollHeight。 + format!( + "bottom: calc(100vh - {y}px + 8px); left: {x}px; transform: translateX(-50%);", + x = anchor_x, + y = anchor_y, + ) + }; + + rsx! { + // 透明遮罩:拦截外部点击(点遮罩即关)。z-40 < 面板 z-50。 + div { + class: "{POPOVER_OVERLAY_CLASS}", + onclick: move |_| on_close.call(()), + } + // 面板:fixed 定位逃出 overflow-hidden 容器。 + div { + class: "{POPOVER_PANEL_CLASS}", + style: "{style}", + {children} + } + } +} + static TAB_GROUP_ID: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); /// 筛选选项卡组件。 diff --git a/src/pages/admin/system.rs b/src/pages/admin/system.rs index 05f84e4..8ae1b51 100644 --- a/src/pages/admin/system.rs +++ b/src/pages/admin/system.rs @@ -1221,8 +1221,6 @@ fn BackupTab() -> Element { // 当前进行中的任务(备份/恢复)id + 进度 let active_task_id: Signal> = use_signal(|| None); let mut active_progress = use_signal(|| Option::::None); - // 待恢复的文件名(确认对话框用) - let mut pending_restore: Signal> = use_signal(|| None); let mut busy = use_signal(|| false); // 刷新备份列表 @@ -1253,65 +1251,6 @@ fn BackupTab() -> Element { refresh_list(); }); - // 恢复确认:pending_restore 被设置时弹出原生 confirm,确认后发起 restore_backup - // 并开始进度轮询。 - // - // 采用与 DbStatusTab 自动刷新相同的长生命周期 loop 模式:在每次循环内部读 - // pending_restore() 信号。原先用 use_future 在挂载时快照一次 pending_restore - //(彼时为 None),use_future 仅运行一次即 return,用户后续点击"恢复"设置 - // pending_restore 后 future 不会重跑(Dioxus 的 ReactiveContext 只订阅闭包 - // *执行期间* 读到的 signal,同步体的 .read().clone() 不建立订阅)→ 确认框 - // 永不弹出。改成 loop 内读信号后,下一轮迭代即拿到最新值。 - use_future(move || { - let mut pending_restore = pending_restore; - let mut busy = busy; - let mut active_progress = active_progress; - let mut active_task_id = active_task_id; - let mut error = error; - async move { - #[cfg(target_arch = "wasm32")] - { - loop { - let fname = match pending_restore() { - Some(f) => f, - None => { - // 空闲:短 yield 让事件循环呼吸,避免忙等。 - wasm_sleep(200).await; - continue; - } - }; - // 取到了:先清空 pending,防止 loop 重入时再次确认。 - pending_restore.set(None); - let confirmed = web_sys::window() - .and_then(|w| { - w.confirm_with_message(&format!( - "恢复将覆盖现有数据,确认恢复 {}?\n\n仅本系统生成的备份可恢复。", - fname - )) - .ok() - }) - == Some(true); - if confirmed { - busy.set(true); - active_progress.set(None); - match restore_backup(fname, true).await { - Ok(id) => active_task_id.set(Some(id)), - Err(e) => { - error.set(Some(e.to_string())); - busy.set(false); - } - } - } - // 处理完一轮后回到 loop 顶部继续等待下一个 pending。 - } - } - #[cfg(not(target_arch = "wasm32"))] - { - let _ = (pending_restore, busy, active_progress, active_task_id, error); - } - } - }); - // 任务进度轮询:active_task_id 存在时每 1.5s 拉取进度,Done/Failed 后停止 + 刷新列表。 // // 同样用长生命周期 loop + 循环内读 active_task_id() 的模式。原先在挂载时把 @@ -1467,26 +1406,42 @@ fn BackupTab() -> Element { size_str: size_str.clone(), dl_url: dl_url.clone(), busy: is_busy, - on_restore: move |f: String| pending_restore.set(Some(f)), - on_delete: move |_fname_del: String| { + // 恢复:确认已在 BackupRow 的 Popover 内完成, + // 这里直接发起 restore_backup 并交由轮询 use_future 接管。 + // pending_restore signal + 确认 use_future 链路已移除 + //(原生 confirm 是阻塞式才需要那套间接机制)。 + on_restore: move |f: String| { #[cfg(target_arch = "wasm32")] { - let fname_del = _fname_del; - let confirmed = web_sys::window() - .and_then(|w| { - w.confirm_with_message(&format!("确认删除 {}?", fname_del)) - .ok() - }) - == Some(true); - if confirmed { - let mut backups = backups; - spawn(async move { - let _ = delete_backup(fname_del.clone()).await; - if let Ok(list) = list_backups().await { - backups.set(list); + let mut busy = busy; + let mut active_progress = active_progress; + let mut active_task_id = active_task_id; + let mut error = error; + spawn(async move { + busy.set(true); + active_progress.set(None); + match restore_backup(f, true).await { + Ok(id) => active_task_id.set(Some(id)), + Err(e) => { + error.set(Some(e.to_string())); + busy.set(false); } - }); - } + } + }); + } + }, + // 删除:确认已在 BackupRow 的 Popover 内完成, + // 直接执行 delete_backup + 刷新列表。 + on_delete: move |fname_del: String| { + #[cfg(target_arch = "wasm32")] + { + let mut backups = backups; + spawn(async move { + let _ = delete_backup(fname_del).await; + if let Ok(list) = list_backups().await { + backups.set(list); + } + }); } }, } @@ -1520,6 +1475,10 @@ fn urlencode_dl(s: &str) -> String { } /// 备份列表单行(抽取为子组件:各自 scope 内 let/clone 不冲突)。 +/// +/// 删除/恢复不再用浏览器原生 confirm(),改用 [`Popover`] 确认框(`position:fixed` +/// 逃出表格 `overflow-hidden`)。点击按钮读 `MouseEvent::client_coordinates()` 作为 +/// popover 锚点,`confirm` 按钮回调父组件的 `on_delete`/`on_restore`。 #[derive(Props, Clone, PartialEq)] struct BackupRowProps { filename: String, @@ -1532,12 +1491,24 @@ struct BackupRowProps { } #[component] +#[cfg_attr(not(target_arch = "wasm32"), allow(unused_mut, unused_variables))] fn BackupRow(props: BackupRowProps) -> Element { - // Callback 是 Copy,直接复用;filename 需 clone(两个闭包各取一份)。 + use crate::components::ui::Popover; + use crate::components::ui::BTN_DANGER_OUTLINE; + + // Callback 是 Copy,直接复用;filename 需 clone(确认框闭包各取一份)。 let on_restore = props.on_restore; let on_delete = props.on_delete; let fname_for_restore = props.filename.clone(); let fname_for_delete = props.filename.clone(); + + // Popover 状态:哪个动作的确认框打开 + 锚点坐标。None = 都关闭。 + // 用一个 String("delete"/"restore") 而非两个 bool,避免同时开两个 popover。 + let mut open_action = use_signal(|| Option::::None); + // 锚点坐标:按钮点击的视口坐标(client_coordinates)。 + let mut anchor_x = use_signal(|| 0i32); + let mut anchor_y = use_signal(|| 0i32); + rsx! { tr { class: "border-b border-paper-border last:border-0 hover:bg-paper-entry transition-colors", td { class: "px-4 py-2 font-mono text-xs text-paper-primary", "{props.filename}" } @@ -1553,16 +1524,90 @@ fn BackupRow(props: BackupRowProps) -> Element { button { class: "{BTN_TEXT_AMBER} mr-3 disabled:opacity-50", disabled: props.busy, - onclick: move |_| on_restore.call(fname_for_restore.clone()), + // 点击记录坐标并打开恢复确认 popover。client_coordinates 两端编译。 + onclick: move |e| { + let c = e.client_coordinates(); + anchor_x.set(c.x as i32); + anchor_y.set(c.y as i32); + open_action.set(Some("restore".to_string())); + }, "恢复" } button { class: "{BTN_TEXT_RED} disabled:opacity-50", disabled: props.busy, - onclick: move |_| on_delete.call(fname_for_delete.clone()), + onclick: move |e| { + let c = e.client_coordinates(); + anchor_x.set(c.x as i32); + anchor_y.set(c.y as i32); + open_action.set(Some("delete".to_string())); + }, "删除" } } + + // 恢复确认 popover + Popover { + open: open_action().as_deref() == Some("restore"), + anchor_x: anchor_x(), + anchor_y: anchor_y(), + placement: "bottom", + on_close: move |_| open_action.set(None), + div { class: "w-64 space-y-3", + p { class: "text-sm text-paper-primary leading-relaxed", + "恢复将覆盖现有数据,确认恢复 " + span { class: "font-mono text-xs break-all", "{props.filename}" } + "?" + } + p { class: "text-xs text-paper-secondary", "仅本系统生成的备份可恢复。" } + div { class: "flex justify-end gap-2 pt-1", + button { + class: "px-3 py-1.5 text-xs text-paper-secondary hover:text-paper-primary transition-colors cursor-pointer", + onclick: move |_| open_action.set(None), + "取消" + } + button { + class: "{BTN_DANGER_OUTLINE}", + onclick: move |_| { + open_action.set(None); + on_restore.call(fname_for_restore.clone()); + }, + "确认恢复" + } + } + } + } + + // 删除确认 popover + Popover { + open: open_action().as_deref() == Some("delete"), + anchor_x: anchor_x(), + anchor_y: anchor_y(), + placement: "bottom", + on_close: move |_| open_action.set(None), + div { class: "w-64 space-y-3", + p { class: "text-sm text-paper-primary", + "确认删除 " + span { class: "font-mono text-xs break-all", "{props.filename}" } + "?" + } + div { class: "flex justify-end gap-2 pt-1", + button { + class: "px-3 py-1.5 text-xs text-paper-secondary hover:text-paper-primary transition-colors cursor-pointer", + onclick: move |_| open_action.set(None), + "取消" + } + button { + class: "{BTN_DANGER_OUTLINE}", + onclick: move |_| { + open_action.set(None); + on_delete.call(fname_for_delete.clone()); + }, + "确认删除" + } + } + } + } } } }