xfy 9a00972be4 fix: 修复格式化工具破坏的单行 if/else 与闭包语法
格式化工具在展开单行语句时引入了 5 处语法错误,导致 dx serve 无法编译:

- comments.rs / trash.rs:on_select 的 `if checked { insert } else { remove }`
  丢失 else 分支,变为悬空语句 + 括号不匹配。
- trash.rs(3 处):`web_sys::window().and_then(|w| {...}).unwrap_or(false) {`
  展开闭包体时漏掉闭合 `)`,吞掉了 if 块的开括号。
- write.rs:封面图 `if cv.contains('?') {...} else {...}` 丢失 else,
  多出一个 `}`,导致 src 块括号失衡。

均还原为正确的多行 if/else 结构。验证:dx check 通过、cargo test 405 passed。
2026-06-25 18:11:35 +08:00

810 lines
40 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! 文章编辑器页面。
//!
//! 提供新建文章与编辑文章两种模式,使用基于 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::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 访问:
// - 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};
/// 新建文章页面组件。
///
/// 内部委托给 `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<i32>) -> 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::<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 saving = use_signal(|| false);
let mut error = use_signal(|| None::<String>);
let mut editor_content_set = use_signal(|| false);
let mut has_backfilled = use_signal(|| false);
let mut load_error = use_signal(|| None::<String>);
// 编辑模式:用于暂存从服务端加载的文章数据。
let mut edit_post = use_signal(|| None::<Post>);
// WASM 前端:编辑器实例句柄(持有实例 + closure统一生命周期
#[cfg(target_arch = "wasm32")]
let mut editor: Signal<Option<EditorHandle>> = 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<Vec<UploadErrorEntry>> = 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;
}
// 防重复 initeffect 可能多次触发)
if editor.read().is_some() {
return;
}
// —— 构造 closure ——
// 用 FnMutDioxus 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);
}
}
});
// 编辑器就绪后解除 loadingonReady 回调设 ready=true
// 独立 effect 避免 on_ready 闭包与 loading 写权限在同一作用域冲突。
#[cfg(target_arch = "wasm32")]
use_effect(move || {
if ready() {
loading.set(false);
}
});
// 非 WASMserver 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<String> = 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)));
}
}
});
}
}
// 非 WASMSSR 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",
if loading() {
div { class: "absolute inset-0 z-10 bg-paper-theme", WriteSkeleton {} }
}
// 整页自然滚动:元信息 + 编辑器 + 错误提示 + 底部操作栏一起随浏览器滚动。
div { class: "flex flex-col",
// 顶部元信息区域
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 mb-0",
placeholder: "摘要(留空则自动生成)",
rows: "2",
value: "{summary}",
oninput: move |evt| summary.set(evt.value()),
}
// 元数据行 - 紧凑精致
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()),
}
}
}
// 封面图上传区:空态矮横条(不挤压编辑器),有图时展开成 21:9 超宽预览。
// 21:9 与首页卡片封面统一比例,比 16:9 更扁,适合宽屏横幅式封面。
// 容器统一绑定拖拽与粘贴事件;内部按 cover_image / cover_uploading 切换空态、上传中、预览。
div {
class: "relative w-full rounded-xl border border-dashed 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-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-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 text-[var(--color-paper-secondary)] shrink-0",
"拖拽 · 点击 · 粘贴封面图"
}
// 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 ml-auto shrink-0",
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),
"×"
}
}
}
}
// 编辑器区域 - 整页滚动下用视口高度,保证充裕的编辑空间。
// h-[60vh]随窗口高度自适应min-h-[400px]:窗口过矮时仍可用。
div { class: "h-[60vh] min-h-[400px] 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 {
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: "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 items-center gap-2 pt-2 pb-4 border-t border-[var(--color-paper-border)]",
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-accent)] text-[var(--color-paper-theme)] rounded-xl font-medium hover:brightness-110 active:scale-[0.98] transition-all cursor-pointer" },
disabled: saving(),
onclick: on_submit,
"{save_button_text}"
}
}
}
}
}