style(admin/posts): 删除按钮 loading 用 spinner 覆盖文字

- 删除改为非乐观:点击后保留行并显示 loading,服务端成功才移除行、
  失败保留行并弹提示(顺带修掉原先「删除失败行却没了」的隐患)
- deleting 信号由 Option<i32> 改为 HashSet<i32>,支持并发删除
- 删除按钮 loading 样式与重建一致:文案恒为「删除」,spinner
  居中覆盖、文字 opacity-40 衬底
This commit is contained in:
xfy 2026-07-03 10:51:16 +08:00
parent 1530d3a2f4
commit b6fe0ae082

View File

@ -57,8 +57,10 @@ pub fn PostsPage(page: i32) -> Element {
let loading = paginated.loading; let loading = paginated.loading;
let _error = paginated.error; let _error = paginated.error;
// 删除中 ID、重建缓存状态与结果仍由本组件持有业务逻辑不归 hook 管)。 // 删除中 / 重建中文章 ID 集合:均由本组件持有(业务逻辑不归 hook 管)。
let mut deleting = use_signal(|| None::<i32>); // 改为非乐观删除后行会保留至请求完成,可并发点多个删除,故用 HashSet
// 与 rebuilding 同形,按行通过 contains 判断 loading 态。
let mut deleting = use_signal(|| std::collections::HashSet::<i32>::new());
// 重建中文章 ID 集合:支持多篇文章并发重建(行不会随点击消失,单值会被后点 // 重建中文章 ID 集合:支持多篇文章并发重建(行不会随点击消失,单值会被后点
// 的覆盖先点的,故用 HashSet按行通过 contains 判断 loading 态。 // 的覆盖先点的,故用 HashSet按行通过 contains 判断 loading 态。
let mut rebuilding = use_signal(|| std::collections::HashSet::<i32>::new()); let mut rebuilding = use_signal(|| std::collections::HashSet::<i32>::new());
@ -130,16 +132,18 @@ pub fn PostsPage(page: i32) -> Element {
PostRow { PostRow {
key: "{post.id}", key: "{post.id}",
post: post.clone(), post: post.clone(),
deleting: deleting() == Some(post.id), deleting: deleting().contains(&post.id),
rebuilding: rebuilding().contains(&post.id), rebuilding: rebuilding().contains(&post.id),
// 删除文章:先乐观更新本地列表,再调用 server function失败时弹出浏览器提示。 // 删除文章:非乐观——先标记 deleting 让按钮显示 loading
// 服务端成功后才移除行并减总数,失败则保留行并弹出浏览器提示。
on_delete: move |id| { on_delete: move |id| {
deleting.set(Some(id)); deleting.write().insert(id);
let id_for_api = id;
posts.with_mut(|list| list.retain(|p| p.id != id));
total.with_mut(|t| *t = t.saturating_sub(1));
spawn(async move { spawn(async move {
match delete_post(id_for_api).await { match delete_post(id).await {
Ok(CreatePostResponse { success: true, .. }) => {
posts.with_mut(|list| list.retain(|p| p.id != id));
total.with_mut(|t| *t = t.saturating_sub(1));
}
Ok(CreatePostResponse { success: false, message: _message, .. }) => { Ok(CreatePostResponse { success: false, message: _message, .. }) => {
#[cfg(target_arch = "wasm32")] #[cfg(target_arch = "wasm32")]
web_sys::window().map(|w| w.alert_with_message(&_message).ok()); web_sys::window().map(|w| w.alert_with_message(&_message).ok());
@ -148,9 +152,8 @@ pub fn PostsPage(page: i32) -> Element {
#[cfg(target_arch = "wasm32")] #[cfg(target_arch = "wasm32")]
web_sys::window().map(|w| w.alert_with_message("删除失败").ok()); web_sys::window().map(|w| w.alert_with_message("删除失败").ok());
} }
_ => {}
} }
deleting.set(None); deleting.write().remove(&id);
}); });
}, },
// 重建单篇文章内容:调用 server function 重新渲染 content_html。 // 重建单篇文章内容:调用 server function 重新渲染 content_html。
@ -334,14 +337,23 @@ fn PostRow(
} }
} }
button { button {
class: if deleting { "text-xs text-paper-secondary cursor-not-allowed" } else { BTN_TEXT_RED }, class: if deleting {
"relative inline-flex items-center text-xs text-paper-secondary cursor-not-allowed"
} else {
BTN_TEXT_RED
},
disabled: deleting, disabled: deleting,
onclick: move |_| on_delete.call(post.id), onclick: move |_| on_delete.call(post.id),
if deleting { span {
"删除中..." class: if deleting { "opacity-40" } else { "" },
} else {
"删除" "删除"
} }
if deleting {
span {
class: "absolute inset-0 flex items-center justify-center",
dangerous_inner_html: SPINNER_SVG,
}
}
} }
} }
} }