diff --git a/Cargo.toml b/Cargo.toml index abed2ae..ba592f0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -69,7 +69,7 @@ mimalloc = { version = "0.1", optional = true } katex-rs = { version = "0.2", optional = true } [target.'cfg(target_arch = "wasm32")'.dependencies] -web-sys = { version = "0.3", features = ["Document", "Window", "Storage", "Element", "HtmlElement", "DomTokenList", "MediaQueryList", "HtmlImageElement", "MouseEvent", "KeyboardEvent", "Node", "EventTarget", "Navigator", "File", "FileList", "FormData", "Request", "RequestInit", "Response", "Headers", "ClipboardEvent", "DataTransfer", "HtmlInputElement", "EventSource", "MessageEvent"] } +web-sys = { version = "0.3", features = ["Document", "Window", "Storage", "Element", "HtmlElement", "DomTokenList", "MediaQueryList", "HtmlImageElement", "MouseEvent", "KeyboardEvent", "Node", "EventTarget", "Navigator", "Clipboard", "File", "FileList", "FormData", "Request", "RequestInit", "Response", "Headers", "ClipboardEvent", "DataTransfer", "HtmlInputElement", "EventSource", "MessageEvent"] } wasm-bindgen = "0.2" wasm-bindgen-futures = "0.4" js-sys = "0.3" diff --git a/src/api/assets/delete.rs b/src/api/assets/delete.rs new file mode 100644 index 0000000..68b9f07 --- /dev/null +++ b/src/api/assets/delete.rs @@ -0,0 +1,186 @@ +//! 素材删除与孤儿清理接口。 +//! +//! 删除保护:被任何文章(含回收站文章)引用的素材禁删,返回引用列表; +//! 孤儿素材硬删除(文件 + DB 行 + 派生缓存)。一键清理仅作用于 +//! 无引用且超过 7 天保护窗的素材(保护未保存草稿的引用)。 +//! Dioxus server function,注册在 `/api` 路径下,仅 admin 可用。 + +use dioxus::prelude::*; + +use super::types::{AssetOpResponse, PurgeOrphansResponse}; + +/// 更新素材 alt(管理性备注,不回写已有文章 HTML)。 +#[server(UpdateAssetAlt, "/api")] +pub async fn update_asset_alt(id: String, alt: String) -> 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)?; + + let alt = alt.trim().to_string(); + let updated = client + .execute( + "UPDATE assets SET alt = NULLIF($2, ''), updated_at = NOW() WHERE id = $1::uuid", + &[&id, &alt], + ) + .await + .map_err(AppError::query)?; + + if updated == 0 { + return Ok(AssetOpResponse::err("素材不存在".to_string())); + } + Ok(AssetOpResponse::ok("alt 已更新".to_string())) + } + #[cfg(not(feature = "server"))] + unreachable!() +} + +/// 删除单张素材。 +/// +/// 引用中 → `Ok(success:false)` + 引用文章列表(业务拒绝不走 Err); +/// 孤儿 → 物理删除文件、DB 行(refs 级联)与派生缓存。 +#[server(DeleteAsset, "/api")] +pub async fn delete_asset(id: String) -> Result { + #[cfg(feature = "server")] + { + use crate::api::auth::get_current_admin_user; + use crate::api::error::AppError; + use crate::db::pool::get_conn; + use crate::models::asset::AssetRef; + + let _admin = get_current_admin_user().await?; + let client = get_conn().await.map_err(AppError::db_conn)?; + + let row = client + .query_opt( + "SELECT id::text AS id, path, filename FROM assets WHERE id = $1::uuid", + &[&id], + ) + .await + .map_err(AppError::query)?; + let Some(row) = row else { + return Ok(AssetOpResponse::err("素材不存在".to_string())); + }; + let path: String = row.get("path"); + + // 引用检查:含回收站文章(其 purge 时 refs 级联删,图自然变孤儿)。 + let ref_rows = client + .query( + "SELECT p.id, p.title FROM asset_refs r JOIN posts p ON p.id = r.post_id \ + WHERE r.asset_id = $1::uuid ORDER BY p.id", + &[&id], + ) + .await + .map_err(AppError::query)?; + if !ref_rows.is_empty() { + let refs: Vec = ref_rows + .iter() + .map(|r| AssetRef { + post_id: r.get(0), + title: r.get(1), + }) + .collect(); + return Ok(AssetOpResponse { + success: false, + message: format!("该素材正被 {} 篇文章引用,无法删除", refs.len()), + refs, + }); + } + + // 孤儿:先删文件(NotFound 容忍——磁盘与 DB 可能已不一致),再删 DB 行。 + let file_path = format!("uploads/{}", path); + if let Err(e) = tokio::fs::remove_file(&file_path).await { + if e.kind() != std::io::ErrorKind::NotFound { + tracing::warn!("Remove asset file failed ({}): {}", file_path, e); + } + } + client + .execute("DELETE FROM assets WHERE id = $1::uuid", &[&id]) + .await + .map_err(AppError::query)?; + crate::api::image::invalidate_asset_caches(&path).await; + + Ok(AssetOpResponse::ok("已删除".to_string())) + } + #[cfg(not(feature = "server"))] + unreachable!() +} + +/// 一键清理孤儿素材:无引用且 created_at 早于 7 天保护窗。 +/// +/// 逐项删文件(容忍单项失败),最后批量删 DB 行。 +/// 返回清理数量、释放字节数与文件删除失败数。 +#[server(PurgeOrphanAssets, "/api")] +pub async fn purge_orphan_assets() -> 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)?; + + let rows = client + .query( + "SELECT a.id::text AS id, a.path, a.size_bytes FROM assets a \ + WHERE NOT EXISTS (SELECT 1 FROM asset_refs r WHERE r.asset_id = a.id) \ + AND a.created_at < NOW() - make_interval(days => $1)", + &[&super::list::PURGE_GRACE_DAYS], + ) + .await + .map_err(AppError::query)?; + + if rows.is_empty() { + return Ok(PurgeOrphansResponse { + success: true, + message: "没有可清理的孤儿素材".to_string(), + deleted_count: 0, + freed_bytes: 0, + failures: 0, + }); + } + + let mut ids: Vec = Vec::with_capacity(rows.len()); + let mut freed_bytes: i64 = 0; + let mut failures: i64 = 0; + for row in &rows { + let id: String = row.get("id"); + 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!("Purge: remove file failed ({}): {}", file_path, e); + failures += 1; + } + } + crate::api::image::invalidate_asset_caches(&path).await; + ids.push(id); + } + + let deleted = client + .execute( + "DELETE FROM assets WHERE id = ANY($1::uuid[])", + &[&ids], + ) + .await + .map_err(AppError::query)?; + + Ok(PurgeOrphansResponse { + success: true, + message: format!("已清理 {} 张孤儿素材", deleted), + deleted_count: deleted as i64, + freed_bytes, + failures, + }) + } + #[cfg(not(feature = "server"))] + unreachable!() +} diff --git a/src/api/assets/mod.rs b/src/api/assets/mod.rs index 46ec3de..9c203a6 100644 --- a/src/api/assets/mod.rs +++ b/src/api/assets/mod.rs @@ -4,10 +4,13 @@ //! 分页列表、删除保护、孤儿清理、全量重建索引。 //! 全部为 Dioxus server function,仅 admin 可用。 +/// 素材删除与孤儿清理。 +pub mod delete; /// 素材分页列表。 pub mod list; /// 请求与响应数据结构。 pub mod types; +pub use delete::{delete_asset, purge_orphan_assets, update_asset_alt}; pub use list::list_assets; pub use types::{AssetListResponse, AssetOpResponse, PurgeOrphansResponse, RebuildAssetsResponse}; diff --git a/src/api/image.rs b/src/api/image.rs index e8b28ec..b4ef67c 100644 --- a/src/api/image.rs +++ b/src/api/image.rs @@ -542,6 +542,22 @@ use axum::http::HeaderMap; #[cfg(feature = "server")] const CACHE_DIR: &str = "uploads/.cache"; +#[cfg(feature = "server")] +/// 素材删除时清理其派生缓存。 +/// +/// - 内存处理缓存 `IMAGE_CACHE`:key 形如 `{path}|w=..|thumb=..`,按前缀批量失效; +/// - 尺寸缓存 `IMAGE_DIMENSIONS_CACHE`:key 即相对路径,直接失效。 +/// +/// 磁盘派生缓存(`uploads/.cache/cache_`)文件名是整 key 的哈希, +/// 无法按路径前缀枚举;这些死条目由 `image_cache_cleanup` 后台任务 +/// 按容量/年龄回收,不在删除路径上处理。 +pub async fn invalidate_asset_caches(rel_path: &str) { + let prefix = format!("{}|", rel_path); + // moka 的 invalidate_entries_if 是同步谓词求值,返回 Result(仅谓词 panic 时 Err)。 + let _ = IMAGE_CACHE.invalidate_entries_if(move |k, _| k.starts_with(&prefix)); + IMAGE_DIMENSIONS_CACHE.invalidate(rel_path); +} + #[cfg(feature = "server")] fn disk_cache_base(cache_key: &str) -> String { // 使用 SHA-256 生成稳定的磁盘缓存文件名,避免进程重启后 DefaultHasher 随机 seed diff --git a/src/pages/admin/assets.rs b/src/pages/admin/assets.rs index 387cf46..ecb2ece 100644 --- a/src/pages/admin/assets.rs +++ b/src/pages/admin/assets.rs @@ -6,8 +6,8 @@ use dioxus::prelude::*; -use crate::api::assets::list_assets; -use crate::api::assets::AssetListResponse; +use crate::api::assets::{delete_asset, list_assets, purge_orphan_assets, update_asset_alt}; +use crate::api::assets::{AssetListResponse, PurgeOrphansResponse}; use crate::components::empty_state::EmptyState; use crate::components::ui::{FilterTabs, Pagination}; use crate::models::asset::{AssetFilter, AssetSort}; @@ -47,12 +47,25 @@ pub fn Assets() -> Element { #[allow(unused_mut)] let mut error: Signal> = use_signal(|| None); - // 数据加载:任一查询条件变化时重新请求。筛选/搜索/排序变化时重置到第 1 页。 + // 操作结果横幅(删除/清理/alt 编辑的反馈)。 + #[allow(unused_mut)] + let mut op_message: Signal> = use_signal(|| None); + // 待二次确认的删除目标(素材 id)与一键清理确认态。 + let mut confirm_delete: Signal> = use_signal(|| None); + let mut purge_confirm = use_signal(|| false); + // alt 内联编辑:目标素材 id + 输入框值。 + let mut editing_alt: Signal> = use_signal(|| None); + let mut alt_input = use_signal(String::new); + // 重载触发器:操作成功后 +1 让 effect 重新请求。 + let mut reload = use_signal(|| 0_i32); + + // 数据加载:任一查询条件或 reload 变化时重新请求。筛选/搜索/排序变化时重置到第 1 页。 use_effect(move || { let f = filter(); let q = query(); let s = sort(); let p = page(); + let _ = reload(); #[cfg(target_arch = "wasm32")] { @@ -79,16 +92,20 @@ pub fn Assets() -> Element { }); let resp = data.read(); - let (assets, total, used_count, orphan_count) = match resp.as_ref() { - Some(r) => ( - r.assets.clone(), - r.total, - r.used_count, - r.orphan_count, - ), - None => (Vec::new(), 0, 0, 0), - }; + let (assets, total, used_count, orphan_count, purgeable_count, purgeable_bytes) = + match resp.as_ref() { + Some(r) => ( + r.assets.clone(), + r.total, + r.used_count, + r.orphan_count, + r.purgeable_count, + r.purgeable_bytes, + ), + None => (Vec::new(), 0, 0, 0, 0, 0), + }; let all_count = used_count + orphan_count; + drop(resp); // Dioxus 格式化段不支持内联 if 块表达式,条件 class 提前算好。 let sort_btn_base = "text-xs font-mono tracking-widest uppercase cursor-pointer px-3 py-2 rounded-full border transition-colors"; @@ -149,6 +166,70 @@ pub fn Assets() -> Element { }, "最大" } + // 一键清理孤儿:仅 7 天保护窗外的无引用素材;两步确认。 + if purgeable_count > 0 { + if purge_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 |_| { + purge_confirm.set(false); + #[cfg(target_arch = "wasm32")] + spawn(async move { + match purge_orphan_assets().await { + Ok(PurgeOrphansResponse { + deleted_count, + freed_bytes, + failures, + .. + }) => { + let mut msg = format!( + "已清理 {} 张孤儿素材,释放 {}", + deleted_count, + format_bytes(freed_bytes) + ); + if failures > 0 { + msg.push_str(&format!( + "({} 个文件删除失败)", + failures + )); + } + op_message.set(Some(msg)); + reload.set(reload() + 1); + } + Err(e) => { + op_message.set(Some(format!("清理失败:{e}"))) + } + } + }); + }, + "确认清理 {purgeable_count} 张({format_bytes(purgeable_bytes)})" + } + 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 |_| purge_confirm.set(false), + "取消" + } + } else { + button { + class: "text-xs font-medium cursor-pointer px-3 py-2 rounded-full border border-amber-500/50 text-amber-600 dark:text-amber-400 hover:bg-amber-500/10 transition-colors", + title: "仅清理无引用且上传超过 7 天的素材(保护未保存的草稿)", + onclick: move |_| purge_confirm.set(true), + "清理孤儿({purgeable_count} 张 · {format_bytes(purgeable_bytes)})" + } + } + } + } + } + + // 操作结果横幅 + if let Some(msg) = op_message() { + div { class: "mt-4 flex items-center justify-between gap-4 rounded-2xl border border-[var(--color-paper-border)] bg-[var(--color-paper-entry)] px-4 py-3 text-sm text-[var(--color-paper-primary)] shadow-sm", + span { "{msg}" } + button { + class: "text-[var(--color-paper-tertiary)] hover:text-[var(--color-paper-primary)] cursor-pointer", + onclick: move |_| op_message.set(None), + "×" + } } } @@ -204,6 +285,147 @@ pub fn Assets() -> Element { p { class: "text-[10px] font-mono text-[var(--color-paper-tertiary)] mt-0.5", "{a.width}×{a.height} · {format_bytes(a.size_bytes)}" } + if let Some(alt_text) = &a.alt { + p { class: "text-[10px] truncate text-[var(--color-paper-secondary)] mt-0.5", + title: "{alt_text}", + "alt: {alt_text}" + } + } + + // 操作区:确认删除 / alt 编辑 / 常规三按钮 三态互斥 + if confirm_delete().as_deref() == Some(a.id.as_str()) { + div { class: "flex items-center gap-2 mt-2", + button { + class: "text-[10px] font-medium cursor-pointer px-2 py-1 rounded-full bg-red-500 text-white hover:bg-red-600 transition-colors", + onclick: { + let id = a.id.clone(); + move |_| { + confirm_delete.set(None); + let id = id.clone(); + #[cfg(target_arch = "wasm32")] + spawn(async move { + match delete_asset(id).await { + Ok(resp) => { + op_message.set(Some(resp.message)); + if resp.success { + reload.set(reload() + 1); + } + } + Err(e) => op_message + .set(Some(format!("删除失败:{e}"))), + } + }); + } + }, + "确认删除" + } + button { + class: "text-[10px] cursor-pointer px-2 py-1 rounded-full border border-[var(--color-paper-border)] text-[var(--color-paper-secondary)] hover:text-[var(--color-paper-primary)] transition-colors", + onclick: move |_| confirm_delete.set(None), + "取消" + } + } + } else if editing_alt().as_deref() == Some(a.id.as_str()) { + div { class: "flex items-center gap-1 mt-2", + input { + class: "flex-1 min-w-0 text-[10px] bg-[var(--color-paper-entry)] text-[var(--color-paper-primary)] focus:outline-none border border-[var(--color-paper-border)] focus:border-[var(--color-paper-primary)] rounded-full px-2 py-1 transition-all", + r#type: "text", + placeholder: "alt 文本", + value: "{alt_input}", + oninput: move |evt| alt_input.set(evt.value()), + } + button { + class: "text-[10px] font-medium cursor-pointer px-2 py-1 rounded-full bg-[var(--color-paper-primary)] text-[var(--color-paper-bg)] hover:opacity-80 transition-opacity", + onclick: { + let id = a.id.clone(); + move |_| { + let id = id.clone(); + let alt = alt_input(); + editing_alt.set(None); + #[cfg(target_arch = "wasm32")] + spawn(async move { + match update_asset_alt(id, alt).await { + Ok(resp) => { + op_message.set(Some(resp.message)); + if resp.success { + reload.set(reload() + 1); + } + } + Err(e) => op_message + .set(Some(format!("保存失败:{e}"))), + } + }); + } + }, + "存" + } + button { + class: "text-[10px] cursor-pointer px-2 py-1 rounded-full border border-[var(--color-paper-border)] text-[var(--color-paper-secondary)] hover:text-[var(--color-paper-primary)] transition-colors", + onclick: move |_| editing_alt.set(None), + "×" + } + } + } else { + div { class: "flex items-center gap-2 mt-2 opacity-0 group-hover:opacity-100 transition-opacity", + button { + class: "text-[10px] cursor-pointer text-[var(--color-paper-secondary)] hover:text-[var(--color-paper-primary)] transition-colors", + title: "复制图片 URL", + onclick: { + let url = format!("/uploads/{}", a.path); + move |_| { + #[cfg(target_arch = "wasm32")] + if let Some(window) = web_sys::window() { + let _ = window + .navigator() + .clipboard() + .write_text(&url); + op_message.set(Some(format!("已复制 {url}"))); + } + } + }, + "复制" + } + button { + class: "text-[10px] cursor-pointer text-[var(--color-paper-secondary)] hover:text-[var(--color-paper-primary)] transition-colors", + title: "编辑 alt", + onclick: { + let id = a.id.clone(); + let current_alt = a.alt.clone().unwrap_or_default(); + move |_| { + alt_input.set(current_alt.clone()); + editing_alt.set(Some(id.clone())); + } + }, + "alt" + } + if asset.ref_count > 0 { + { + let refs_tip = asset + .refs + .iter() + .map(|r| r.title.clone()) + .collect::>() + .join("、"); + rsx! { + span { + class: "text-[10px] text-[var(--color-paper-tertiary)] cursor-not-allowed", + title: "被引用:{refs_tip}", + "删除" + } + } + } + } else { + button { + class: "text-[10px] cursor-pointer text-red-500/70 hover:text-red-500 transition-colors", + onclick: { + let id = a.id.clone(); + move |_| confirm_delete.set(Some(id.clone())) + }, + "删除" + } + } + } + } } } }