feat(assets): 素材管理只读列表页

- list_assets:引用状态筛选 + filename/alt 搜索 + 时间/大小排序 +
  分页(60/页),一次响应带齐 tabs 计数与可清理孤儿统计
- /admin/assets 网格页:?thumb=300x300 缩略图卡片 + 引用徽标,
  单路由客户端分页(对齐管理文章模式),导航加「素材」入口
This commit is contained in:
xfy 2026-07-24 16:15:19 +08:00
parent 0c811efd4d
commit 99b96e7a0c
8 changed files with 517 additions and 1 deletions

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

@ -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<AssetListResponse, 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::{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<String> = 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}"
),
&params,
)
.await
.map_err(AppError::query)?;
let total: i64 = client
.query_one(
&format!("SELECT COUNT(*) FROM assets a {where_clause}"),
&params[..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<String> = rows.iter().map(|r| r.get::<_, String>("id")).collect();
let mut refs_map: std::collections::HashMap<String, Vec<AssetRef>> =
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!()
}

13
src/api/assets/mod.rs Normal file
View File

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

81
src/api/assets/types.rs Normal file
View File

@ -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<AssetDto>,
/// 当前筛选条件下的总数(分页用)。
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<crate::models::asset::AssetRef>,
}
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,
}

View File

@ -6,6 +6,8 @@
/// 认证相关的 Dioxus server function。
pub mod auth;
/// 素材管理assets/asset_refs的 Dioxus server function。
pub mod assets;
/// 代码运行接口与数据结构。
pub mod code_runner;
/// 评论相关接口。

View File

@ -44,6 +44,7 @@ pub fn AdminLayout() -> Element {
(Route::Admin {}, "仪表盘"),
(Route::Write {}, "写文章"),
(Route::Posts {}, "管理文章"),
(Route::Assets {}, "素材"),
(Route::Runner {}, "试运行"),
(Route::System {}, "系统"),
];

226
src/pages/admin/assets.rs Normal file
View File

@ -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<Option<AssetListResponse>> = use_signal(|| None);
#[allow(unused_mut)]
let mut loading: Signal<bool> = use_signal(|| true);
#[allow(unused_mut)]
let mut error: Signal<Option<String>> = 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),
}
}
}
}
}

View File

@ -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};
/// 管理后台仪表盘组件。

View File

@ -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 {},