feat(assets): 素材管理只读列表页
- list_assets:引用状态筛选 + filename/alt 搜索 + 时间/大小排序 + 分页(60/页),一次响应带齐 tabs 计数与可清理孤儿统计 - /admin/assets 网格页:?thumb=300x300 缩略图卡片 + 引用徽标, 单路由客户端分页(对齐管理文章模式),导航加「素材」入口
This commit is contained in:
parent
0c811efd4d
commit
99b96e7a0c
186
src/api/assets/list.rs
Normal file
186
src/api/assets/list.rs
Normal 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}"
|
||||
),
|
||||
¶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<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
13
src/api/assets/mod.rs
Normal 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
81
src/api/assets/types.rs
Normal 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,
|
||||
}
|
||||
@ -6,6 +6,8 @@
|
||||
|
||||
/// 认证相关的 Dioxus server function。
|
||||
pub mod auth;
|
||||
/// 素材管理(assets/asset_refs)的 Dioxus server function。
|
||||
pub mod assets;
|
||||
/// 代码运行接口与数据结构。
|
||||
pub mod code_runner;
|
||||
/// 评论相关接口。
|
||||
|
||||
@ -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
226
src/pages/admin/assets.rs
Normal 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),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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};
|
||||
/// 管理后台仪表盘组件。
|
||||
|
||||
@ -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 {},
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user