diff --git a/src/api/assets/list.rs b/src/api/assets/list.rs new file mode 100644 index 0000000..0c22a3e --- /dev/null +++ b/src/api/assets/list.rs @@ -0,0 +1,186 @@ +//! 素材分页列表接口。 +//! +//! 按引用状态(全部/引用中/孤儿)筛选、按文件名/alt 搜索、按时间或大小排序。 +//! 同时返回各筛选维度计数与可清理孤儿统计,供 tabs 与「清理孤儿」按钮展示。 +//! Dioxus server function,注册在 `/api` 路径下,仅 admin 可用。 + +use dioxus::prelude::*; + +use super::types::AssetListResponse; +use crate::models::asset::{AssetFilter, AssetSort}; + +/// 每页素材数(网格 6 列 x 10 行)。 +#[cfg(feature = "server")] +const PER_PAGE: i64 = 60; + +/// 孤儿清理保护窗:上传不满 7 天的无引用素材不可一键清理 +/// (保护尚未首次保存的草稿引用)。 +#[cfg(feature = "server")] +pub(crate) const PURGE_GRACE_DAYS: i32 = 7; + +/// 获取素材分页列表。 +#[server(ListAssets, "/api")] +pub async fn list_assets( + filter: AssetFilter, + query: String, + sort: AssetSort, + page: i32, +) -> 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::{Asset, AssetDto, AssetRef}; + + let _admin = get_current_admin_user().await?; + + let client = get_conn().await.map_err(AppError::db_conn)?; + + let page = page.max(1); + let offset: i64 = (page as i64 - 1) * PER_PAGE; + let query = query.trim().to_string(); + + // 筛选/搜索条件统一拼进 WHERE;参数按出现顺序编号。 + // 引用状态用 EXISTS 子查询,搜索用 ILIKE 转义通配符。 + let mut conditions: Vec = Vec::new(); + let mut params: Vec<&(dyn tokio_postgres::types::ToSql + Sync)> = Vec::new(); + match filter { + AssetFilter::Used => { + conditions.push( + "EXISTS (SELECT 1 FROM asset_refs r WHERE r.asset_id = a.id)".to_string(), + ); + } + AssetFilter::Orphan => { + conditions.push( + "NOT EXISTS (SELECT 1 FROM asset_refs r WHERE r.asset_id = a.id)".to_string(), + ); + } + AssetFilter::All => {} + } + if !query.is_empty() { + params.push(&query); + conditions.push(format!( + "(a.filename ILIKE '%' || ${} || '%' OR a.alt ILIKE '%' || ${} || '%')", + params.len(), + params.len() + )); + } + let where_clause = if conditions.is_empty() { + String::new() + } else { + format!("WHERE {}", conditions.join(" AND ")) + }; + + let order_clause = match sort { + AssetSort::CreatedDesc => "a.created_at DESC, a.id", + AssetSort::SizeDesc => "a.size_bytes DESC, a.id", + }; + + // 列表查询:ref_count 用相关子查询一次带出。 + // const 不能取引用(内联后借临时值会垂悬),绑定到局部变量再进参数列表。 + let per_page = PER_PAGE; + params.push(&per_page); + let limit_idx = params.len(); + params.push(&offset); + let offset_idx = params.len(); + let rows = client + .query( + &format!( + "SELECT a.id::text AS id, a.path, a.filename, a.mime, a.size_bytes, \ + a.width, a.height, a.alt, a.created_at, \ + (SELECT COUNT(*) FROM asset_refs r WHERE r.asset_id = a.id) AS ref_count \ + FROM assets a {where_clause} \ + ORDER BY {order_clause} LIMIT ${limit_idx} OFFSET ${offset_idx}" + ), + ¶ms, + ) + .await + .map_err(AppError::query)?; + + let total: i64 = client + .query_one( + &format!("SELECT COUNT(*) FROM assets a {where_clause}"), + ¶ms[..params.len() - 2], + ) + .await + .map_err(AppError::query)? + .get(0); + + // 汇总计数:tabs 与「清理孤儿」按钮徽标。不受筛选/搜索影响,始终全局。 + let summary = client + .query_one( + "SELECT \ + COUNT(*) FILTER (WHERE EXISTS (SELECT 1 FROM asset_refs r WHERE r.asset_id = a.id)), \ + COUNT(*) FILTER (WHERE NOT EXISTS (SELECT 1 FROM asset_refs r WHERE r.asset_id = a.id)), \ + COUNT(*) FILTER (WHERE NOT EXISTS (SELECT 1 FROM asset_refs r WHERE r.asset_id = a.id) \ + AND a.created_at < NOW() - make_interval(days => $1)), \ + COALESCE(SUM(a.size_bytes) FILTER (WHERE NOT EXISTS (SELECT 1 FROM asset_refs r WHERE r.asset_id = a.id) \ + AND a.created_at < NOW() - make_interval(days => $1)), 0) \ + FROM assets a", + &[&PURGE_GRACE_DAYS], + ) + .await + .map_err(AppError::query)?; + + // 本页素材的引用文章(第二查询,避免 JOIN fan-out 与分页错位)。 + let ids: Vec = rows.iter().map(|r| r.get::<_, String>("id")).collect(); + let mut refs_map: std::collections::HashMap> = + std::collections::HashMap::new(); + if !ids.is_empty() { + let ref_rows = client + .query( + "SELECT r.asset_id::text AS asset_id, p.id AS post_id, p.title \ + FROM asset_refs r JOIN posts p ON p.id = r.post_id \ + WHERE r.asset_id = ANY($1::uuid[]) \ + ORDER BY p.id", + &[&ids], + ) + .await + .map_err(AppError::query)?; + for rr in ref_rows { + refs_map + .entry(rr.get::<_, String>("asset_id")) + .or_default() + .push(AssetRef { + post_id: rr.get("post_id"), + title: rr.get("title"), + }); + } + } + + let assets = rows + .into_iter() + .map(|row| { + let id: String = row.get("id"); + let refs = refs_map.remove(&id).unwrap_or_default(); + AssetDto { + asset: Asset { + id, + path: row.get("path"), + filename: row.get("filename"), + mime: row.get("mime"), + size_bytes: row.get("size_bytes"), + width: row.get("width"), + height: row.get("height"), + alt: row.get("alt"), + created_at: row.get("created_at"), + }, + ref_count: row.get("ref_count"), + refs, + } + }) + .collect(); + + Ok(AssetListResponse { + assets, + total, + used_count: summary.get(0), + orphan_count: summary.get(1), + purgeable_count: summary.get(2), + purgeable_bytes: summary.get(3), + }) + } + #[cfg(not(feature = "server"))] + unreachable!() +} diff --git a/src/api/assets/mod.rs b/src/api/assets/mod.rs new file mode 100644 index 0000000..46ec3de --- /dev/null +++ b/src/api/assets/mod.rs @@ -0,0 +1,13 @@ +//! 素材管理 API 模块。 +//! +//! 管理 `uploads/` 图片的注册表(assets 表)与引用关系(asset_refs 表): +//! 分页列表、删除保护、孤儿清理、全量重建索引。 +//! 全部为 Dioxus server function,仅 admin 可用。 + +/// 素材分页列表。 +pub mod list; +/// 请求与响应数据结构。 +pub mod types; + +pub use list::list_assets; +pub use types::{AssetListResponse, AssetOpResponse, PurgeOrphansResponse, RebuildAssetsResponse}; diff --git a/src/api/assets/types.rs b/src/api/assets/types.rs new file mode 100644 index 0000000..488b0e1 --- /dev/null +++ b/src/api/assets/types.rs @@ -0,0 +1,81 @@ +//! 素材管理 API 的请求与响应数据结构。 + +use serde::{Deserialize, Serialize}; + +use crate::models::asset::AssetDto; + +/// 素材分页列表响应。 +/// +/// 附带各筛选维度的计数(tabs 展示)与可清理孤儿的统计(清理按钮展示), +/// 避免前端为徽标数字额外发请求。 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AssetListResponse { + pub assets: Vec, + /// 当前筛选条件下的总数(分页用)。 + pub total: i64, + /// 全部被引用素材数(「引用中」tab)。 + pub used_count: i64, + /// 全部无引用素材数(「孤儿」tab,含 7 天保护窗内的)。 + pub orphan_count: i64, + /// 可一键清理的孤儿数(无引用且 created_at 早于 7 天前)。 + pub purgeable_count: i64, + /// 可清理孤儿的总字节数。 + pub purgeable_bytes: i64, +} + +/// 通用素材操作响应(删除/清理/重建共用)。 +/// +/// 业务拒绝(如引用中禁删)走 `Ok(success:false)`,遵循仓库约定不走 Err。 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AssetOpResponse { + pub success: bool, + pub message: String, + /// 删除被拦截时的引用文章列表(post_id, title)。 + pub refs: Vec, +} + +impl AssetOpResponse { + pub fn ok(message: String) -> Self { + Self { + success: true, + message, + refs: Vec::new(), + } + } + + pub fn err(message: String) -> Self { + Self { + success: false, + message, + refs: Vec::new(), + } + } +} + +/// 一键清理孤儿的结果。 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PurgeOrphansResponse { + pub success: bool, + pub message: String, + pub deleted_count: i64, + pub freed_bytes: i64, + /// 删文件失败但 DB 行已删的素材数(文件可能已不存在,属可容忍不一致)。 + pub failures: i64, +} + +/// 重建索引的结果。 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RebuildAssetsResponse { + pub success: bool, + pub message: String, + /// 磁盘扫描到的图片文件数。 + pub scanned: i64, + /// 新登记进 assets 的数量。 + pub inserted: i64, + /// 已存在并更新技术字段的数量。 + pub updated: i64, + /// 文件已消失而删除的 DB 行数。 + pub removed: i64, + /// 重建后的引用关联总数。 + pub ref_count: i64, +} diff --git a/src/api/mod.rs b/src/api/mod.rs index 510bd79..1a7684a 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -6,6 +6,8 @@ /// 认证相关的 Dioxus server function。 pub mod auth; +/// 素材管理(assets/asset_refs)的 Dioxus server function。 +pub mod assets; /// 代码运行接口与数据结构。 pub mod code_runner; /// 评论相关接口。 diff --git a/src/components/admin_layout.rs b/src/components/admin_layout.rs index 9ebe846..c60978e 100644 --- a/src/components/admin_layout.rs +++ b/src/components/admin_layout.rs @@ -44,6 +44,7 @@ pub fn AdminLayout() -> Element { (Route::Admin {}, "仪表盘"), (Route::Write {}, "写文章"), (Route::Posts {}, "管理文章"), + (Route::Assets {}, "素材"), (Route::Runner {}, "试运行"), (Route::System {}, "系统"), ]; diff --git a/src/pages/admin/assets.rs b/src/pages/admin/assets.rs new file mode 100644 index 0000000..387cf46 --- /dev/null +++ b/src/pages/admin/assets.rs @@ -0,0 +1,226 @@ +//! 素材管理页面。 +//! +//! 网格浏览 `uploads/` 已登记图片:搜索(文件名/alt)、引用状态筛选 +//! (全部/引用中/孤儿)、排序(最新/最大)、客户端分页。 +//! 缩略图直接复用 `serve_image` 的动态处理(`?thumb=300x300`),零额外成本。 + +use dioxus::prelude::*; + +use crate::api::assets::list_assets; +use crate::api::assets::AssetListResponse; +use crate::components::empty_state::EmptyState; +use crate::components::ui::{FilterTabs, Pagination}; +use crate::models::asset::{AssetFilter, AssetSort}; + +/// 每页素材数,与服务端 list.rs 的 PER_PAGE 对齐。 +const ASSETS_PER_PAGE: i32 = 60; + +/// 格式化字节数为可读字符串(B/KB/MB/GB)。 +fn format_bytes(bytes: i64) -> String { + const KB: f64 = 1024.0; + const MB: f64 = KB * 1024.0; + const GB: f64 = MB * 1024.0; + let b = bytes as f64; + if b >= GB { + format!("{:.1} GB", b / GB) + } else if b >= MB { + format!("{:.1} MB", b / MB) + } else if b >= KB { + format!("{:.1} KB", b / KB) + } else { + format!("{} B", bytes) + } +} + +/// 素材管理入口组件。 +#[component] +pub fn Assets() -> Element { + // 筛选/搜索/排序/分页状态:全部客户端驱动(单路由 + signal,对齐「管理文章」模式)。 + let mut filter = use_signal(|| "all".to_string()); + let mut query = use_signal(String::new); + let mut sort = use_signal(|| "created".to_string()); + let mut page = use_signal(|| 1_i32); + + let mut data: Signal> = use_signal(|| None); + #[allow(unused_mut)] + let mut loading: Signal = use_signal(|| true); + #[allow(unused_mut)] + let mut error: Signal> = use_signal(|| None); + + // 数据加载:任一查询条件变化时重新请求。筛选/搜索/排序变化时重置到第 1 页。 + use_effect(move || { + let f = filter(); + let q = query(); + let s = sort(); + let p = page(); + + #[cfg(target_arch = "wasm32")] + { + let filter_enum = match f.as_str() { + "used" => AssetFilter::Used, + "orphan" => AssetFilter::Orphan, + _ => AssetFilter::All, + }; + let sort_enum = if s == "size" { + AssetSort::SizeDesc + } else { + AssetSort::CreatedDesc + }; + spawn(async move { + loading.set(true); + error.set(None); + match list_assets(filter_enum, q, sort_enum, p).await { + Ok(resp) => data.set(Some(resp)), + Err(e) => error.set(Some(e.to_string())), + } + loading.set(false); + }); + } + }); + + 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 all_count = used_count + orphan_count; + + // 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"; + let sort_active = "border-[var(--color-paper-primary)] text-[var(--color-paper-primary)]"; + let sort_idle = "border-[var(--color-paper-border)] text-[var(--color-paper-secondary)] hover:text-[var(--color-paper-primary)]"; + let sort_created_class = if sort() == "created" { + format!("{sort_btn_base} {sort_active}") + } else { + format!("{sort_btn_base} {sort_idle}") + }; + let sort_size_class = if sort() == "size" { + format!("{sort_btn_base} {sort_active}") + } else { + format!("{sort_btn_base} {sort_idle}") + }; + + rsx! { + div { + h1 { class: "text-3xl font-extrabold tracking-tight mb-2", "素材管理" } + p { class: "text-sm text-[var(--color-paper-secondary)] mb-8", + "管理文章编辑器上传的图片。共 {all_count} 张,引用中 {used_count} 张,孤儿 {orphan_count} 张。" + } + + // 顶栏:筛选 tabs + 搜索 + 排序 + div { class: "flex flex-wrap items-end justify-between gap-4", + FilterTabs { + items: vec![("all", "全部"), ("used", "引用中"), ("orphan", "孤儿")], + active_value: filter(), + on_change: move |v: String| { + filter.set(v); + page.set(1); + }, + } + div { class: "flex items-center gap-3 pb-1", + input { + class: "w-56 text-sm bg-[var(--color-paper-entry)] text-[var(--color-paper-primary)] placeholder-[var(--color-paper-tertiary)] focus:outline-none border border-[var(--color-paper-border)] focus:border-[var(--color-paper-primary)] rounded-2xl px-4 py-2 shadow-sm transition-all", + r#type: "search", + placeholder: "搜索文件名 / alt", + value: "{query}", + oninput: move |evt| { + query.set(evt.value()); + page.set(1); + }, + } + button { + class: "{sort_created_class}", + onclick: move |_| { + sort.set("created".to_string()); + page.set(1); + }, + "最新" + } + button { + class: "{sort_size_class}", + onclick: move |_| { + sort.set("size".to_string()); + page.set(1); + }, + "最大" + } + } + } + + // 内容区 + if let Some(err) = error() { + div { class: "mt-8 text-sm text-red-500", "加载失败:{err}" } + } else if loading() && assets.is_empty() { + div { class: "mt-8 text-sm text-[var(--color-paper-secondary)]", "加载中..." } + } else if assets.is_empty() { + EmptyState { + title: "暂无素材".to_string(), + description: "在编辑器中上传图片后会自动出现在这里".to_string(), + } + } else { + // 网格:缩略图卡片 + div { class: "grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-6 gap-4 mt-2", + for asset in assets.iter() { + { + let a = &asset.asset; + let thumb = format!("/uploads/{}?thumb=300x300", a.path); + let is_orphan = asset.ref_count == 0; + let badge_class = if is_orphan { + "absolute top-2 left-2 text-[10px] font-mono px-2 py-0.5 rounded-full backdrop-blur-sm bg-amber-500/80 text-white" + } else { + "absolute top-2 left-2 text-[10px] font-mono px-2 py-0.5 rounded-full backdrop-blur-sm bg-black/50 text-white" + }; + 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", + div { class: "aspect-square overflow-hidden bg-[var(--color-paper-theme)]", + img { + class: "w-full h-full object-cover", + src: "{thumb}", + alt: a.alt.clone().unwrap_or_else(|| a.filename.clone()), + loading: "lazy", + } + } + // 引用徽标 + span { + class: "{badge_class}", + if is_orphan { + "孤儿" + } else { + "被 {asset.ref_count} 篇引用" + } + } + div { class: "p-3", + p { class: "text-xs font-medium truncate text-[var(--color-paper-primary)]", + title: "{a.filename}", + "{a.filename}" + } + p { class: "text-[10px] font-mono text-[var(--color-paper-tertiary)] mt-0.5", + "{a.width}×{a.height} · {format_bytes(a.size_bytes)}" + } + } + } + } + } + } + } + + Pagination { + variant: "admin", + current_page: page(), + total, + per_page: ASSETS_PER_PAGE, + unit: "张", + on_prev: move |_| page.set((page() - 1).max(1)), + on_next: move |_| page.set(page() + 1), + } + } + } + } +} diff --git a/src/pages/admin/mod.rs b/src/pages/admin/mod.rs index 45bb1f2..affb883 100644 --- a/src/pages/admin/mod.rs +++ b/src/pages/admin/mod.rs @@ -2,6 +2,8 @@ //! //! 汇总并重新导出后台管理相关的页面组件,供路由与其他模块使用。 +/// 素材管理页面模块。 +pub mod assets; /// 评论管理页面模块。 pub mod comments; /// 管理后台仪表盘页面模块。 @@ -17,6 +19,8 @@ pub mod system; /// 文章编辑器页面模块(基于 Tiptap 富文本编辑器)。 pub mod write; +/// 素材管理页面组件。 +pub use assets::Assets; /// 评论管理入口组件(带默认分页)。 pub use comments::{AdminComments, AdminCommentsPage}; /// 管理后台仪表盘组件。 diff --git a/src/router.rs b/src/router.rs index bd978e3..60c3faa 100644 --- a/src/router.rs +++ b/src/router.rs @@ -12,7 +12,7 @@ use crate::components::frontend_layout::FrontendLayout; use crate::context::UserContext; use crate::pages::about::About; use crate::pages::admin::{ - Admin, AdminComments, AdminCommentsPage, Posts, Runner, System, Write, WriteEdit, + Admin, AdminComments, AdminCommentsPage, Assets, Posts, Runner, System, Write, WriteEdit, }; use crate::pages::archives::Archives; use crate::pages::home::{Home, HomePage}; @@ -77,6 +77,9 @@ pub enum Route { /// 文章管理(列表 + 回收站,客户端 tab 切换,单一路由) #[route("/posts")] Posts {}, + /// 素材管理(上传图片注册表:浏览/搜索/删除/孤儿清理) + #[route("/assets")] + Assets {}, /// 评论管理 #[route("/comments")] AdminComments {},