feat(write): 支持封面图上传(拖拽/点击/粘贴)

将编辑页封面图从纯 URL 输入框升级为完整的上传控件:
- 16:9 拖拽区,支持拖拽文件、点击选择、剪贴板粘贴三种上传入口
- 完整交互状态:空态/上传中(骨架占位)/预览/错误提示
- 保留外链 URL 输入(内联折叠),覆盖图床/CDN 场景
- 上传复用现有 POST /api/upload,服务端零改动

tiptap_bridge 提取 upload_image_file 通用上传函数,
make_upload_upload 改为薄包装,消除 fetch 逻辑重复。

Cargo.toml 补 web-sys feature(ClipboardEvent/DataTransfer/FileList/
HtmlInputElement),供粘贴/拖拽读取文件。
This commit is contained in:
xfy 2026-06-24 15:09:47 +08:00
parent ff2694c5a3
commit 8971fbe1a2
3 changed files with 353 additions and 70 deletions

View File

@ -40,7 +40,7 @@ futures = { version = "0.3", optional = true }
bytes = { version = "1", optional = true } bytes = { version = "1", optional = true }
[target.'cfg(target_arch = "wasm32")'.dependencies] [target.'cfg(target_arch = "wasm32")'.dependencies]
web-sys = { version = "0.3", features = ["Document", "Window", "Storage", "Element", "HtmlElement", "DomTokenList", "MediaQueryList", "HtmlImageElement", "MouseEvent", "KeyboardEvent", "Node", "EventTarget", "Navigator", "File", "FormData", "Request", "RequestInit", "Response", "Headers"] } web-sys = { version = "0.3", features = ["Document", "Window", "Storage", "Element", "HtmlElement", "DomTokenList", "MediaQueryList", "HtmlImageElement", "MouseEvent", "KeyboardEvent", "Node", "EventTarget", "Navigator", "File", "FileList", "FormData", "Request", "RequestInit", "Response", "Headers", "ClipboardEvent", "DataTransfer", "HtmlInputElement"] }
wasm-bindgen = "0.2" wasm-bindgen = "0.2"
wasm-bindgen-futures = "0.4" wasm-bindgen-futures = "0.4"
js-sys = "0.3" js-sys = "0.3"

View File

