//! 文章编辑器页面。 //! //! 提供新建文章与编辑文章两种模式,使用基于 Tiptap 的富文本编辑器。 //! 编辑器通过 [`crate::tiptap_bridge`] 的 wasm-bindgen 绑定在 WASM 前端初始化, //! 并与 `window.TiptapEditor` 实例交互,实现 Markdown 内容回填、图片上传与组件卸载时的清理。 // prelude 在 WASM 构建里直接使用(use_signal/Signal/Element 等); // server 构建里 #[component] 宏会重新导出这些符号导致 native 报 unused,故 allow。 #[allow(unused_imports)] use dioxus::prelude::*; // 仅在 WASM 前端使用的类型转换与文章 API。 #[cfg(target_arch = "wasm32")] 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, upload_image_file, EditorHandle}; // 共享上传状态类型:两端都编译(rsx 在 server SSR 时也要渲染这些结构)。 use crate::tiptap_bridge::{UploadErrorEntry, UploadsInFlight}; use crate::components::write_skeleton::WriteSkeleton; 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}; /// 新建文章页面组件。 /// /// 内部委托给 `write_editor`,以 `None` 表示新建模式。 #[component] #[cfg_attr(not(target_arch = "wasm32"), allow(unused_mut, unused_variables))] pub fn Write() -> Element { write_editor(None) } /// 编辑文章页面组件。 /// /// `id` 为要编辑的文章 ID,内部委托给 `write_editor` 加载现有数据。 #[component] #[cfg_attr(not(target_arch = "wasm32"), allow(unused_mut, unused_variables))] pub fn WriteEdit(id: i32) -> Element { write_editor(Some(id)) } /// 文章编辑器核心组件,支持新建(`post_id == None`)与编辑模式。 /// /// 负责: /// - 编辑模式下通过 server function 拉取文章数据; /// - 在 WASM 前端通过 tiptap_bridge 的 closure 回调初始化 Tiptap 富文本编辑器; /// - 编辑模式下将 Markdown 内容回填到编辑器; /// - 提交时读取编辑器 Markdown、校验并调用 create_post / update_post; /// - 组件卸载时销毁 Tiptap 实例(EditorHandle::drop 自动 destroy + 释放 closure)。 #[cfg_attr(not(target_arch = "wasm32"), allow(unused_mut, unused_variables))] fn write_editor(post_id: Option) -> Element { let is_edit = post_id.is_some(); // 文章元信息表单字段。 let mut title = use_signal(|| "".to_string()); let mut summary = use_signal(|| "".to_string()); let mut slug = use_signal(|| "".to_string()); let mut tags = use_signal(|| "".to_string()); 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); let mut error = use_signal(|| None::); let mut editor_content_set = use_signal(|| false); let mut has_backfilled = use_signal(|| false); let mut load_error = use_signal(|| None::); // 编辑模式:用于暂存从服务端加载的文章数据。 let mut edit_post = use_signal(|| None::); // WASM 前端:编辑器实例句柄(持有实例 + closure,统一生命周期)。 #[cfg(target_arch = "wasm32")] let mut editor: Signal> = use_signal(|| None); // WASM 前端:编辑器就绪标志(onReady 回调驱动,替代 __tiptap_ready 轮询)。 #[cfg(target_arch = "wasm32")] let ready = use_signal(|| false); // 上传状态:当前进行中计数(保存拦截)+ 顶部失败提示堆叠(用户手动关闭) let uploads_in_flight = use_signal(UploadsInFlight::default); let upload_errors: Signal> = use_signal(Vec::new); // 编辑模式:文章数据加载完成后,将字段回填到表单信号。 use_effect(move || { if !is_edit || has_backfilled() { return; } if let Some(ref post) = edit_post() { has_backfilled.set(true); title.set(post.title.clone()); summary.set(post.summary.clone().unwrap_or_default()); slug.set(post.slug.clone()); tags.set(post.tags.join(", ")); cover_image.set(post.cover_image.clone().unwrap_or_default()); status.set(post.status.as_str().to_string()); content.set(post.content_md.clone()); } }); // 编辑模式:仅在 WASM 前端通过 server function 加载文章详情。 use_effect(move || { if is_edit { #[cfg(target_arch = "wasm32")] if let Some(id) = post_id { spawn(async move { match get_post_by_id(id).await { Ok(SinglePostResponse { post: Some(post) }) => { edit_post.set(Some(post)); } Ok(SinglePostResponse { post: None }) => { load_error.set(Some("文章不存在".to_string())); } Err(e) => { load_error.set(Some(format!("加载失败: {}", e))); } } }); } } }); // 组件卸载时清理 Tiptap 实例:EditorHandle::drop 会 destroy 编辑器并释放全部 closure。 #[cfg(target_arch = "wasm32")] use_drop(move || { editor.set(None); }); // Tiptap 编辑器初始化:构造 closure + EditorOptions,调用 bridge.create。 // 替代旧版 eval initEditor 脚本 + 100ms 就绪轮询。 // use_effect 在首次渲染后跑,此时 #tiptap-editor 容器已挂载。 #[cfg(target_arch = "wasm32")] use_effect(move || { // 编辑模式:等数据加载完再初始化(避免空内容覆盖回填) if is_edit && edit_post().is_none() { return; } // 防重复 init(effect 可能多次触发) if editor.read().is_some() { return; } // —— 构造 closure —— // 用 FnMut:Dioxus Signal 的 write/set 接收 &mut self,回调需可变借用捕获的 signal。 let on_update = Closure::new({ let mut content = content; move |md: String| content.set(md) }); let on_ready = Closure::new({ let mut ready = ready; move || ready.set(true) }); let on_image_upload = crate::tiptap_bridge::make_upload_closure(); let on_upload_event = Closure::new({ let uploads_in_flight = uploads_in_flight; let upload_errors = upload_errors; move |ev: crate::tiptap_bridge::UploadEventJs| { consume_upload_event(&ev, uploads_in_flight, upload_errors); } }); // —— 构造 options —— let opts = crate::tiptap_bridge::EditorOptions::new(); opts.set_placeholder("在此输入内容..."); opts.set_on_update(&on_update); opts.set_on_ready(&on_ready); opts.set_on_image_upload(&on_image_upload); opts.set_on_upload_event(&on_upload_event); // —— create(同步返回;找不到容器返回 None,构造失败抛异常)—— match crate::tiptap_bridge::get_module().create("tiptap-editor", &opts) { Ok(Some(inst)) => { // 编辑模式回填:create 成功立即回填(实例已创建,时机确定) if is_edit && !editor_content_set() { let md = content(); if !md.is_empty() { inst.set_markdown(&md); } editor_content_set.set(true); } let handle = EditorHandle::new( inst, on_update, on_image_upload, on_ready, on_upload_event, ); editor.set(Some(handle)); } Ok(None) => { load_error.set(Some("编辑器容器未就绪".to_string())); loading.set(false); } Err(e) => { load_error.set(Some(format!("编辑器初始化错误: {:?}", e))); loading.set(false); } } }); // 编辑器就绪后解除 loading(onReady 回调设 ready=true)。 // 独立 effect 避免 on_ready 闭包与 loading 写权限在同一作用域冲突。 #[cfg(target_arch = "wasm32")] use_effect(move || { if ready() { loading.set(false); } }); // 非 WASM(server SSR):无编辑器,直接解除 loading。 #[cfg(not(target_arch = "wasm32"))] use_effect(move || { 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 |_| { // 上传未完成/失败拦截:有占位符时阻止保存 let in_flight = uploads_in_flight.read(); if in_flight.uploading > 0 || in_flight.error > 0 { let msg = if in_flight.uploading > 0 { format!("有 {} 张图片正在上传,请等待完成后再保存", in_flight.uploading) } else { format!("有 {} 张图片上传失败,请移除或重试后再保存", in_flight.error) }; error.set(Some(msg)); return; } drop(in_flight); // 封面图上传中拦截:防止保存半成品。 if cover_uploading() { error.set(Some("封面图正在上传,请等待完成后再保存".to_string())); return; } if title().trim().is_empty() { error.set(Some("标题不能为空".to_string())); } // 仅在 WASM 前端读取编辑器内容并发起保存请求。 #[cfg(target_arch = "wasm32")] if !title().trim().is_empty() { // 通过 EditorHandle 实例读取 Markdown;句柄未就绪时退回 content signal。 let md = if let Some(handle) = &*editor.read() { handle.instance().get_markdown() } else { content() }; // 兜底:扫描残留的上传占位符标记(轮询窗口期漏判防护) // 检测 ![](blob:...) 形式的泄漏图片 src,而非裸 "blob:" 字符串, // 避免误伤合法讨论 blob URL 的代码块/正文。 if md.contains("](blob:") || md.contains("data-upload-state") { error.set(Some("检测到未完成上传的图片,请处理后保存".to_string())); return; } if md.trim().is_empty() { error.set(Some("内容不能为空".to_string())); return; } // 将逗号分隔的标签字符串转换为列表。 // 同时支持半角/全角逗号与分号,避免中文输入法下的全角标点被误并入单个标签。 let tags_list: Vec = tags() .split(|c| matches!(c, ',' | ',' | ';' | ';')) .map(|t| t.trim().to_string()) .filter(|t| !t.is_empty()) .collect(); let slug_opt = if slug().trim().is_empty() { None } else { Some(slug().trim().to_string()) }; let summary_opt = if summary().trim().is_empty() { None } else { Some(summary().trim().to_string()) }; let cover_image_opt = if cover_image().trim().is_empty() { None } else { Some(cover_image().trim().to_string()) }; saving.set(true); error.set(None); if let Some(id) = post_id { // 编辑模式:调用 update_post spawn(async move { match update_post( id, title().trim().to_string(), slug_opt, summary_opt, md, status(), tags_list, cover_image_opt, ) .await { Ok(CreatePostResponse { success: true, .. }) => { saving.set(false); let _ = dioxus::router::navigator().push(Route::Posts {}); } Ok(CreatePostResponse { success: false, message, .. }) => { saving.set(false); error.set(Some(message)); } Err(e) => { saving.set(false); error.set(Some(format!("更新失败: {}", e))); } } }); } else { // 新建模式:调用 create_post spawn(async move { match create_post( title().trim().to_string(), slug_opt, summary_opt, md, status(), tags_list, cover_image_opt, ) .await { Ok(CreatePostResponse { success: true, .. }) => { saving.set(false); let _ = dioxus::router::navigator().push(Route::Posts {}); } Ok(CreatePostResponse { success: false, message, .. }) => { saving.set(false); error.set(Some(message)); } Err(e) => { saving.set(false); error.set(Some(format!("保存失败: {}", e))); } } }); } } // 非 WASM(SSR hydration 前等场景):明确提示无法保存,而非静默无反应。 #[cfg(not(target_arch = "wasm32"))] if !title().trim().is_empty() { error.set(Some("此页面需要 JavaScript 才能保存".to_string())); } }; let save_button_text = if saving() { "保存中..." } else if is_edit { "更新" } else { "保存" }; // 元信息表单复用的样式:label 与 input 各一份,避免三处重复粘贴。 const META_LABEL_CLASS: &str = "block text-[11px] font-medium text-[var(--color-paper-secondary)] tracking-wider mb-2"; const META_INPUT_CLASS: &str = "w-full text-sm bg-transparent text-[var(--color-paper-primary)] placeholder-[var(--color-paper-tertiary)] focus:outline-none border-b border-[var(--color-paper-tertiary)] focus:border-[var(--color-paper-primary)] transition-colors pb-1.5"; rsx! { div { class: "relative flex flex-col flex-1 min-h-0 overflow-hidden", if loading() { div { class: "absolute inset-0 z-10 bg-white dark:bg-[#1d1e20]", WriteSkeleton {} } } // 顶部元信息区域 - 固定高度,不滚动 div { class: "flex-shrink-0 space-y-5 pt-8", // 标题区域 - 大字号无框输入 div { input { class: "w-full text-3xl md:text-4xl font-bold bg-transparent text-[var(--color-paper-primary)] placeholder-[var(--color-paper-tertiary)] focus:outline-none tracking-tight leading-tight", placeholder: "文章标题", value: "{title}", oninput: move |evt| title.set(evt.value()), } } // 摘要 textarea { class: "w-full text-base bg-transparent text-[var(--color-paper-secondary)] placeholder-[var(--color-paper-tertiary)] focus:outline-none resize-none leading-relaxed", placeholder: "摘要(留空则自动生成)", rows: "2", value: "{summary}", 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]", label { class: "{META_LABEL_CLASS}", "Slug" } input { class: "{META_INPUT_CLASS}", placeholder: "自动生成", value: "{slug}", oninput: move |evt| slug.set(evt.value()), } } div { class: "flex-1 min-w-[140px]", label { class: "{META_LABEL_CLASS}", "标签" } input { class: "{META_INPUT_CLASS}", placeholder: "逗号分隔", value: "{tags}", oninput: move |evt| tags.set(evt.value()), } } } } // 编辑器区域 - 沾满剩余高度 div { class: "flex-1 min-h-0 flex flex-col my-4", div { class: "flex-1 min-h-0 w-full border border-[var(--color-paper-border)] rounded-xl overflow-hidden bg-[var(--color-paper-entry)] shadow-[0_2px_8px_rgba(0,0,0,0.04)] dark:shadow-none", id: "tiptap-editor", } } // 错误和成功提示 if let Some(err) = load_error() { div { class: "flex-shrink-0 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 mb-2", "{err}" } } if let Some(err) = error() { div { class: "flex-shrink-0 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 mb-2", "{err}" } } // 上传失败提示:多条堆叠,×关闭同时删除编辑器内失败占位符(避免孤儿) for err in upload_errors().clone() { div { class: "flex-shrink-0 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 mb-2", span { "图片上传失败: {err.file_name} — {err.message}" } button { class: "shrink-0 text-red-400 hover:text-red-600 cursor-pointer text-lg leading-none", aria_label: "关闭提示", onclick: { // 捕获 owned id,避免借用临时值 let id = err.id.clone(); let mut upload_errors = upload_errors; move |_| { // 关闭提示同时删除编辑器内失败占位符(避免孤儿) #[cfg(target_arch = "wasm32")] if let Some(handle) = &*editor.read() { handle.instance().remove_upload_by_upload_id(&id); } upload_errors.write().retain(|e| e.id != id); } }, "×" } } } // 底部操作栏 - 在编辑器下方,左对齐 div { class: "flex-shrink-0 flex items-center gap-2 pt-2 pb-4", button { class: "px-4 py-1.5 text-sm text-[var(--color-paper-secondary)] hover:text-[var(--color-paper-primary)] transition-colors cursor-pointer", onclick: move |_| { let _ = dioxus::router::navigator().push(Route::Posts {}); }, "取消" } div { class: "w-px h-5 bg-[var(--color-paper-border)]" } div { class: "relative inline-flex items-center px-3 py-1.5 text-sm text-[var(--color-paper-secondary)] cursor-pointer", select { class: "absolute inset-0 w-full h-full opacity-0 cursor-pointer", style: "appearance: none; -webkit-appearance: none;", value: "{status}", onchange: move |evt| status.set(evt.value()), option { value: "draft", "草稿" } option { value: "published", "发布" } } span { class: "pr-1.5 text-[var(--color-paper-primary)] font-medium", if status() == "draft" { "草稿" } else { "发布" } } svg { class: "h-3.5 w-3.5 text-[var(--color-paper-tertiary)] pointer-events-none", xmlns: "http://www.w3.org/2000/svg", view_box: "0 0 20 20", fill: "currentColor", path { fill_rule: "evenodd", d: "M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z", clip_rule: "evenodd" } } } div { class: "w-px h-5 bg-[var(--color-paper-border)]" } button { class: if saving() { "px-5 py-1.5 text-sm bg-[var(--color-paper-tertiary)] text-[var(--color-paper-secondary)] rounded-xl font-medium cursor-not-allowed" } else { "px-5 py-1.5 text-sm bg-[var(--color-paper-primary)] text-[var(--color-paper-theme)] rounded-xl font-medium hover:opacity-90 transition-opacity cursor-pointer" }, disabled: saving(), onclick: on_submit, "{save_button_text}" } } } } }