//! 文章编辑器页面。 //! //! 提供新建文章与编辑文章两种模式,使用基于 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::components::ui::{BTN_CLOSE_ICON, BTN_PRIMARY_SM, LoadingButton}; use crate::components::write_skeleton::WriteSkeleton; use crate::models::post::Post; use crate::router::Route; use crate::tiptap_bridge::{UploadErrorEntry, UploadsInFlight}; #[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}; /// 元信息表单复用的样式常量(label 与 input 各一份,避免多处重复粘贴)。 /// 提升到模块级以便 write_editor 与 CoverUploader 共用。 const META_LABEL_CLASS: &str = "block text-sm font-medium text-[var(--color-paper-secondary)] mb-2"; const META_INPUT_CLASS: &str = "w-full 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-3 shadow-sm transition-all"; /// 新建文章页面组件。 /// /// 内部委托给 `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()); // 封面上传中状态:由子组件 CoverUploader 写入,本组件在 on_submit 读取以拦截保存。 // 无需 mut:本组件只读取(L254)与传递(L452),写入都在 CoverUploader 内部, // 而 Dioxus Signal 是 Copy 类型,.set() 不要求 mut 绑定。 let cover_uploading = use_signal(|| false); let mut status = use_signal(|| "draft".to_string()); let mut content = 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 || { // 编辑模式:等数据加载完再初始化(避免空内容覆盖回填)。 // 回填内容直接从 edit_post(唯一可信源)取,不走 content signal—— // content 由独立的回填 effect 写入,两者在 edit_post 变更同一 tick 触发时 // 没有保证的先后顺序:本 effect 可能先于回填 effect 跑,读到仍是空串, // 导致 setMarkdown 被跳过、编辑器空白(且 editor_content_set=true 阻止重试)。 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); } }); // 运行代码 closure:start_exec + 轮询,结果字符串回填 JS 结果区 DOM。 let on_run_code = crate::tiptap_bridge::make_run_code_closure(); // —— 构造 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); opts.set_on_run_code(&on_run_code); // —— create(同步返回;找不到容器返回 None,构造失败抛异常)—— match crate::tiptap_bridge::get_module().create("tiptap-editor", &opts) { Ok(Some(inst)) => { // 编辑模式回填:create 成功立即回填(实例已创建,时机确定)。 // 直接从 edit_post 取 content_md:本 effect 已在上面 guard 了 edit_post.is_none(), // 而 edit_post 是异步加载后一次性写入、之后只读的真值源,读取它没有竞态。 // 用 editor_content_set 防重复回填(effect 重跑时跳过)。 if is_edit && !editor_content_set() { if let Some(post) = edit_post() { let md = &post.content_md; 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, on_run_code, ); 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); }); // 提交表单:校验标题与内容,读取 Tiptap 编辑器 Markdown,调用 create_post 或 update_post。 let mut 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); // 封面图上传中拦截:防止保存半成品(cover_uploading 由子组件 CoverUploader 写入)。 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([',', ',', ';', ';']) .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())); } }; // 元信息表单复用样式见模块级 META_LABEL_CLASS / META_INPUT_CLASS。 rsx! { // 根容器:与 admin shell 对齐(main 已提供 max-w-7xl mx-auto + px-10 py-12), // 这里只承担内部纵向布局 + min-h-full(让底部 sticky 操作栏能贴住卡片底沿)。 div { class: "relative flex flex-col w-full min-h-full", if loading() { div { class: "absolute inset-0 z-10 bg-paper-theme", WriteSkeleton {} } } // 整页自然滚动:元信息 + 编辑器 + 错误提示 + 底部操作栏一起随浏览器滚动。 div { class: "flex flex-col", // 顶部元信息区域(pt-8 已移除:main 的 py-12 已提供顶部间距, // 与 posts/system 等其他 admin 页面保持一致)。 div { class: "flex-shrink-0 flex flex-col gap-6", // 页面标题与状态 div { class: "flex flex-col md:flex-row md:items-end justify-between gap-6 pb-6 border-b border-[var(--color-paper-border)]", div { h1 { class: "text-4xl font-extrabold tracking-tight text-[var(--color-paper-primary)]", if is_edit { "编辑文章" } else { "撰写新文章" } } p { class: "text-base text-[var(--color-paper-secondary)] mt-2", "内容编辑器" } } } // 核心文本域 div { class: "flex flex-col gap-6", // 标题输入 input { class: "w-full text-4xl md:text-5xl font-extrabold 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 font-normal bg-transparent text-[var(--color-paper-secondary)] placeholder-[var(--color-paper-tertiary)] focus:outline-none resize-none leading-relaxed mb-4 border-b-2 border-transparent focus:border-[var(--color-paper-border)] pb-2 transition-all", placeholder: "摘要(选填)...", rows: "2", value: "{summary}", oninput: move |evt| summary.set(evt.value()), } } // 元信息放在控制面板网格 div { class: "grid grid-cols-1 md:grid-cols-2 gap-6 mt-4", div { class: "flex flex-col gap-6 p-8 rounded-3xl border border-transparent bg-[var(--color-paper-entry)] hover:border-[var(--color-paper-border)] transition-colors shadow-sm", div { label { class: "{META_LABEL_CLASS}", "自定义链接 (Slug)" } input { class: "{META_INPUT_CLASS}", placeholder: "自动生成", value: "{slug}", oninput: move |evt| slug.set(evt.value()), } } div { label { class: "{META_LABEL_CLASS}", "标签系统" } input { class: "{META_INPUT_CLASS}", placeholder: "输入标签,以逗号分隔...", value: "{tags}", oninput: move |evt| tags.set(evt.value()), } } } // 封面图放右边 div { class: "flex flex-col p-8 rounded-3xl border border-transparent bg-[var(--color-paper-entry)] hover:border-[var(--color-paper-border)] transition-colors shadow-sm", label { class: "{META_LABEL_CLASS}", "封面图" } CoverUploader { cover_image, cover_uploading } } } } // 编辑器区域 - 整页滚动下用视口高度,保证充裕的编辑空间。 // h-[60vh]:随窗口高度自适应;min-h-[400px]:窗口过矮时仍可用。 div { class: "h-[60vh] min-h-[400px] flex flex-col my-4", div { class: "relative group flex-1 min-h-0 w-full border border-[var(--color-paper-border)] rounded-3xl overflow-hidden bg-[var(--color-paper-entry)] shadow-sm", id: "tiptap-editor", img { src: "/images/xiantiaoxiaogou_input_bg.webp", alt: "", class: "absolute bottom-2 right-2 w-24 opacity-10 pointer-events-none z-0", } } } // 错误和成功提示 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 { key: "{err.id}", 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: "{BTN_CLOSE_ICON}", 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); } }, "×" } } } } // 内容区闭合 // 底部操作栏 - sticky 贴底:内容滚动时停在视口底部(圆角卡片滚动容器的底部)。 // sticky 相对最近的 overflow-y-auto 祖先(layout 的圆角卡片)定位。 // -mt-12 + -mx-10 + px-10 py-4:抵消 main 的 py-12 px-10,让操作栏贴满卡片底沿宽度。 // bg + backdrop-blur:滚动内容从下方滑过时不穿透。 div { class: "sticky bottom-0 -mt-12 -mx-10 px-10 py-4 flex items-center gap-4 border-t border-[var(--color-paper-border)] bg-[var(--color-paper-theme)]/95 backdrop-blur-sm", button { class: "px-6 py-2 rounded-full text-sm font-medium 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-4 py-2 rounded-full text-sm font-medium text-[var(--color-paper-secondary)] border border-[var(--color-paper-border)] bg-[var(--color-paper-entry)] cursor-pointer hover:bg-[var(--color-paper-theme)] transition-colors", 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-2 text-[var(--color-paper-primary)]", 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)]" } LoadingButton { label: if is_edit { "更新文章".to_string() } else { "发布文章".to_string() }, loading: saving(), onclick: move |_| on_submit(()), } } } } } /// 封面上传子组件。 /// /// 封装封面图的全部状态与交互:拖拽/粘贴/选择文件上传、URL 输入、预览、移除。 /// 通过两个 signal 与父组件双向绑定: /// - `cover_image`:子组件写入最终 URL,父组件读取用于保存; /// - `cover_uploading`:子组件上传时置 true,父组件在 `on_submit` 读取以拦截 /// 上传中的保存(避免封面 URL 尚未落定就提交导致封面丢失)。 /// 其余上传中间态(error/url/drag)对本组件私有。 /// /// 从 `write_editor` 抽取以降低 god component 复杂度(见 dioxus-render-purity skill)。 #[component] #[cfg_attr(not(target_arch = "wasm32"), allow(unused_mut, unused_variables))] fn CoverUploader(cover_image: Signal, cover_uploading: Signal) -> Element { // 封面图上传状态:错误消息、URL 输入框展开、拖拽高亮(cover_uploading 由父组件传入)。 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()); // 封面图上传: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); }); }; rsx! { // 封面图上传区:空态矮横条(不挤压编辑器),有图时展开成 21:9 超宽预览。 // 21:9 与首页卡片封面统一比例,比 16:9 更扁,适合宽屏横幅式封面。 // 容器统一绑定拖拽与粘贴事件;内部按 cover_image / cover_uploading 切换空态、上传中、预览。 div { class: "relative w-full border border-dashed rounded-2xl overflow-hidden transition-all duration-200 group/cover", // 空态矮横条;有图/上传中展开成 21:9。 class: if cover_image().is_empty() && !cover_uploading() { "h-14" } else { "aspect-[21/9]" }, class: if cover_drag_active() { "border-[var(--color-paper-primary)] bg-[var(--color-paper-entry)]" } else if cover_image().is_empty() { "border-[var(--color-paper-border)] bg-[var(--color-paper-entry)] hover:border-[var(--color-paper-primary)]" } 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 font-medium 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-sm font-medium text-white/90 drop-shadow-md", "点击更换封面" } } } // —— 空态:横向矮横条,图标+提示+URL 链 —— if cover_image().is_empty() && !cover_uploading() { // label 包裹整个空态:点击天然触发隐藏的 file input,无需 JS。 label { class: "absolute inset-0 flex flex-row items-center gap-3 cursor-pointer px-4 text-left", // 上传图标(Feather 风格线框,与项目现有图标体系一致)。 svg { class: "w-5 h-5 shrink-0 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 font-medium text-[var(--color-paper-secondary)] shrink-0", "拖拽 / 粘贴 / 点击上传" } // URL 文字链:阻止 label 的默认 file 触发,切换到 URL 输入模式。 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(); cover_url_mode.set(true); cover_url_input.set(cover_image()); }, "输入链接" } // 隐藏的 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: "粘贴图片链接...", 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 {BTN_PRIMARY_SM}", 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-4 py-1.5 rounded-full text-sm font-medium text-[var(--color-paper-secondary)] hover:bg-[var(--color-paper-entry)] 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: "{BTN_CLOSE_ICON}", aria_label: "关闭提示", onclick: move |_| cover_error.set(None), "×" } } } } }