feat(system): 备份恢复删除/恢复改用 Popover 确认框替代浏览器 confirm

新增通用 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 不变,仍服务创建备份+恢复)。
This commit is contained in:
xfy 2026-07-13 15:08:24 +08:00
parent c1530956b0
commit 5888f1fc41
3 changed files with 266 additions and 81 deletions

View File

@ -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;

View File

@ -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<RefCell<Option<wasm_bindgen::prelude::Closure<dyn FnMut(web_sys::KeyboardEvent)>>>>;
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<dyn FnMut(web_sys::KeyboardEvent)>);
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);
/// 筛选选项卡组件。

View File

@ -1221,8 +1221,6 @@ fn BackupTab() -> Element {
// 当前进行中的任务(备份/恢复id + 进度
let active_task_id: Signal<Option<String>> = use_signal(|| None);
let mut active_progress = use_signal(|| Option::<TaskProgress>::None);
// 待恢复的文件名(确认对话框用)
let mut pending_restore: Signal<Option<String>> = 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
//(彼时为 Noneuse_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::<String>::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());
},
"确认删除"
}
}
}
}
}
}
}