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:
parent
c1530956b0
commit
5888f1fc41
16
input.css
16
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;
|
||||
|
||||
@ -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);
|
||||
|
||||
/// 筛选选项卡组件。
|
||||
|
||||
@ -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
|
||||
//(彼时为 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::<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());
|
||||
},
|
||||
"确认删除"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user