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:
parent
99b96e7a0c
commit
799455da43
@ -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
186
src/api/assets/delete.rs
Normal 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!()
|
||||
}
|
||||
@ -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};
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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()))
|
||||
},
|
||||
"删除"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user