feat(admin): 素材页支持多选与批量删除

卡片勾选框:仅未引用素材可选(与单删保护语义一致),hover 显现、
有选择时常显,选中卡片加主题色 ring;stop_propagation 防触发灯箱。
批量条:已选计数 / 全选本页(可切换取消本页) / 清除 / 删除所选
(两步确认),选择跨翻页与筛选保留。

服务端新增 BatchDeleteAssets:一次查询取引用存在性,被引用的跳过,
其余删文件(NotFound 容忍)+ 批量删 DB 行 + 清派生缓存,返回
删除/跳过/释放/失败统计。
This commit is contained in:
xfy 2026-07-27 11:40:02 +08:00
parent 248060d920
commit 775c58a9e6
4 changed files with 267 additions and 6 deletions

View File

@ -7,7 +7,7 @@
use dioxus::prelude::*; use dioxus::prelude::*;
use super::types::{AssetOpResponse, PurgeOrphansResponse}; use super::types::{AssetOpResponse, BatchDeleteAssetsResponse, PurgeOrphansResponse};
/// 更新素材 alt管理性备注不回写已有文章 HTML /// 更新素材 alt管理性备注不回写已有文章 HTML
#[server(UpdateAssetAlt, "/api")] #[server(UpdateAssetAlt, "/api")]
@ -121,6 +121,105 @@ pub async fn delete_asset(id: String) -> Result<AssetOpResponse, ServerFnError>
unreachable!() unreachable!()
} }
/// 批量删除素材。
///
/// 逐项做引用检查:被引用的跳过(保护语义与单删一致),无引用的硬删除
/// (文件 + DB 行 + 派生缓存)。非法 id 计入失败,不存在的 id 静默忽略。
/// 返回删除/跳过/失败统计,供批量操作条展示。
#[server(BatchDeleteAssets, "/api")]
pub async fn batch_delete_assets(
ids: Vec<String>,
) -> Result<BatchDeleteAssetsResponse, ServerFnError> {
#[cfg(feature = "server")]
{
use crate::api::auth::get_current_admin_user;
use crate::api::error::AppError;
use crate::db::pool::get_conn;
let _admin = get_current_admin_user().await?;
let client = get_conn().await.map_err(AppError::db_conn)?;
// id 在边界处从 String 解析为 Uuid非法 id 属业务错误,计入 failures 不走 500。
let mut uuids: Vec<uuid::Uuid> = Vec::with_capacity(ids.len());
let mut failures: i64 = 0;
for id in &ids {
match uuid::Uuid::parse_str(id) {
Ok(u) => uuids.push(u),
Err(_) => failures += 1,
}
}
if uuids.is_empty() {
return Ok(BatchDeleteAssetsResponse {
success: false,
message: "没有可删除的素材".to_string(),
deleted_count: 0,
skipped_referenced: 0,
freed_bytes: 0,
failures,
});
}
// 一次查询取路径/大小/引用存在性,避免逐 id 往返。
let rows = client
.query(
"SELECT a.id AS id, a.path, a.size_bytes, \
EXISTS (SELECT 1 FROM asset_refs r WHERE r.asset_id = a.id) AS referenced \
FROM assets a WHERE a.id = ANY($1)",
&[&uuids],
)
.await
.map_err(AppError::query)?;
let mut delete_ids: Vec<uuid::Uuid> = Vec::with_capacity(rows.len());
let mut freed_bytes: i64 = 0;
let mut skipped: i64 = 0;
for row in &rows {
let id: uuid::Uuid = row.get("id");
if row.get::<_, bool>("referenced") {
skipped += 1;
continue;
}
let path: String = row.get("path");
freed_bytes += row.get::<_, i64>("size_bytes");
let file_path = format!("uploads/{}", path);
if let Err(e) = tokio::fs::remove_file(&file_path).await {
// NotFound 可容忍(文件可能已被手动删);其他错误计入 failures
// DB 行照删——残留文件由重建索引的反向语义兜底。
if e.kind() != std::io::ErrorKind::NotFound {
tracing::warn!("Batch delete: remove file failed ({}): {}", file_path, e);
failures += 1;
}
}
crate::api::image::invalidate_asset_caches(&path).await;
delete_ids.push(id);
}
let deleted = if delete_ids.is_empty() {
0
} else {
client
.execute("DELETE FROM assets WHERE id = ANY($1)", &[&delete_ids])
.await
.map_err(AppError::query)?
};
let mut message = format!("已删除 {} 张素材", deleted);
if skipped > 0 {
message.push_str(&format!(",跳过 {} 张被引用", skipped));
}
Ok(BatchDeleteAssetsResponse {
success: true,
message,
deleted_count: deleted as i64,
skipped_referenced: skipped,
freed_bytes,
failures,
})
}
#[cfg(not(feature = "server"))]
unreachable!()
}
/// 一键清理孤儿素材:无引用且 created_at 早于 7 天保护窗。 /// 一键清理孤儿素材:无引用且 created_at 早于 7 天保护窗。
/// ///
/// 逐项删文件(容忍单项失败),最后批量删 DB 行。 /// 逐项删文件(容忍单项失败),最后批量删 DB 行。

