骨架屏在两条渲染路径都被高度链断裂卡住,内容只占页面 40-50%, 底部大片空白。 根因:高度链在两处父容器断裂,导致骨架屏无法引用到确定高度: 1. 未登录态(admin_layout):骨架屏被包在 div.p-10.animate-pulse, 该 div 是普通块级元素,无 flex-1 不撑满 main、非 flex 容器使 骨架屏 flex-1 无效、无 h-full。SSR 初始态走这条。 2. 登录态(write.rs 覆盖层):absolute inset-0 虽撑满,但非 flex 容器,骨架屏 flex-1 无意义;改 h-full 又遇 height:100% 在 非显式高度父容器下解析不可靠的经典坑。 修复:统一让两条路径的父容器都成为 flex flex-col,骨架屏根用 flex-1(比 height:100% 可靠,不依赖父显式 height): - admin_layout 包裹层: p-10.animate-pulse → flex-1.min-h-0. flex.flex-col.animate-pulse(去掉对 write 不合适的 p-10, 非 write 页面 padding 由 main_class 自带) - write.rs 覆盖层: absolute inset-0 → absolute inset-0 flex flex-col - write_skeleton 根: h-full → flex-1
855 lines
42 KiB
Rust
855 lines
42 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::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 各一份,避免多处重复粘贴)。
|
||
/// 提升到模块级以便 CoverUploader 的 URL 输入框复用。
|
||
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<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());
|
||
// 封面上传中状态:由子组件 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::<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 || {
|
||
// 编辑模式:等数据加载完再初始化(避免空内容覆盖回填)。
|
||
// 回填内容直接从 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()
|
||
};
|
||
|
||
// 兜底:扫描残留的上传占位符标记(轮询窗口期漏判防护)
|
||
// 检测  形式的泄漏图片 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([',', ',', ';', ';'])
|
||
.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_INPUT_CLASS。
|
||
|
||
rsx! {
|
||
// 根容器:flex 分区布局。layout 给 write 的 main 是 flex 容器(无 padding/不滚动),
|
||
// 这里拆成 [内容区 flex-1 overflow-y-auto] + [底栏 flex-shrink-0] 两块。
|
||
// 底栏作为 main 直接子元素永远贴卡片底沿,不随内容滚动,也无需 sticky。
|
||
div { class: "relative flex flex-col w-full min-h-0 flex-1",
|
||
if loading() {
|
||
div { class: "absolute inset-0 z-10 bg-paper-theme flex flex-col", WriteSkeleton {} }
|
||
}
|
||
|
||
// 两栏容器:flex-1 分配空间,自身不滚动(min-h-0),滚动职责下放给左右两栏。
|
||
div { class: "flex-1 min-h-0 flex",
|
||
// 左栏(主写作区):flex-1 撑满宽度,min-w-0 防止长标题/代码块撑破 flex,
|
||
// overflow-y-auto 独立滚动。
|
||
div { class: "flex-1 min-w-0 min-h-0 overflow-y-auto px-6 py-8 flex flex-col",
|
||
// 标题输入
|
||
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 mt-6",
|
||
placeholder: "摘要(选填)...",
|
||
rows: "2",
|
||
value: "{summary}",
|
||
oninput: move |evt| summary.set(evt.value()),
|
||
}
|
||
|
||
// 错误和成功提示
|
||
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);
|
||
}
|
||
},
|
||
"×"
|
||
}
|
||
}
|
||
}
|
||
|
||
// 编辑器区域:flex-1 撑满左栏剩余高度,不再硬编码 60vh。
|
||
// 编辑器内部 .tiptap-editor/.ProseMirror 均为 height:100% + overflow-y:auto,
|
||
// 容器变高编辑器自动跟着变高。min-h-[400px] 保证窗口过矮时仍可用。
|
||
div { class: "flex-1 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",
|
||
}
|
||
}
|
||
}
|
||
} // 左栏闭合
|
||
|
||
// 右栏(侧边栏):分节式布局,每节带小标题,节间用细分隔线分隔。
|
||
// 透明背景(与左栏共用 paper-theme 底),靠 border-l 和分隔线划分区域。
|
||
div { class: "w-80 flex-shrink-0 min-h-0 overflow-y-auto border-l border-[var(--color-paper-border)] flex flex-col",
|
||
// Slug 节
|
||
div { class: "p-6 border-b border-[var(--color-paper-border)]",
|
||
label { class: "block text-xs font-semibold uppercase tracking-wide text-[var(--color-paper-tertiary)] mb-3", "链接" }
|
||
input {
|
||
class: "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-xl px-3 py-2 transition-all",
|
||
placeholder: "自动生成",
|
||
value: "{slug}",
|
||
oninput: move |evt| slug.set(evt.value()),
|
||
}
|
||
}
|
||
// 标签节
|
||
div { class: "p-6 border-b border-[var(--color-paper-border)]",
|
||
label { class: "block text-xs font-semibold uppercase tracking-wide text-[var(--color-paper-tertiary)] mb-3", "标签" }
|
||
input {
|
||
class: "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-xl px-3 py-2 transition-all",
|
||
placeholder: "逗号分隔...",
|
||
value: "{tags}",
|
||
oninput: move |evt| tags.set(evt.value()),
|
||
}
|
||
}
|
||
// 封面图节
|
||
div { class: "p-6",
|
||
label { class: "block text-xs font-semibold uppercase tracking-wide text-[var(--color-paper-tertiary)] mb-3", "封面图" }
|
||
CoverUploader { cover_image, cover_uploading }
|
||
}
|
||
} // 右栏闭合
|
||
} // 两栏容器闭合
|
||
|
||
// 底部操作栏 - flex 分区布局的贴底块:作为 main 直接子元素(flex-shrink-0),
|
||
// 永远贴卡片底沿,不随内容区滚动。无需 sticky,不会跳动。
|
||
// px-6 py-4 与内容区水平对齐;border-t 与页头分割线视觉一致。
|
||
div { class: "flex-shrink-0 px-6 py-4 flex items-center gap-4 border-t border-[var(--color-paper-border)] bg-[var(--color-paper-theme)]",
|
||
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<String>, cover_uploading: Signal<bool>) -> Element {
|
||
// 封面图上传状态:错误消息、URL 输入框展开、拖拽高亮(cover_uploading 由父组件传入)。
|
||
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());
|
||
|
||
// 封面图上传: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::<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 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),
|
||
"×"
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|