feat(assets): 删除保护、孤儿清理与 alt 编辑

- delete_asset:引用中业务拒绝(Ok(success:false) + 引用文章列表),
  孤儿硬删除(文件 + DB 行级联 refs + 派生缓存)
- purge_orphan_assets:一键清理无引用且超过 7 天保护窗的素材,
  容忍单项文件删除失败,返回数量/释放字节/失败数
- update_asset_alt:管理性 alt 编辑
- invalidate_asset_caches:IMAGE_CACHE 前缀批量失效 + 尺寸缓存失效;
  磁盘派生缓存(哈希命名不可枚举)由既有清理任务回收
- 前端:卡片操作区三态(复制/alt/删除 ↔ alt 编辑 ↔ 确认删除),
  顶栏「清理孤儿」两步确认(带数量与总大小),操作结果横幅
This commit is contained in:
xfy 2026-07-24 16:23:00 +08:00
parent 99b96e7a0c
commit 799455da43
5 changed files with 440 additions and 13 deletions

View File

@ -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"

186
src/api/assets/delete.rs Normal file
View File

@ -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<AssetOpResponse, 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)?;
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<AssetOpResponse, ServerFnError> {
#[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<AssetRef> = 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<PurgeOrphansResponse, 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)?;
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<String> = 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!()
}

View File

@ -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};

View File

@ -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_<sha256(key)>`)文件名是整 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

View File

@ -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<Option<String>> = use_signal(|| None);
// 数据加载:任一查询条件变化时重新请求。筛选/搜索/排序变化时重置到第 1 页。
// 操作结果横幅(删除/清理/alt 编辑的反馈)。
#[allow(unused_mut)]
let mut op_message: Signal<Option<String>> = use_signal(|| None);
// 待二次确认的删除目标(素材 id与一键清理确认态。
let mut confirm_delete: Signal<Option<String>> = use_signal(|| None);
let mut purge_confirm = use_signal(|| false);
// alt 内联编辑:目标素材 id + 输入框值。
let mut editing_alt: Signal<Option<String>> = 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::<Vec<_>>()
.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()))
},
"删除"
}
}
}
}
}
}
}