将编辑页封面图从纯 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),供粘贴/拖拽读取文件。
809 lines
39 KiB
Rust
809 lines
39 KiB
Rust
//! 文章编辑器页面。
|
||
//!
|
||
//! 提供新建文章与编辑文章两种模式,使用基于 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<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;
|
||
}
|
||
// 防重复 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()
|
||
};
|
||
|
||
// 兜底:扫描残留的上传占位符标记(轮询窗口期漏判防护)
|
||
// 检测  形式的泄漏图片 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)));
|
||
}
|
||
}
|
||
});
|
||
}
|
||
}
|
||
|
||
// 非 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::<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-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}"
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|