From 775c58a9e6a590ff27496de6e4636e9c79e9e897 Mon Sep 17 00:00:00 2001 From: xfy Date: Mon, 27 Jul 2026 11:40:02 +0800 Subject: [PATCH] =?UTF-8?q?feat(admin):=20=E7=B4=A0=E6=9D=90=E9=A1=B5?= =?UTF-8?q?=E6=94=AF=E6=8C=81=E5=A4=9A=E9=80=89=E4=B8=8E=E6=89=B9=E9=87=8F?= =?UTF-8?q?=E5=88=A0=E9=99=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 卡片勾选框:仅未引用素材可选(与单删保护语义一致),hover 显现、 有选择时常显,选中卡片加主题色 ring;stop_propagation 防触发灯箱。 批量条:已选计数 / 全选本页(可切换取消本页) / 清除 / 删除所选 (两步确认),选择跨翻页与筛选保留。 服务端新增 BatchDeleteAssets:一次查询取引用存在性,被引用的跳过, 其余删文件(NotFound 容忍)+ 批量删 DB 行 + 清派生缓存,返回 删除/跳过/释放/失败统计。 --- src/api/assets/delete.rs | 101 ++++++++++++++++++++++++- src/api/assets/mod.rs | 7 +- src/api/assets/types.rs | 13 ++++ src/pages/admin/assets.rs | 152 +++++++++++++++++++++++++++++++++++++- 4 files changed, 267 insertions(+), 6 deletions(-) diff --git a/src/api/assets/delete.rs b/src/api/assets/delete.rs index b6c9f24..16b1aed 100644 --- a/src/api/assets/delete.rs +++ b/src/api/assets/delete.rs @@ -7,7 +7,7 @@ use dioxus::prelude::*; -use super::types::{AssetOpResponse, PurgeOrphansResponse}; +use super::types::{AssetOpResponse, BatchDeleteAssetsResponse, PurgeOrphansResponse}; /// 更新素材 alt(管理性备注,不回写已有文章 HTML)。 #[server(UpdateAssetAlt, "/api")] @@ -121,6 +121,105 @@ pub async fn delete_asset(id: String) -> Result unreachable!() } +/// 批量删除素材。 +/// +/// 逐项做引用检查:被引用的跳过(保护语义与单删一致),无引用的硬删除 +/// (文件 + DB 行 + 派生缓存)。非法 id 计入失败,不存在的 id 静默忽略。 +/// 返回删除/跳过/失败统计,供批量操作条展示。 +#[server(BatchDeleteAssets, "/api")] +pub async fn batch_delete_assets( + ids: Vec, +) -> Result { + #[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 = 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 = 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 天保护窗。 /// /// 逐项删文件(容忍单项失败),最后批量删 DB 行。 diff --git a/src/api/assets/mod.rs b/src/api/assets/mod.rs index da7e45a..44cca33 100644 --- a/src/api/assets/mod.rs +++ b/src/api/assets/mod.rs @@ -15,7 +15,10 @@ pub mod rebuild; /// 请求与响应数据结构。 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 rebuild::rebuild_assets_index; -pub use types::{AssetListResponse, AssetOpResponse, PurgeOrphansResponse, RebuildAssetsResponse}; +pub use types::{ + AssetListResponse, AssetOpResponse, BatchDeleteAssetsResponse, PurgeOrphansResponse, + RebuildAssetsResponse, +}; diff --git a/src/api/assets/types.rs b/src/api/assets/types.rs index 488b0e1..03cb292 100644 --- a/src/api/assets/types.rs +++ b/src/api/assets/types.rs @@ -63,6 +63,19 @@ pub struct PurgeOrphansResponse { 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)] pub struct RebuildAssetsResponse { diff --git a/src/pages/admin/assets.rs b/src/pages/admin/assets.rs index c1627db..9c42a56 100644 --- a/src/pages/admin/assets.rs +++ b/src/pages/admin/assets.rs @@ -14,17 +14,19 @@ use dioxus::prelude::*; // server fn 仅在 WASM 前端调用(全部包在 cfg(wasm32) 块内),server SSR 只编译类型。 #[cfg(target_arch = "wasm32")] 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; #[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::ui::{FilterTabs, Pagination}; #[cfg(target_arch = "wasm32")] use crate::utils::js::invoke_optional_global; #[cfg(target_arch = "wasm32")] use crate::models::asset::{AssetFilter, AssetSort}; +use std::collections::HashSet; /// 每页素材数,与服务端 list.rs 的 PER_PAGE 对齐。 const ASSETS_PER_PAGE: i32 = 60; @@ -82,6 +84,10 @@ pub fn Assets() -> Element { let mut alt_input = use_signal(String::new); // 重载触发器:操作成功后 +1 让 effect 重新请求。 let mut reload = use_signal(|| 0_i32); + // 多选:选中素材 id 集合 + 批量删除确认态。仅未引用素材可选(被引用的禁删, + // 与单删保护语义一致);选择跨翻页/筛选保留,批量删除成功后整体清空。 + let mut selected_ids: Signal> = use_signal(HashSet::new); + let mut batch_confirm = use_signal(|| false); // 搜索防抖:query 是输入框原始值(受控绑定),debounced_query 才是请求参数。 // 停顿 300ms 无新输入才提交;每次击键重启本 effect 并新 spawn 一个延时任务, @@ -182,6 +188,18 @@ pub fn Assets() -> Element { format!("{sort_btn_base} {sort_idle}") }; + // 多选派生值:本页可删(未引用)素材 id、是否已全选本页、是否有任何选择 + // (驱动未选中卡片的勾选框常显)。全选/取消本页闭包各需一份 id 列表拷贝。 + let page_orphan_ids: Vec = 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! { div { 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 = 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() { 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 img_alt = a.alt.clone().unwrap_or_else(|| a.filename.clone()); 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,不提升会被展示层盖住(灯箱改造的回归)。 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" @@ -352,7 +475,7 @@ pub fn Assets() -> Element { rsx! { div { 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 模糊占位 + // data-src 展示层(IO 懒加载);点击由 lightbox.js 接管为灯箱 // (图集模式,原图 = data-src 去 query)。不加 lightbox-single。 @@ -377,6 +500,29 @@ pub fn Assets() -> Element { "被 {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", p { class: "text-xs font-medium truncate text-[var(--color-paper-primary)]", title: "{a.filename}",