feat(assets): 封面「从素材库选择」联动
- AssetPickerModal:素材网格(最新 60 + 搜索)单击回填封面 URL, 内嵌「上传新图」(复用 upload_image_file,成功即选中, 失败留在 modal 内提示),上传期间置位 cover_uploading 拦截保存 - CoverUploader 空态加「素材库」入口(与「输入链接」并列)
This commit is contained in:
parent
0c47e87d30
commit
5612f16425
170
src/pages/admin/asset_picker.rs
Normal file
170
src/pages/admin/asset_picker.rs
Normal file
@ -0,0 +1,170 @@
|
||||
//! 素材选择 modal(封面上「从素材库选择」)。
|
||||
//!
|
||||
//! 网格展示素材库(默认最新排序,支持文件名/alt 搜索),单击选中回填封面 URL。
|
||||
//! 内嵌「上传新图」入口(复用 `upload_image_file`),上传成功即选中。
|
||||
//! 纯 Dioxus 组件,不触碰 Tiptap;数据加载仅在 WASM 前端发生。
|
||||
|
||||
use dioxus::prelude::*;
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
use crate::api::assets::list_assets;
|
||||
use crate::models::asset::AssetDto;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
use crate::models::asset::{AssetFilter, AssetSort};
|
||||
|
||||
/// 素材选择 modal。
|
||||
///
|
||||
/// - `visible`:显隐控制(父组件持有,选中/点遮罩/× 都会置 false)。
|
||||
/// - `on_select`:选中回填,参数为 `/uploads/<path>` URL。
|
||||
/// - `cover_uploading`:modal 内上传新图时置位,供父页面拦截保存(与 CoverUploader 语义一致)。
|
||||
#[component]
|
||||
pub fn AssetPickerModal(
|
||||
mut visible: Signal<bool>,
|
||||
on_select: EventHandler<String>,
|
||||
cover_uploading: Signal<bool>,
|
||||
) -> Element {
|
||||
let mut assets: Signal<Vec<AssetDto>> = use_signal(Vec::new);
|
||||
#[allow(unused_mut)]
|
||||
let mut loading = use_signal(|| false);
|
||||
let mut query = use_signal(String::new);
|
||||
#[allow(unused_mut)]
|
||||
let mut error = use_signal(|| None::<String>);
|
||||
|
||||
// 打开时与搜索词变化时加载第一页(最新 60 张,封面复用场景足够)。
|
||||
use_effect(move || {
|
||||
let open = visible();
|
||||
let q = query();
|
||||
if !open {
|
||||
return;
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
spawn(async move {
|
||||
loading.set(true);
|
||||
match list_assets(AssetFilter::All, q, AssetSort::CreatedDesc, 1).await {
|
||||
Ok(resp) => {
|
||||
assets.set(resp.assets);
|
||||
error.set(None);
|
||||
}
|
||||
Err(e) => error.set(Some(e.to_string())),
|
||||
}
|
||||
loading.set(false);
|
||||
});
|
||||
});
|
||||
|
||||
if !visible() {
|
||||
return rsx! {};
|
||||
}
|
||||
|
||||
rsx! {
|
||||
// 遮罩:点击关闭
|
||||
div { class: "fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-6",
|
||||
onclick: move |_| visible.set(false),
|
||||
// 面板:阻止点击穿透到遮罩
|
||||
div { class: "w-full max-w-3xl max-h-[80vh] flex flex-col rounded-[2rem] bg-[var(--color-paper-entry)] border border-[var(--color-paper-border)] shadow-xl overflow-hidden",
|
||||
onclick: move |evt| evt.stop_propagation(),
|
||||
|
||||
// 头部:标题 + 搜索 + 上传 + 关闭
|
||||
div { class: "flex items-center gap-3 px-6 py-4 border-b border-[var(--color-paper-border)]",
|
||||
h2 { class: "text-lg font-bold text-[var(--color-paper-primary)] shrink-0",
|
||||
"选择封面图"
|
||||
}
|
||||
input {
|
||||
class: "flex-1 min-w-0 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()),
|
||||
}
|
||||
// 上传新图:成功后直接选中
|
||||
label { class: "shrink-0 text-sm font-medium px-4 py-2 rounded-full bg-[var(--color-paper-primary)] text-[var(--color-paper-theme)] hover:opacity-80 transition-opacity cursor-pointer",
|
||||
"上传新图"
|
||||
input {
|
||||
r#type: "file",
|
||||
accept: "image/jpeg,image/png,image/gif,image/webp",
|
||||
class: "hidden",
|
||||
onchange: move |evt| {
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
use dioxus::html::HasFileData;
|
||||
use dioxus::web::WebFileExt;
|
||||
if let Some(file) = evt.files().into_iter().next() {
|
||||
if let Some(web_file) = file.get_web_file() {
|
||||
cover_uploading.set(true);
|
||||
spawn(async move {
|
||||
match crate::tiptap_bridge::upload_image_file(
|
||||
web_file,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(url) => {
|
||||
on_select.call(url);
|
||||
visible.set(false);
|
||||
}
|
||||
// 失败留在 modal 内提示,不关闭。
|
||||
Err(msg) => error.set(Some(msg)),
|
||||
}
|
||||
cover_uploading.set(false);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
button {
|
||||
class: "shrink-0 w-8 h-8 flex items-center justify-center rounded-full text-[var(--color-paper-secondary)] hover:bg-[var(--color-paper-theme)] transition-colors cursor-pointer",
|
||||
aria_label: "关闭",
|
||||
onclick: move |_| visible.set(false),
|
||||
"×"
|
||||
}
|
||||
}
|
||||
|
||||
// 网格内容区
|
||||
div { class: "flex-1 overflow-y-auto p-4",
|
||||
if let Some(err) = error() {
|
||||
div { class: "py-12 text-center text-sm text-red-500", "加载失败:{err}" }
|
||||
} else if loading() && assets.read().is_empty() {
|
||||
div { class: "py-12 text-center text-sm text-[var(--color-paper-secondary)]",
|
||||
"加载中..."
|
||||
}
|
||||
} else if assets.read().is_empty() {
|
||||
div { class: "py-12 text-center text-sm text-[var(--color-paper-secondary)]",
|
||||
"素材库为空,点击「上传新图」添加"
|
||||
}
|
||||
} else {
|
||||
div { class: "grid grid-cols-3 sm:grid-cols-4 gap-3",
|
||||
for asset in assets.read().iter() {
|
||||
{
|
||||
let url = format!("/uploads/{}", asset.asset.path);
|
||||
let thumb = format!("{}?thumb=300x300", url);
|
||||
rsx! {
|
||||
button {
|
||||
key: "{asset.asset.id}",
|
||||
class: "group relative aspect-square rounded-2xl overflow-hidden border border-[var(--color-paper-border)] hover:border-[var(--color-paper-primary)] hover:shadow-md transition-all cursor-pointer",
|
||||
title: "{asset.asset.filename}",
|
||||
onclick: {
|
||||
let url = url.clone();
|
||||
move |_| {
|
||||
on_select.call(url.clone());
|
||||
visible.set(false);
|
||||
}
|
||||
},
|
||||
img {
|
||||
class: "w-full h-full object-cover",
|
||||
src: "{thumb}",
|
||||
alt: asset.asset.alt.clone().unwrap_or_else(|| {
|
||||
asset.asset.filename.clone()
|
||||
}),
|
||||
loading: "lazy",
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -366,7 +366,7 @@ pub fn Assets() -> Element {
|
||||
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",
|
||||
class: "text-[10px] font-medium cursor-pointer px-2 py-1 rounded-full bg-[var(--color-paper-primary)] text-[var(--color-paper-theme)] hover:opacity-80 transition-opacity",
|
||||
onclick: {
|
||||
let id = a.id.clone();
|
||||
move |_| {
|
||||
|
||||
@ -2,6 +2,8 @@
|
||||
//!
|
||||
//! 汇总并重新导出后台管理相关的页面组件,供路由与其他模块使用。
|
||||
|
||||
/// 素材选择 modal(封面上「从素材库选择」)。
|
||||
pub mod asset_picker;
|
||||
/// 素材管理页面模块。
|
||||
pub mod assets;
|
||||
/// 评论管理页面模块。
|
||||
|
||||
@ -20,6 +20,7 @@ use crate::tiptap_bridge::{consume_upload_event, upload_image_file, EditorHandle
|
||||
use crate::components::ui::{LoadingButton, BTN_CLOSE_ICON, BTN_PRIMARY_SM};
|
||||
use crate::components::write_skeleton::WriteSkeleton;
|
||||
use crate::models::post::Post;
|
||||
use crate::pages::admin::asset_picker::AssetPickerModal;
|
||||
use crate::router::Route;
|
||||
use crate::tiptap_bridge::{UploadErrorEntry, UploadsInFlight};
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
@ -606,6 +607,8 @@ fn CoverUploader(cover_image: Signal<String>, cover_uploading: Signal<bool>) ->
|
||||
let mut cover_drag_active = use_signal(|| false);
|
||||
// 封面 URL 输入框的临时值(确认前不直接写入 cover_image,避免半截 URL 触发预览加载)。
|
||||
let mut cover_url_input = use_signal(|| "".to_string());
|
||||
// 素材选择 modal 显隐。
|
||||
let mut picker_visible = use_signal(|| false);
|
||||
|
||||
// 封面图上传:spawn 一个 async 调用 upload_image_file。
|
||||
// 三条入口(file input / drop / paste)收敛成拿到 web_sys::File 后统一调用此闭包。
|
||||
@ -777,9 +780,19 @@ fn CoverUploader(cover_image: Signal<String>, cover_uploading: Signal<bool>) ->
|
||||
span { class: "text-sm font-medium text-[var(--color-paper-secondary)] shrink-0",
|
||||
"拖拽 / 粘贴 / 点击上传"
|
||||
}
|
||||
// URL 文字链:阻止 label 的默认 file 触发,切换到 URL 输入模式。
|
||||
// 从素材库选择:阻止 label 默认 file 触发,打开选择 modal。
|
||||
span {
|
||||
class: "text-sm font-medium text-[var(--color-paper-tertiary)] hover:text-[var(--color-paper-primary)] transition-colors ml-auto shrink-0",
|
||||
onclick: move |evt| {
|
||||
evt.prevent_default();
|
||||
evt.stop_propagation();
|
||||
picker_visible.set(true);
|
||||
},
|
||||
"素材库"
|
||||
}
|
||||
// URL 文字链:阻止 label 的默认 file 触发,切换到 URL 输入模式。
|
||||
span {
|
||||
class: "text-sm font-medium text-[var(--color-paper-tertiary)] hover:text-[var(--color-paper-primary)] transition-colors shrink-0",
|
||||
onclick: move |evt| {
|
||||
evt.prevent_default();
|
||||
evt.stop_propagation();
|
||||
@ -864,5 +877,15 @@ fn CoverUploader(cover_image: Signal<String>, cover_uploading: Signal<bool>) ->
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 素材选择 modal:选中回填封面 URL(上传新图成功同样回填)。
|
||||
AssetPickerModal {
|
||||
visible: picker_visible,
|
||||
cover_uploading,
|
||||
on_select: move |url: String| {
|
||||
cover_image.set(url);
|
||||
cover_error.set(None);
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user