@ -15,7 +15,7 @@ use crate::api::posts::{
create_post, get_post_by_id, update_post, CreatePostResponse, SinglePostResponse, create_post, get_post_by_id, update_post, CreatePostResponse, SinglePostResponse,
}; };
#[cfg(target_arch = "wasm32")] #[cfg(target_arch = "wasm32")]
use crate::tiptap_bridge::{consume_upload_event, EditorHandle}; use crate::tiptap_bridge::{consume_upload_event, upload_image_file, EditorHandle};
// 共享上传状态类型两端都编译rsx 在 server SSR 时也要渲染这些结构)。 // 共享上传状态类型两端都编译rsx 在 server SSR 时也要渲染这些结构)。
use crate::tiptap_bridge::{UploadErrorEntry, UploadsInFlight}; use crate::tiptap_bridge::{UploadErrorEntry, UploadsInFlight};
use crate::components::write_skeleton::WriteSkeleton; use crate::components::write_skeleton::WriteSkeleton;
@ -23,6 +23,15 @@ use crate::models::post::Post;
use crate::router::Route; use crate::router::Route;
#[cfg(target_arch = "wasm32")] #[cfg(target_arch = "wasm32")]
use wasm_bindgen::closure::Closure; use wasm_bindgen::closure::Closure;
// 封面图上传:从 Dioxus 事件拿底层 web_sys::File以及 paste 事件的原始 web 事件。
// 这三个 trait 仅在 WASM 端可用dioxus-web 提供),通过 dioxus 的 re-export 访问:
// - HasFileDataevt.files()FormEvent / DragEvent 取文件)— dioxus::html
// - WebFileExtfile.get_web_file()FileData 取底层 web_sys::File— dioxus::web
// - WebEventExtevt.try_as_web_event()ClipboardEvent 取原始 web 事件)— dioxus::web
#[cfg(target_arch = "wasm32")]
use dioxus::html::HasFileData;
#[cfg(target_arch = "wasm32")]
use dioxus::web::{WebEventExt, WebFileExt};
/// 新建文章页面组件。 /// 新建文章页面组件。
/// ///
@ -62,6 +71,13 @@ fn write_editor(post_id: Option<i32>) -> Element {
let mut cover_image = use_signal(|| "".to_string()); let mut cover_image = use_signal(|| "".to_string());
let mut status = use_signal(|| "draft".to_string()); let mut status = use_signal(|| "draft".to_string());
let mut content = use_signal(|| "".to_string()); let mut content = use_signal(|| "".to_string());
// 封面图上传状态uploading 进度态、错误消息、URL 输入框展开、拖拽高亮。
let mut cover_uploading = use_signal(|| false);
let mut cover_error = use_signal(|| None::<String>);
let mut cover_url_mode = use_signal(|| false);
let mut cover_drag_active = use_signal(|| false);
// 封面 URL 输入框的临时值(确认前不直接写入 cover_image避免半截 URL 触发预览加载)。
let mut cover_url_input = use_signal(|| "".to_string());
// 页面与编辑器加载、保存、错误、成功等状态。 // 页面与编辑器加载、保存、错误、成功等状态。
let mut loading = use_signal(|| true); let mut loading = use_signal(|| true);
let mut saving = use_signal(|| false); let mut saving = use_signal(|| false);
@ -216,6 +232,27 @@ fn write_editor(post_id: Option<i32>) -> Element {
loading.set(false); loading.set(false);
}); });
// 封面图上传spawn 一个 async 调用 upload_image_file。
// 三条入口file input / drop / paste收敛成拿到 web_sys::File 后统一调用此闭包。
// 仅在 WASM 端有意义upload_image_file 与 spawn 都依赖 WASM 运行时),
// server SSR 不渲染上传逻辑,故整体 cfg-gate 避免引用 wasm-only 符号。
#[cfg(target_arch = "wasm32")]
let mut spawn_cover_upload = move |file: web_sys::File| {
cover_uploading.set(true);
cover_error.set(None);
spawn(async move {
match upload_image_file(file).await {
Ok(url) => {
cover_image.set(url);
}
Err(msg) => {
cover_error.set(Some(msg));
}
}
cover_uploading.set(false);
});
};
// 提交表单:校验标题与内容,读取 Tiptap 编辑器 Markdown调用 create_post 或 update_post。 // 提交表单:校验标题与内容,读取 Tiptap 编辑器 Markdown调用 create_post 或 update_post。
let on_submit = move |_| { let on_submit = move |_| {
// 上传未完成/失败拦截:有占位符时阻止保存 // 上传未完成/失败拦截:有占位符时阻止保存
@ -231,6 +268,12 @@ fn write_editor(post_id: Option<i32>) -> Element {
} }
drop(in_flight); drop(in_flight);
// 封面图上传中拦截:防止保存半成品。
if cover_uploading() {
error.set(Some("封面图正在上传,请等待完成后再保存".to_string()));
return;
}
if title().trim().is_empty() { if title().trim().is_empty() {
error.set(Some("标题不能为空".to_string())); error.set(Some("标题不能为空".to_string()));
} }
@ -405,6 +448,241 @@ fn write_editor(post_id: Option<i32>) -> Element {
oninput: move |evt| summary.set(evt.value()), oninput: move |evt| summary.set(evt.value()),
} }
// 封面图上传区16:9 拖拽/点击/粘贴三合一,保留外链 URL 输入。
// 容器统一绑定拖拽与粘贴事件;内部按 cover_image / cover_uploading 切换空态、上传中、预览。
div {
class: "relative aspect-video w-full rounded-xl border border-dashed overflow-hidden transition-all duration-200 group/cover",
class: if cover_drag_active() {
"border-[var(--color-paper-accent)] bg-[var(--color-paper-accent-soft)]"
} else if cover_image().is_empty() {
"border-[var(--color-paper-border)] bg-[var(--color-paper-entry)] hover:border-[var(--color-paper-accent)] hover:bg-[var(--color-paper-accent-soft)]"
} else {
"border-[var(--color-paper-border)] bg-[var(--color-paper-entry)]"
},
// 整个容器可接收拖拽与粘贴ondragover 必须 prevent_default否则浏览器直接打开文件
ondragover: move |evt| {
evt.prevent_default();
if !cover_uploading() && cover_image().is_empty() {
cover_drag_active.set(true);
}
},
ondragenter: move |evt| {
evt.prevent_default();
},
ondragleave: move |_| {
cover_drag_active.set(false);
},
ondrop: move |evt| {
evt.prevent_default();
cover_drag_active.set(false);
if !cover_uploading() && cover_image().is_empty() {
#[cfg(target_arch = "wasm32")]
{
if let Some(file) = evt.files().into_iter().next() {
if let Some(web_file) = file.get_web_file() {
spawn_cover_upload(web_file);
}
}
}
}
},
onpaste: move |evt| {
evt.prevent_default();
if !cover_uploading() && cover_image().is_empty() {
#[cfg(target_arch = "wasm32")]
{
use wasm_bindgen::JsCast;
if let Some(raw) = evt.try_as_web_event() {
if let Some(ce) = raw.dyn_ref::<web_sys::ClipboardEvent>() {
if let Some(dt) = ce.clipboard_data() {
if let Some(file_list) = dt.files() {
if let Some(file) = file_list.item(0) {
spawn_cover_upload(file);
}
}
}
}
}
}
}
},
// —— 上传中:骨架占位 + 文案 ——
if cover_uploading() {
div { class: "absolute inset-0 flex flex-col items-center justify-center gap-3 bg-[var(--color-paper-tertiary)]/30 animate-pulse",
span { class: "text-sm text-[var(--color-paper-secondary)]",
"上传中..."
}
}
}
// —— 有图:预览 + 移除/更换 ——
if !cover_image().is_empty() && !cover_uploading() {
// 预览图:/uploads/ 路径加 ?w=600 缩略,外链 URL 原样用。
// 用条件表达式内联计算,避免 rsx 内 let 块(宏在 server 端解析受限)。
img {
class: "absolute inset-0 w-full h-full object-cover",
src: {
let cv = cover_image();
if cv.starts_with("/uploads/") {
let base = cv.split('?').next().unwrap_or(&cv);
if cv.contains('?') { format!("{}&w=600", base) } else { format!("{}?w=600", base) }
} else {
cv
}
},
alt: "封面预览",
// 外链预览加载失败时提示,避免空白。
onerror: move |_| {
cover_error.set(Some("封面图加载失败,请检查 URL".to_string()));
},
}
// 右上角移除按钮hover 出现)。
button {
class: "absolute top-2 right-2 w-7 h-7 flex items-center justify-center rounded-full bg-black/50 text-white opacity-0 group-hover/cover:opacity-100 transition-opacity hover:bg-black/70 cursor-pointer",
aria_label: "移除封面",
onclick: move |_| {
cover_image.set(String::new());
cover_error.set(None);
cover_url_mode.set(false);
cover_url_input.set(String::new());
},
// 内联 SVG关闭 X与 header.rs 关闭按钮同风格view_box 0 0 24 24
svg {
class: "w-4 h-4",
xmlns: "http://www.w3.org/2000/svg",
view_box: "0 0 24 24",
fill: "none",
stroke: "currentColor",
stroke_width: "2",
stroke_linecap: "round",
stroke_linejoin: "round",
path { d: "M6 6l12 12M6 18L18 6" }
}
}
// 底部渐变遮罩 + "更换封面"提示hover 出现)。
div { class: "absolute inset-x-0 bottom-0 h-12 bg-gradient-to-t from-black/50 to-transparent opacity-0 group-hover/cover:opacity-100 transition-opacity flex items-end justify-center pb-2 pointer-events-none",
span { class: "text-xs text-white/90",
"点击下方区域可重新上传"
}
}
}
// —— 空态:图标 + 三入口提示 + URL 文字链 ——
if cover_image().is_empty() && !cover_uploading() {
// label 包裹整个空态:点击天然触发隐藏的 file input无需 JS。
label { class: "absolute inset-0 flex flex-col items-center justify-center gap-2 cursor-pointer px-4 text-center",
// 上传图标Feather 风格线框,与项目现有图标体系一致)。
svg {
class: "w-8 h-8 text-[var(--color-paper-secondary)]",
xmlns: "http://www.w3.org/2000/svg",
view_box: "0 0 24 24",
fill: "none",
stroke: "currentColor",
stroke_width: "1.8",
stroke_linecap: "round",
stroke_linejoin: "round",
path { d: "M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" }
polyline { points: "17 8 12 3 7 8" }
line {
x1: "12",
y1: "3",
x2: "12",
y2: "15",
}
}
span { class: "text-sm text-[var(--color-paper-secondary)]",
"拖拽图片到此 · 点击选择 · 粘贴"
}
// URL 文字链:阻止 label 的默认 file 触发,切换到 URL 输入模式。
span {
class: "text-[11px] font-medium tracking-wider text-[var(--color-paper-tertiary)] hover:text-[var(--color-paper-accent)] transition-colors",
onclick: move |evt| {
evt.prevent_default();
evt.stop_propagation();
cover_url_mode.set(true);
cover_url_input.set(cover_image());
},
"或使用图片 URL"
}
// 隐藏的 file input由 label 点击触发。
input {
r#type: "file",
accept: "image/jpeg,image/png,image/gif,image/webp",
class: "hidden",
onchange: move |evt| {
#[cfg(target_arch = "wasm32")]
{
if let Some(file) = evt.files().into_iter().next() {
if let Some(web_file) = file.get_web_file() {
spawn_cover_upload(web_file);
}
}
}
// 注意:未重置 input.value重复选择同一文件不会再次触发 onchange。
// 这是 file input 的通用行为,封面场景影响可忽略。
},
}
}
}
}
// —— URL 输入模式(内联展开,空态时叠加在容器外,避免与拖拽区争抢点击)——
if cover_url_mode() && cover_image().is_empty() {
div { class: "flex items-center gap-2 mt-2",
input {
class: "flex-1 {META_INPUT_CLASS}",
placeholder: "粘贴图片 URL",
value: "{cover_url_input}",
oninput: move |evt| cover_url_input.set(evt.value()),
onkeydown: move |evt| {
if evt.key() == Key::Enter {
let v = cover_url_input().trim().to_string();
if !v.is_empty() {
cover_image.set(v);
cover_error.set(None);
cover_url_mode.set(false);
}
}
},
}
button {
class: "shrink-0 px-3 py-1 text-xs text-[var(--color-paper-theme)] bg-[var(--color-paper-accent)] rounded-full hover:brightness-110 active:scale-[0.98] transition-all cursor-pointer",
onclick: move |_| {
let v = cover_url_input().trim().to_string();
if !v.is_empty() {
cover_image.set(v);
cover_error.set(None);
cover_url_mode.set(false);
}
},
"确认"
}
button {
class: "shrink-0 px-3 py-1 text-xs text-[var(--color-paper-secondary)] hover:text-[var(--color-paper-primary)] transition-colors cursor-pointer",
onclick: move |_| {
cover_url_mode.set(false);
cover_url_input.set(String::new());
},
"取消"
}
}
}
// 封面上传失败提示:复用页面红色条风格。
if let Some(err) = cover_error() {
div { class: "flex items-center justify-between gap-3 px-4 py-2 bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 rounded-xl text-sm border border-red-100 dark:border-red-900/30 mt-2",
span { "封面图: {err}" }
button {
class: "shrink-0 text-red-400 hover:text-red-600 cursor-pointer text-lg leading-none",
aria_label: "关闭提示",
onclick: move |_| cover_error.set(None),
"×"
}
}
}
// 元数据行 - 紧凑精致 // 元数据行 - 紧凑精致
div { class: "flex flex-wrap items-end gap-x-8 gap-y-4 text-sm", div { class: "flex flex-wrap items-end gap-x-8 gap-y-4 text-sm",
div { class: "flex-1 min-w-[140px]", div { class: "flex-1 min-w-[140px]",
@ -429,17 +707,6 @@ fn write_editor(post_id: Option<i32>) -> Element {
oninput: move |evt| tags.set(evt.value()), oninput: move |evt| tags.set(evt.value()),
} }
} }
div { class: "flex-1 min-w-[140px]",
label { class: "{META_LABEL_CLASS}",
"封面图"
}
input {
class: "{META_INPUT_CLASS}",
placeholder: "URL可选",
value: "{cover_image}",
oninput: move |evt| cover_image.set(evt.value()),
}
}
} }
} }