View File

@ -15,7 +15,10 @@ pub mod rebuild;
/// 请求与响应数据结构。 /// 请求与响应数据结构。
pub mod types; pub mod types;
pub use delete::{delete_asset, purge_orphan_assets, update_asset_alt}; pub use delete::{batch_delete_assets, delete_asset, purge_orphan_assets, update_asset_alt};
pub use list::list_assets; pub use list::list_assets;
pub use rebuild::rebuild_assets_index; pub use rebuild::rebuild_assets_index;
pub use types::{AssetListResponse, AssetOpResponse, PurgeOrphansResponse, RebuildAssetsResponse}; pub use types::{
AssetListResponse, AssetOpResponse, BatchDeleteAssetsResponse, PurgeOrphansResponse,
RebuildAssetsResponse,
};

View File

@ -63,6 +63,19 @@ pub struct PurgeOrphansResponse {
pub failures: i64, pub failures: i64,
} }
/// 批量删除的结果。
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BatchDeleteAssetsResponse {
pub success: bool,
pub message: String,
pub deleted_count: i64,
/// 因被文章引用而跳过的数量(保护语义与单删一致)。
pub skipped_referenced: i64,
pub freed_bytes: i64,
/// id 非法或删文件失败的数量DB 行已删/未处理,属可容忍不一致)。
pub failures: i64,
}
/// 重建索引的结果。 /// 重建索引的结果。
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RebuildAssetsResponse { pub struct RebuildAssetsResponse {

View File

@ -14,17 +14,19 @@ use dioxus::prelude::*;
// server fn 仅在 WASM 前端调用(全部包在 cfg(wasm32) 块内server SSR 只编译类型。 // server fn 仅在 WASM 前端调用(全部包在 cfg(wasm32) 块内server SSR 只编译类型。
#[cfg(target_arch = "wasm32")] #[cfg(target_arch = "wasm32")]
use crate::api::assets::{ use crate::api::assets::{
delete_asset, list_assets, purge_orphan_assets, rebuild_assets_index, update_asset_alt, batch_delete_assets, delete_asset, list_assets, purge_orphan_assets, rebuild_assets_index,
update_asset_alt,
}; };
use crate::api::assets::AssetListResponse; use crate::api::assets::AssetListResponse;
#[cfg(target_arch = "wasm32")] #[cfg(target_arch = "wasm32")]
use crate::api::assets::{PurgeOrphansResponse, RebuildAssetsResponse}; use crate::api::assets::{BatchDeleteAssetsResponse, PurgeOrphansResponse, RebuildAssetsResponse};
use crate::components::empty_state::EmptyState; use crate::components::empty_state::EmptyState;
use crate::components::ui::{FilterTabs, Pagination}; use crate::components::ui::{FilterTabs, Pagination};
#[cfg(target_arch = "wasm32")] #[cfg(target_arch = "wasm32")]
use crate::utils::js::invoke_optional_global; use crate::utils::js::invoke_optional_global;
#[cfg(target_arch = "wasm32")] #[cfg(target_arch = "wasm32")]
use crate::models::asset::{AssetFilter, AssetSort}; use crate::models::asset::{AssetFilter, AssetSort};
use std::collections::HashSet;
/// 每页素材数,与服务端 list.rs 的 PER_PAGE 对齐。 /// 每页素材数,与服务端 list.rs 的 PER_PAGE 对齐。
const ASSETS_PER_PAGE: i32 = 60; const ASSETS_PER_PAGE: i32 = 60;
@ -82,6 +84,10 @@ pub fn Assets() -> Element {
let mut alt_input = use_signal(String::new); let mut alt_input = use_signal(String::new);
// 重载触发器:操作成功后 +1 让 effect 重新请求。 // 重载触发器:操作成功后 +1 让 effect 重新请求。
let mut reload = use_signal(|| 0_i32); let mut reload = use_signal(|| 0_i32);
// 多选:选中素材 id 集合 + 批量删除确认态。仅未引用素材可选(被引用的禁删,
// 与单删保护语义一致);选择跨翻页/筛选保留,批量删除成功后整体清空。
let mut selected_ids: Signal<HashSet<String>> = use_signal(HashSet::new);
let mut batch_confirm = use_signal(|| false);
// 搜索防抖query 是输入框原始值受控绑定debounced_query 才是请求参数。 // 搜索防抖query 是输入框原始值受控绑定debounced_query 才是请求参数。
// 停顿 300ms 无新输入才提交;每次击键重启本 effect 并新 spawn 一个延时任务, // 停顿 300ms 无新输入才提交;每次击键重启本 effect 并新 spawn 一个延时任务,
@ -182,6 +188,18 @@ pub fn Assets() -> Element {
format!("{sort_btn_base} {sort_idle}") format!("{sort_btn_base} {sort_idle}")
}; };
// 多选派生值:本页可删(未引用)素材 id、是否已全选本页、是否有任何选择
// (驱动未选中卡片的勾选框常显)。全选/取消本页闭包各需一份 id 列表拷贝。
let page_orphan_ids: Vec<String> = assets
.iter()
.filter(|item| item.ref_count == 0)
.map(|item| item.asset.id.clone())
.collect();
let any_selected = !selected_ids().is_empty();
let all_page_selected =
!page_orphan_ids.is_empty() && page_orphan_ids.iter().all(|id| selected_ids().contains(id));
let page_orphan_ids_for_toggle = page_orphan_ids.clone();
rsx! { rsx! {
div { div {
h1 { class: "text-3xl font-extrabold tracking-tight mb-2", "素材管理" } h1 { class: "text-3xl font-extrabold tracking-tight mb-2", "素材管理" }
@ -323,6 +341,95 @@ pub fn Assets() -> Element {
} }
} }
// 多选批量操作条:出现即与操作横幅同槽位(顶栏与网格之间)。
if any_selected {
div { class: "mb-6 flex items-center gap-3 rounded-2xl border border-[var(--color-paper-border)] bg-[var(--color-paper-entry)] px-4 py-3 text-sm shadow-sm",
span { class: "text-sm text-[var(--color-paper-secondary)]",
"已选 {selected_ids().len()} 张"
}
button {
class: "text-xs cursor-pointer text-[var(--color-paper-secondary)] hover:text-[var(--color-paper-primary)] transition-colors",
onclick: move |_| {
let mut s = selected_ids();
if all_page_selected {
for id in &page_orphan_ids_for_toggle {
s.remove(id);
}
} else {
for id in &page_orphan_ids_for_toggle {
s.insert(id.clone());
}
}
selected_ids.set(s);
},
if all_page_selected {
"取消本页"
} else {
"全选本页"
}
}
button {
class: "text-xs cursor-pointer text-[var(--color-paper-secondary)] hover:text-[var(--color-paper-primary)] transition-colors",
onclick: move |_| {
selected_ids.set(HashSet::new());
batch_confirm.set(false);
},
"清除"
}
div { class: "flex-1" }
if batch_confirm() {
button {
class: "text-xs font-medium cursor-pointer px-3 py-2 rounded-full bg-red-500 text-white hover:bg-red-600 transition-colors",
onclick: move |_| {
batch_confirm.set(false);
let ids: Vec<String> = selected_ids().iter().cloned().collect();
#[cfg(target_arch = "wasm32")]
spawn(async move {
match batch_delete_assets(ids).await {
Ok(resp) => {
let BatchDeleteAssetsResponse {
message,
freed_bytes,
failures,
deleted_count,
..
} = resp;
let mut msg = message;
if freed_bytes > 0 {
msg.push_str(&format!(",释放 {}", format_bytes(freed_bytes)));
}
if failures > 0 {
msg.push_str(&format!("{failures} 项失败)"));
}
op_message.set(Some(msg));
if deleted_count > 0 {
selected_ids.set(HashSet::new());
reload.set(reload() + 1);
}
}
Err(e) => {
op_message.set(Some(format!("批量删除失败:{e}")))
}
}
});
},
"确认删除 {selected_ids().len()} 张"
}
button {
class: "text-xs cursor-pointer px-3 py-2 rounded-full border border-[var(--color-paper-border)] text-[var(--color-paper-secondary)] hover:text-[var(--color-paper-primary)] transition-colors",
onclick: move |_| batch_confirm.set(false),
"取消"
}
} else {
button {
class: "text-xs font-medium cursor-pointer px-3 py-2 rounded-full border border-red-500/50 text-red-600 dark:text-red-400 hover:bg-red-500/10 transition-colors",
onclick: move |_| batch_confirm.set(true),
"删除所选"
}
}
}
}
// 内容区 // 内容区
if let Some(err) = error() { if let Some(err) = error() {
div { class: "mt-8 text-sm text-red-500", "加载失败:{err}" } div { class: "mt-8 text-sm text-red-500", "加载失败:{err}" }
@ -343,6 +450,22 @@ pub fn Assets() -> Element {
let placeholder = format!("/uploads/{}?w=20", a.path); let placeholder = format!("/uploads/{}?w=20", a.path);
let img_alt = a.alt.clone().unwrap_or_else(|| a.filename.clone()); let img_alt = a.alt.clone().unwrap_or_else(|| a.filename.clone());
let is_orphan = asset.ref_count == 0; let is_orphan = asset.ref_count == 0;
let is_selected = selected_ids().contains(&a.id);
// 选中卡片加主题色描边ring 与原有 border 叠加,不动布局)。
let card_ring = if is_selected {
"ring-2 ring-[var(--color-paper-accent)]"
} else {
""
};
// 勾选框:未引用素材才可删可选。选中或有任何选择时常显,
// 否则随卡片 hover 显现(与卡片操作按钮同一显现语言)。
let checkbox_class = if is_selected {
"absolute top-2 right-2 z-10 w-6 h-6 flex items-center justify-center rounded-full text-xs cursor-pointer transition-all bg-[var(--color-paper-accent)] text-white border border-[var(--color-paper-accent)]"
} else if any_selected {
"absolute top-2 right-2 z-10 w-6 h-6 flex items-center justify-center rounded-full text-xs cursor-pointer transition-all bg-black/40 backdrop-blur-sm text-white border border-white/60"
} else {
"absolute top-2 right-2 z-10 w-6 h-6 flex items-center justify-center rounded-full text-xs cursor-pointer transition-all bg-black/40 backdrop-blur-sm text-white border border-white/60 opacity-0 group-hover:opacity-100"
};
// z-10.blur-img-full 带 z-index:1不提升会被展示层盖住灯箱改造的回归 // z-10.blur-img-full 带 z-index:1不提升会被展示层盖住灯箱改造的回归
let badge_class = if is_orphan { let badge_class = if is_orphan {
"absolute top-2 left-2 z-10 text-[10px] font-mono px-2 py-0.5 rounded-full backdrop-blur-sm bg-amber-500/80 text-white" "absolute top-2 left-2 z-10 text-[10px] font-mono px-2 py-0.5 rounded-full backdrop-blur-sm bg-amber-500/80 text-white"
@ -352,7 +475,7 @@ pub fn Assets() -> Element {
rsx! { rsx! {
div { div {
key: "{a.id}", key: "{a.id}",
class: "group relative rounded-3xl overflow-hidden border border-[var(--color-paper-border)] bg-[var(--color-paper-entry)] shadow-sm hover:shadow-md transition-all", class: "group relative rounded-3xl overflow-hidden border border-[var(--color-paper-border)] bg-[var(--color-paper-entry)] shadow-sm hover:shadow-md transition-all {card_ring}",
// blur-img 双层结构(对齐前台正文图):?w=20 模糊占位 + // blur-img 双层结构(对齐前台正文图):?w=20 模糊占位 +
// data-src 展示层IO 懒加载);点击由 lightbox.js 接管为灯箱 // data-src 展示层IO 懒加载);点击由 lightbox.js 接管为灯箱
// (图集模式,原图 = data-src 去 query。不加 lightbox-single。 // (图集模式,原图 = data-src 去 query。不加 lightbox-single。
@ -377,6 +500,29 @@ pub fn Assets() -> Element {
"被 {asset.ref_count} 篇引用" "被 {asset.ref_count} 篇引用"
} }
} }
// 多选勾选框仅未引用素材stop_propagation 防触发灯箱)
if is_orphan {
button {
class: "{checkbox_class}",
title: if is_selected { "取消选择" } else { "选择" },
onclick: {
let id = a.id.clone();
move |evt| {
evt.stop_propagation();
let mut s = selected_ids();
if s.contains(&id) {
s.remove(&id);
} else {
s.insert(id.clone());
}
selected_ids.set(s);
}
},
if is_selected {
""
}
}
}
div { class: "p-3", div { class: "p-3",
p { class: "text-xs font-medium truncate text-[var(--color-paper-primary)]", p { class: "text-xs font-medium truncate text-[var(--color-paper-primary)]",
title: "{a.filename}", title: "{a.filename}",