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