View File

@ -244,69 +244,85 @@ pub mod wasm {
// —— make_upload_closureRust fetch 上传 —— // —— make_upload_closureRust fetch 上传 ——
/// 创建图片上传 closureFormData POST /api/upload解析 {success, url, error}。 /// 通用图片上传FormData POST /api/upload解析 {success, url, error}。
/// ///
/// 行为与旧版 write.rs eval 注入的 JS fetch 等价: /// 由封面图上传spawn 直接 await与 Tiptap 编辑器上传make_upload_closure 包 Promise共用
/// 避免两处重复同一份 fetch + 解析逻辑。
///
/// 行为:
/// - credentials: same-origin携带 session cookie /// - credentials: same-origin携带 session cookie
/// - 字段名 'image'(与服务端 upload.rs 对齐) /// - 字段名 'image'(与服务端 upload.rs 对齐)
/// - 成功 → resolve(url);失败 → reject(Error(服务端中文 error)) /// - 成功 → Ok(url);失败 → Err(服务端中文 error或状态码兜底)
///
/// 构造阶段的错误FormData/Request 等)以 Err 返回,而非 panic
/// 单张坏文件不应导致整个上传流程崩溃。
pub async fn upload_image_file(file: web_sys::File) -> Result<String, String> {
// 构造 FormData字段名 'image' 与服务端 upload.rs 对齐
let form = web_sys::FormData::new().map_err(|_| "无法构造上传表单".to_string())?;
form.append_with_blob("image", &file)
.map_err(|_| "无法附加文件".to_string())?;
// 构造 POST 请求credentials same-origin 携带 session cookie
let init = web_sys::RequestInit::new();
init.set_method("POST");
// set_body 接收 &JsValue非 OptionFormData: AsRef<JsValue>。
init.set_body(form.as_ref());
init.set_credentials(web_sys::RequestCredentials::SameOrigin);
let request = web_sys::Request::new_with_str_and_init("/api/upload", &init)
.map_err(|_| "无法构造上传请求".to_string())?;
let window = web_sys::window().expect("no window");
let promise = window.fetch_with_request(&request);
// fetch Promise → Future → 解析响应体
let resp_val = wasm_bindgen_futures::JsFuture::from(promise)
.await
.map_err(|e| format!("上传请求失败: {:?}", e))?;
let resp: web_sys::Response = resp_val
.dyn_into()
.map_err(|_| "上传响应类型异常".to_string())?;
// 读响应体文本(无论 2xx 与否,服务端都返回 JSON
let text_promise = resp
.text()
.map_err(|e| format!("读取响应失败: {:?}", e))?;
let text_val = wasm_bindgen_futures::JsFuture::from(text_promise)
.await
.map_err(|e| format!("读取响应失败: {:?}", e))?;
let text = text_val.as_string().unwrap_or_default();
// 解析 {success, url, error}
let data: serde_json::Value =
serde_json::from_str(&text).unwrap_or(serde_json::Value::Null);
if data["success"].as_bool() == Some(true) {
let url = data["url"].as_str().unwrap_or("");
if !url.is_empty() {
Ok(url.to_string())
} else {
// success=true 但 url 为空:服务端契约异常,按失败处理
Err("上传成功但未返回图片地址".to_string())
}
} else {
// 失败:优先用服务端中文 error兜底用状态码
Err(data["error"]
.as_str()
.map(|s| s.to_string())
.unwrap_or_else(|| format!("上传失败: {}", resp.status())))
}
}
/// 创建 Tiptap 图片上传 closure内部复用 [`upload_image_file`],包装成 JS Promise。
/// ///
/// 返回的 closure 签名 `(File) -> Promise` 对应 JS `onImageUpload`。 /// 返回的 closure 签名 `(File) -> Promise` 对应 JS `onImageUpload`。
/// 构造阶段的错误FormData/Request 等)以 rejected Promise 返回,
/// 而非 panic —— 单张坏文件不应导致整个编辑器崩溃。
pub fn make_upload_closure() -> Closure<dyn Fn(web_sys::File) -> js_sys::Promise> { pub fn make_upload_closure() -> Closure<dyn Fn(web_sys::File) -> js_sys::Promise> {
Closure::new(move |file: web_sys::File| -> js_sys::Promise { Closure::new(move |file: web_sys::File| -> js_sys::Promise {
wasm_bindgen_futures::future_to_promise(async move { wasm_bindgen_futures::future_to_promise(async move {
// 构造 FormData字段名 'image' 与服务端 upload.rs 对齐 upload_image_file(file)
let form = web_sys::FormData::new() .await
.map_err(|_| js_sys::Error::new("无法构造上传表单"))?; .map(|url| js_sys::JsString::from(url).into())
form.append_with_blob("image", &file) .map_err(|msg| js_sys::Error::new(&msg).into())
.map_err(|_| js_sys::Error::new("无法附加文件"))?;
// 构造 POST 请求credentials same-origin 携带 session cookie
let init = web_sys::RequestInit::new();
init.set_method("POST");
// set_body 接收 &JsValue非 OptionFormData: AsRef<JsValue>。
init.set_body(form.as_ref());
init.set_credentials(web_sys::RequestCredentials::SameOrigin);
let request = web_sys::Request::new_with_str_and_init("/api/upload", &init)
.map_err(|_| js_sys::Error::new("无法构造上传请求"))?;
let window = web_sys::window().expect("no window");
let promise = window.fetch_with_request(&request);
// 把 fetch Promise → Future → 解析响应体 → 再包回 Promise
let resp_val = wasm_bindgen_futures::JsFuture::from(promise).await?;
let resp: web_sys::Response = resp_val.dyn_into()?;
// 读响应体文本(无论 2xx 与否,服务端都返回 JSON
let text_promise = resp
.text()
.map_err(|e| js_sys::Error::new(&format!("读取响应失败: {:?}", e)))?;
let text_val = wasm_bindgen_futures::JsFuture::from(text_promise).await?;
let text = text_val.as_string().unwrap_or_default();
// 解析 {success, url, error}
let data: serde_json::Value =
serde_json::from_str(&text).unwrap_or(serde_json::Value::Null);
if data["success"].as_bool() == Some(true) {
let url = data["url"].as_str().unwrap_or("");
if !url.is_empty() {
Ok(js_sys::JsString::from(url).into())
} else {
// success=true 但 url 为空:服务端契约异常,按失败处理
Err(js_sys::Error::new("上传成功但未返回图片地址").into())
}
} else {
// 失败:优先用服务端中文 error兜底用状态码
let msg = data["error"]
.as_str()
.map(|s| s.to_string())
.unwrap_or_else(|| format!("上传失败: {}", resp.status()));
Err(js_sys::Error::new(&msg).into())
}
}) })
}) })
} }
@ -314,6 +330,6 @@ pub mod wasm {
#[cfg(target_arch = "wasm32")] #[cfg(target_arch = "wasm32")]
pub use wasm::{ pub use wasm::{
consume_upload_event, make_upload_closure, EditorHandle, EditorOptions, UploadEventJs, consume_upload_event, make_upload_closure, upload_image_file, EditorHandle, EditorOptions,
get_module, UploadEventJs, get_module,
}; };