From 5612f1642568ae2b33c9b4f7e8eccfadbe000aba Mon Sep 17 00:00:00 2001 From: xfy Date: Fri, 24 Jul 2026 16:32:43 +0800 Subject: [PATCH] =?UTF-8?q?feat(assets):=20=E5=B0=81=E9=9D=A2=E3=80=8C?= =?UTF-8?q?=E4=BB=8E=E7=B4=A0=E6=9D=90=E5=BA=93=E9=80=89=E6=8B=A9=E3=80=8D?= =?UTF-8?q?=E8=81=94=E5=8A=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AssetPickerModal:素材网格(最新 60 + 搜索)单击回填封面 URL, 内嵌「上传新图」(复用 upload_image_file,成功即选中, 失败留在 modal 内提示),上传期间置位 cover_uploading 拦截保存 - CoverUploader 空态加「素材库」入口(与「输入链接」并列) --- src/pages/admin/asset_picker.rs | 170 ++++++++++++++++++++++++++++++++ src/pages/admin/assets.rs | 2 +- src/pages/admin/mod.rs | 2 + src/pages/admin/write.rs | 25 ++++- 4 files changed, 197 insertions(+), 2 deletions(-) create mode 100644 src/pages/admin/asset_picker.rs diff --git a/src/pages/admin/asset_picker.rs b/src/pages/admin/asset_picker.rs new file mode 100644 index 0000000..875d4af --- /dev/null +++ b/src/pages/admin/asset_picker.rs @@ -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/` URL。 +/// - `cover_uploading`:modal 内上传新图时置位,供父页面拦截保存(与 CoverUploader 语义一致)。 +#[component] +pub fn AssetPickerModal( + mut visible: Signal, + on_select: EventHandler, + cover_uploading: Signal, +) -> Element { + let mut assets: Signal> = 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::); + + // 打开时与搜索词变化时加载第一页(最新 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", + } + } + } + } + } + } + } + } + } + } + } +} diff --git a/src/pages/admin/assets.rs b/src/pages/admin/assets.rs index cf1bfbc..d2e2c06 100644 --- a/src/pages/admin/assets.rs +++ b/src/pages/admin/assets.rs @@ -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 |_| { diff --git a/src/pages/admin/mod.rs b/src/pages/admin/mod.rs index affb883..b721348 100644 --- a/src/pages/admin/mod.rs +++ b/src/pages/admin/mod.rs @@ -2,6 +2,8 @@ //! //! 汇总并重新导出后台管理相关的页面组件,供路由与其他模块使用。 +/// 素材选择 modal(封面上「从素材库选择」)。 +pub mod asset_picker; /// 素材管理页面模块。 pub mod assets; /// 评论管理页面模块。 diff --git a/src/pages/admin/write.rs b/src/pages/admin/write.rs index 3068682..72b8f81 100644 --- a/src/pages/admin/write.rs +++ b/src/pages/admin/write.rs @@ -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, cover_uploading: Signal) -> 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, cover_uploading: Signal) -> 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, cover_uploading: Signal) -> } } } + + // 素材选择 modal:选中回填封面 URL(上传新图成功同样回填)。 + AssetPickerModal { + visible: picker_visible, + cover_uploading, + on_select: move |url: String| { + cover_image.set(url); + cover_error.set(None); + }, + } } }