diff --git a/Cargo.toml b/Cargo.toml index 6ff18e5..aa715b7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -40,7 +40,7 @@ futures = { version = "0.3", optional = true } bytes = { version = "1", optional = true } [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-futures = "0.4" js-sys = "0.3" diff --git a/src/pages/admin/write.rs b/src/pages/admin/write.rs index 0b284f4..a09e7e6 100644 --- a/src/pages/admin/write.rs +++ b/src/pages/admin/write.rs @@ -15,7 +15,7 @@ use crate::api::posts::{ create_post, get_post_by_id, update_post, CreatePostResponse, SinglePostResponse, }; #[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 时也要渲染这些结构)。 use crate::tiptap_bridge::{UploadErrorEntry, UploadsInFlight}; use crate::components::write_skeleton::WriteSkeleton; @@ -23,6 +23,15 @@ use crate::models::post::Post; use crate::router::Route; #[cfg(target_arch = "wasm32")] use wasm_bindgen::closure::Closure; +// 封面图上传:从 Dioxus 事件拿底层 web_sys::File,以及 paste 事件的原始 web 事件。 +// 这三个 trait 仅在 WASM 端可用(dioxus-web 提供),通过 dioxus 的 re-export 访问: +// - HasFileData:evt.files()(FormEvent / DragEvent 取文件)— dioxus::html +// - WebFileExt:file.get_web_file()(FileData 取底层 web_sys::File)— dioxus::web +// - WebEventExt:evt.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) -> Element { let mut cover_image = use_signal(|| "".to_string()); let mut status = use_signal(|| "draft".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::); + 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 saving = use_signal(|| false); @@ -216,6 +232,27 @@ fn write_editor(post_id: Option) -> Element { 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。 let on_submit = move |_| { // 上传未完成/失败拦截:有占位符时阻止保存 @@ -231,6 +268,12 @@ fn write_editor(post_id: Option) -> Element { } drop(in_flight); + // 封面图上传中拦截:防止保存半成品。 + if cover_uploading() { + error.set(Some("封面图正在上传,请等待完成后再保存".to_string())); + return; + } + if title().trim().is_empty() { error.set(Some("标题不能为空".to_string())); } @@ -405,6 +448,241 @@ fn write_editor(post_id: Option) -> Element { 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::() { + 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-1 min-w-[140px]", @@ -429,17 +707,6 @@ fn write_editor(post_id: Option) -> Element { 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()), - } - } } } diff --git a/src/tiptap_bridge.rs b/src/tiptap_bridge.rs index fccb673..d1b784f 100644 --- a/src/tiptap_bridge.rs +++ b/src/tiptap_bridge.rs @@ -244,69 +244,85 @@ pub mod wasm { // —— make_upload_closure:Rust fetch 上传 —— - /// 创建图片上传 closure:FormData 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) /// - 字段名 '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 { + // 构造 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(非 Option);FormData: AsRef。 + 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`。 - /// 构造阶段的错误(FormData/Request 等)以 rejected Promise 返回, - /// 而非 panic —— 单张坏文件不应导致整个编辑器崩溃。 pub fn make_upload_closure() -> Closure js_sys::Promise> { Closure::new(move |file: web_sys::File| -> js_sys::Promise { wasm_bindgen_futures::future_to_promise(async move { - // 构造 FormData:字段名 'image' 与服务端 upload.rs 对齐 - let form = web_sys::FormData::new() - .map_err(|_| js_sys::Error::new("无法构造上传表单"))?; - form.append_with_blob("image", &file) - .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(非 Option);FormData: AsRef。 - 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()) - } + upload_image_file(file) + .await + .map(|url| js_sys::JsString::from(url).into()) + .map_err(|msg| js_sys::Error::new(&msg).into()) }) }) } @@ -314,6 +330,6 @@ pub mod wasm { #[cfg(target_arch = "wasm32")] pub use wasm::{ - consume_upload_event, make_upload_closure, EditorHandle, EditorOptions, UploadEventJs, - get_module, + consume_upload_event, make_upload_closure, upload_image_file, EditorHandle, EditorOptions, + UploadEventJs, get_module, };