Skip to main content

yggdrasil/pages/admin/
write.rs

1//! 文章编辑器页面。
2//!
3//! 提供新建文章与编辑文章两种模式,使用基于 Tiptap 的富文本编辑器。
4//! 编辑器通过 [`crate::tiptap_bridge`] 的 wasm-bindgen 绑定在 WASM 前端初始化,
5//! 并与 `window.TiptapEditor` 实例交互,实现 Markdown 内容回填、图片上传与组件卸载时的清理。
6
7// prelude 在 WASM 构建里直接使用(use_signal/Signal/Element 等);
8// server 构建里 #[component] 宏会重新导出这些符号导致 native 报 unused,故 allow。
9#[allow(unused_imports)]
10use dioxus::prelude::*;
11
12// 仅在 WASM 前端使用的类型转换与文章 API。
13#[cfg(target_arch = "wasm32")]
14use crate::api::posts::{
15    create_post, get_post_by_id, update_post, CreatePostResponse, SinglePostResponse,
16};
17#[cfg(target_arch = "wasm32")]
18use crate::tiptap_bridge::{consume_upload_event, upload_image_file, EditorHandle};
19// 共享上传状态类型:两端都编译(rsx 在 server SSR 时也要渲染这些结构)。
20use crate::components::forms::{FormInput, FormSelect, INPUT_INLINE_CLASS};
21use crate::components::ui::{LoadingButton, BTN_CLOSE_ICON, BTN_PRIMARY_SM};
22use crate::components::write_skeleton::WriteSkeleton;
23use crate::models::post::Post;
24use crate::pages::admin::asset_picker::AssetPickerModal;
25use crate::router::Route;
26use crate::tiptap_bridge::{UploadErrorEntry, UploadsInFlight};
27#[cfg(target_arch = "wasm32")]
28use wasm_bindgen::closure::Closure;
29// 封面图上传:从 Dioxus 事件拿底层 web_sys::File,以及 paste 事件的原始 web 事件。
30// 这三个 trait 仅在 WASM 端可用(dioxus-web 提供),通过 dioxus 的 re-export 访问:
31// - HasFileData:evt.files()(FormEvent / DragEvent 取文件)— dioxus::html
32// - WebFileExt:file.get_web_file()(FileData 取底层 web_sys::File)— dioxus::web
33// - WebEventExt:evt.try_as_web_event()(ClipboardEvent 取原始 web 事件)— dioxus::web
34#[cfg(target_arch = "wasm32")]
35use dioxus::html::HasFileData;
36#[cfg(target_arch = "wasm32")]
37use dioxus::web::{WebEventExt, WebFileExt};
38
39/// 新建文章页面组件。
40///
41/// 内部委托给 `write_editor`,以 `None` 表示新建模式。
42#[component]
43#[cfg_attr(not(target_arch = "wasm32"), allow(unused_mut, unused_variables))]
44pub fn Write() -> Element {
45    write_editor(None)
46}
47
48/// 编辑文章页面组件。
49///
50/// `id` 为要编辑的文章 ID,内部委托给 `write_editor` 加载现有数据。
51#[component]
52#[cfg_attr(not(target_arch = "wasm32"), allow(unused_mut, unused_variables))]
53pub fn WriteEdit(id: i32) -> Element {
54    write_editor(Some(id))
55}
56
57/// 文章编辑器核心组件,支持新建(`post_id == None`)与编辑模式。
58///
59/// 负责:
60/// - 编辑模式下通过 server function 拉取文章数据;
61/// - 在 WASM 前端通过 tiptap_bridge 的 closure 回调初始化 Tiptap 富文本编辑器;
62/// - 编辑模式下将 Markdown 内容回填到编辑器;
63/// - 提交时读取编辑器 Markdown、校验并调用 create_post / update_post;
64/// - 组件卸载时销毁 Tiptap 实例(EditorHandle::drop 自动 destroy + 释放 closure)。
65#[cfg_attr(not(target_arch = "wasm32"), allow(unused_mut, unused_variables))]
66fn write_editor(post_id: Option<i32>) -> Element {
67    let is_edit = post_id.is_some();
68
69    // 文章元信息表单字段。
70    let mut title = use_signal(|| "".to_string());
71    let mut summary = use_signal(|| "".to_string());
72    let mut slug = use_signal(|| "".to_string());
73    let mut tags = use_signal(|| "".to_string());
74    let mut cover_image = use_signal(|| "".to_string());
75    // 封面上传中状态:由子组件 CoverUploader 写入,本组件在 on_submit 读取以拦截保存。
76    // 无需 mut:本组件只读取(L254)与传递(L452),写入都在 CoverUploader 内部,
77    // 而 Dioxus Signal 是 Copy 类型,.set() 不要求 mut 绑定。
78    let cover_uploading = use_signal(|| false);
79    let mut status = use_signal(|| "published".to_string());
80    let mut content = use_signal(|| "".to_string());
81    // 页面与编辑器加载、保存、错误、成功等状态。
82    let mut loading = use_signal(|| true);
83    let mut saving = use_signal(|| false);
84    let mut error = use_signal(|| None::<String>);
85    let mut editor_content_set = use_signal(|| false);
86    let mut has_backfilled = use_signal(|| false);
87    let mut load_error = use_signal(|| None::<String>);
88
89    // 编辑模式:用于暂存从服务端加载的文章数据。
90    let mut edit_post = use_signal(|| None::<Post>);
91
92    // WASM 前端:编辑器实例句柄(持有实例 + closure,统一生命周期)。
93    #[cfg(target_arch = "wasm32")]
94    let mut editor: Signal<Option<EditorHandle>> = use_signal(|| None);
95    // WASM 前端:编辑器就绪标志(onReady 回调驱动,替代 __tiptap_ready 轮询)。
96    #[cfg(target_arch = "wasm32")]
97    let ready = use_signal(|| false);
98
99    // 上传状态:当前进行中计数(保存拦截)+ 顶部失败提示堆叠(用户手动关闭)
100    let uploads_in_flight = use_signal(UploadsInFlight::default);
101    let upload_errors: Signal<Vec<UploadErrorEntry>> = use_signal(Vec::new);
102
103    // 编辑模式:文章数据加载完成后,将字段回填到表单信号。
104    use_effect(move || {
105        if !is_edit || has_backfilled() {
106            return;
107        }
108        if let Some(ref post) = edit_post() {
109            has_backfilled.set(true);
110            title.set(post.title.clone());
111            summary.set(post.summary.clone().unwrap_or_default());
112            slug.set(post.slug.clone());
113            tags.set(post.tags.join(", "));
114            cover_image.set(post.cover_image.clone().unwrap_or_default());
115            status.set(post.status.as_str().to_string());
116            content.set(post.content_md.clone());
117        }
118    });
119
120    // 编辑模式:仅在 WASM 前端通过 server function 加载文章详情。
121    use_effect(move || {
122        if is_edit {
123            #[cfg(target_arch = "wasm32")]
124            if let Some(id) = post_id {
125                spawn(async move {
126                    match get_post_by_id(id).await {
127                        Ok(SinglePostResponse { post: Some(post) }) => {
128                            edit_post.set(Some(post));
129                        }
130                        Ok(SinglePostResponse { post: None }) => {
131                            load_error.set(Some("文章不存在".to_string()));
132                        }
133                        Err(e) => {
134                            load_error.set(Some(format!("加载失败: {}", e)));
135                        }
136                    }
137                });
138            }
139        }
140    });
141
142    // 组件卸载时清理 Tiptap 实例:EditorHandle::drop 会 destroy 编辑器并释放全部 closure。
143    #[cfg(target_arch = "wasm32")]
144    use_drop(move || {
145        editor.set(None);
146    });
147
148    // Tiptap 编辑器初始化:构造 closure + EditorOptions,调用 bridge.create。
149    // 替代旧版 eval initEditor 脚本 + 100ms 就绪轮询。
150    // use_effect 在首次渲染后跑,此时 #tiptap-editor 容器已挂载。
151    #[cfg(target_arch = "wasm32")]
152    use_effect(move || {
153        // 编辑模式:等数据加载完再初始化(避免空内容覆盖回填)。
154        // 回填内容直接从 edit_post(唯一可信源)取,不走 content signal——
155        // content 由独立的回填 effect 写入,两者在 edit_post 变更同一 tick 触发时
156        // 没有保证的先后顺序:本 effect 可能先于回填 effect 跑,读到仍是空串,
157        // 导致 setMarkdown 被跳过、编辑器空白(且 editor_content_set=true 阻止重试)。
158        if is_edit && edit_post().is_none() {
159            return;
160        }
161        // 防重复 init(effect 可能多次触发)
162        if editor.read().is_some() {
163            return;
164        }
165
166        // —— 构造 closure ——
167        // 用 FnMut:Dioxus Signal 的 write/set 接收 &mut self,回调需可变借用捕获的 signal。
168        let on_update = Closure::new({
169            let mut content = content;
170            move |md: String| content.set(md)
171        });
172        let on_ready = Closure::new({
173            let mut ready = ready;
174            move || ready.set(true)
175        });
176        let on_image_upload = crate::tiptap_bridge::make_upload_closure();
177        let on_upload_event = Closure::new({
178            let uploads_in_flight = uploads_in_flight;
179            let upload_errors = upload_errors;
180            move |ev: crate::tiptap_bridge::UploadEventJs| {
181                consume_upload_event(&ev, uploads_in_flight, upload_errors);
182            }
183        });
184        // 运行代码 closure:start_exec + 轮询,结果字符串回填 JS 结果区 DOM。
185        let on_run_code = crate::tiptap_bridge::make_run_code_closure();
186
187        // —— 构造 options ——
188        let opts = crate::tiptap_bridge::EditorOptions::new();
189        opts.set_placeholder("在此输入内容...");
190        opts.set_on_update(&on_update);
191        opts.set_on_ready(&on_ready);
192        opts.set_on_image_upload(&on_image_upload);
193        opts.set_on_upload_event(&on_upload_event);
194        opts.set_on_run_code(&on_run_code);
195
196        // —— create(同步返回;找不到容器返回 None,构造失败抛异常)——
197        match crate::tiptap_bridge::get_module().create("tiptap-editor", &opts) {
198            Ok(Some(inst)) => {
199                // 编辑模式回填:create 成功立即回填(实例已创建,时机确定)。
200                // 直接从 edit_post 取 content_md:本 effect 已在上面 guard 了 edit_post.is_none(),
201                // 而 edit_post 是异步加载后一次性写入、之后只读的真值源,读取它没有竞态。
202                // 用 editor_content_set 防重复回填(effect 重跑时跳过)。
203                if is_edit && !editor_content_set() {
204                    if let Some(post) = edit_post() {
205                        let md = &post.content_md;
206                        if !md.is_empty() {
207                            inst.set_markdown(md);
208                        }
209                    }
210                    editor_content_set.set(true);
211                }
212                let handle = EditorHandle::new(
213                    inst,
214                    on_update,
215                    on_image_upload,
216                    on_ready,
217                    on_upload_event,
218                    on_run_code,
219                );
220                editor.set(Some(handle));
221            }
222            Ok(None) => {
223                load_error.set(Some("编辑器容器未就绪".to_string()));
224                loading.set(false);
225            }
226            Err(e) => {
227                load_error.set(Some(format!("编辑器初始化错误: {:?}", e)));
228                loading.set(false);
229            }
230        }
231    });
232
233    // 编辑器就绪后解除 loading(onReady 回调设 ready=true)。
234    // 独立 effect 避免 on_ready 闭包与 loading 写权限在同一作用域冲突。
235    #[cfg(target_arch = "wasm32")]
236    use_effect(move || {
237        if ready() {
238            loading.set(false);
239        }
240    });
241
242    // 非 WASM(server SSR):无编辑器,直接解除 loading。
243    #[cfg(not(target_arch = "wasm32"))]
244    use_effect(move || {
245        loading.set(false);
246    });
247
248    // 提交表单:校验标题与内容,读取 Tiptap 编辑器 Markdown,调用 create_post 或 update_post。
249    let mut on_submit = move |_| {
250        // 上传未完成/失败拦截:有占位符时阻止保存
251        let in_flight = uploads_in_flight.read();
252        if in_flight.uploading > 0 || in_flight.error > 0 {
253            let msg = if in_flight.uploading > 0 {
254                format!(
255                    "有 {} 张图片正在上传,请等待完成后再保存",
256                    in_flight.uploading
257                )
258            } else {
259                format!(
260                    "有 {} 张图片上传失败,请移除或重试后再保存",
261                    in_flight.error
262                )
263            };
264            error.set(Some(msg));
265            return;
266        }
267        drop(in_flight);
268
269        // 封面图上传中拦截:防止保存半成品(cover_uploading 由子组件 CoverUploader 写入)。
270        if cover_uploading() {
271            error.set(Some("封面图正在上传,请等待完成后再保存".to_string()));
272            return;
273        }
274
275        if title().trim().is_empty() {
276            error.set(Some("标题不能为空".to_string()));
277        }
278
279        // 仅在 WASM 前端读取编辑器内容并发起保存请求。
280        #[cfg(target_arch = "wasm32")]
281        if !title().trim().is_empty() {
282            // 通过 EditorHandle 实例读取 Markdown;句柄未就绪时退回 content signal。
283            let md = if let Some(handle) = &*editor.read() {
284                handle.instance().get_markdown()
285            } else {
286                content()
287            };
288
289            // 兜底:扫描残留的上传占位符标记(轮询窗口期漏判防护)
290            // 检测 ![](blob:...) 形式的泄漏图片 src,而非裸 "blob:" 字符串,
291            // 避免误伤合法讨论 blob URL 的代码块/正文。
292            if md.contains("](blob:") || md.contains("data-upload-state") {
293                error.set(Some("检测到未完成上传的图片,请处理后保存".to_string()));
294                return;
295            }
296
297            if md.trim().is_empty() {
298                error.set(Some("内容不能为空".to_string()));
299                return;
300            }
301
302            // 将逗号分隔的标签字符串转换为列表。
303            // 同时支持半角/全角逗号与分号,避免中文输入法下的全角标点被误并入单个标签。
304            let tags_list: Vec<String> = tags()
305                .split([',', ',', ';', ';'])
306                .map(|t| t.trim().to_string())
307                .filter(|t| !t.is_empty())
308                .collect();
309
310            let slug_opt = if slug().trim().is_empty() {
311                None
312            } else {
313                Some(slug().trim().to_string())
314            };
315
316            let summary_opt = if summary().trim().is_empty() {
317                None
318            } else {
319                Some(summary().trim().to_string())
320            };
321
322            let cover_image_opt = if cover_image().trim().is_empty() {
323                None
324            } else {
325                Some(cover_image().trim().to_string())
326            };
327
328            saving.set(true);
329            error.set(None);
330
331            if let Some(id) = post_id {
332                // 编辑模式:调用 update_post
333                spawn(async move {
334                    match update_post(
335                        id,
336                        title().trim().to_string(),
337                        slug_opt,
338                        summary_opt,
339                        md,
340                        status(),
341                        tags_list,
342                        cover_image_opt,
343                    )
344                    .await
345                    {
346                        Ok(CreatePostResponse { success: true, .. }) => {
347                            saving.set(false);
348                            let _ = dioxus::router::navigator().push(Route::Posts {});
349                        }
350                        Ok(CreatePostResponse {
351                            success: false,
352                            message,
353                            ..
354                        }) => {
355                            saving.set(false);
356                            error.set(Some(message));
357                        }
358                        Err(e) => {
359                            saving.set(false);
360                            error.set(Some(format!("更新失败: {}", e)));
361                        }
362                    }
363                });
364            } else {
365                // 新建模式:调用 create_post
366                spawn(async move {
367                    match create_post(
368                        title().trim().to_string(),
369                        slug_opt,
370                        summary_opt,
371                        md,
372                        status(),
373                        tags_list,
374                        cover_image_opt,
375                    )
376                    .await
377                    {
378                        Ok(CreatePostResponse { success: true, .. }) => {
379                            saving.set(false);
380                            let _ = dioxus::router::navigator().push(Route::Posts {});
381                        }
382                        Ok(CreatePostResponse {
383                            success: false,
384                            message,
385                            ..
386                        }) => {
387                            saving.set(false);
388                            error.set(Some(message));
389                        }
390                        Err(e) => {
391                            saving.set(false);
392                            error.set(Some(format!("保存失败: {}", e)));
393                        }
394                    }
395                });
396            }
397        }
398
399        // 非 WASM(SSR hydration 前等场景):明确提示无法保存,而非静默无反应。
400        #[cfg(not(target_arch = "wasm32"))]
401        if !title().trim().is_empty() {
402            error.set(Some("此页面需要 JavaScript 才能保存".to_string()));
403        }
404    };
405
406    rsx! {
407        // 根容器:flex 分区布局。layout 给 write 的 main 是 flex 容器(无 padding/不滚动),
408        // 这里拆成 [内容区 flex-1 overflow-y-auto] + [底栏 flex-shrink-0] 两块。
409        // 底栏作为 main 直接子元素永远贴卡片底沿,不随内容滚动,也无需 sticky。
410        div { class: "relative flex flex-col w-full min-h-0 flex-1",
411            if loading() {
412                div { class: "absolute inset-0 z-10 bg-paper-theme flex flex-col", WriteSkeleton {} }
413            }
414
415            // 两栏容器:flex-1 分配空间,自身不滚动(min-h-0),滚动职责下放给左右两栏。
416            div { class: "flex-1 min-h-0 flex",
417                // 左栏(主写作区):flex-1 撑满宽度,min-w-0 防止长标题/代码块撑破 flex,
418                // overflow-y-auto 独立滚动。
419                div { class: "flex-1 min-w-0 min-h-0 overflow-y-auto px-6 py-8 flex flex-col",
420                    // 标题输入
421                    input {
422                        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",
423                        placeholder: "输入文章标题...",
424                        value: "{title}",
425                        oninput: move |evt| title.set(evt.value()),
426                    }
427
428                    // 错误和成功提示
429                    if let Some(err) = load_error() {
430                        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-2xl text-sm border border-red-100 dark:border-red-900/30 mb-2",
431                            "{err}"
432                        }
433                    }
434
435                    if let Some(err) = error() {
436                        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-2xl text-sm border border-red-100 dark:border-red-900/30 mb-2",
437                            "{err}"
438                        }
439                    }
440
441                    // 上传失败提示:多条堆叠,×关闭同时删除编辑器内失败占位符(避免孤儿)
442                    for err in upload_errors().clone() {
443                        div {
444                            key: "{err.id}",
445                            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-2xl text-sm border border-red-100 dark:border-red-900/30 mb-2",
446                            span { "图片上传失败: {err.file_name} — {err.message}" }
447                            button {
448                                class: "{BTN_CLOSE_ICON}",
449                                aria_label: "关闭提示",
450                                onclick: {
451                                    // 捕获 owned id,避免借用临时值
452                                    let id = err.id.clone();
453                                    let mut upload_errors = upload_errors;
454                                    move |_| {
455                                        // 关闭提示同时删除编辑器内失败占位符(避免孤儿)
456                                        #[cfg(target_arch = "wasm32")]
457                                        if let Some(handle) = &*editor.read() {
458                                            handle.instance().remove_upload_by_upload_id(&id);
459                                        }
460                                        upload_errors.write().retain(|e| e.id != id);
461                                    }
462                                },
463                                "×"
464                            }
465                        }
466                    }
467
468                    // 编辑器区域:flex-1 撑满左栏剩余高度,不再硬编码 60vh。
469                    // 编辑器内部 .tiptap-editor/.ProseMirror 均为 height:100% + overflow-y:auto,
470                    // 容器变高编辑器自动跟着变高。min-h-[400px] 保证窗口过矮时仍可用。
471                    div { class: "flex-1 min-h-[400px] flex flex-col my-4",
472                        div {
473                            class: "relative group flex-1 min-h-0 w-full border border-[var(--color-paper-border)] rounded-2xl overflow-hidden bg-[var(--color-paper-entry)] shadow-sm",
474                            id: "tiptap-editor",
475                            img {
476                                src: "/images/xiantiaoxiaogou_input_bg.webp",
477                                alt: "",
478                                class: "absolute bottom-2 right-2 w-24 opacity-10 pointer-events-none z-0",
479                            }
480                        }
481                    }
482                } // 左栏闭合
483
484                // 右栏(侧边栏):分节式布局,每节带小标题,节间用细分隔线分隔。
485                // 透明背景(与左栏共用 paper-theme 底),靠 border-l 和分隔线划分区域。
486                div { class: "w-80 flex-shrink-0 min-h-0 overflow-y-auto border-l border-[var(--color-paper-border)] flex flex-col",
487                    // Slug 节
488                    div { class: "p-6 border-b border-[var(--color-paper-border)]",
489                        label { class: "block text-xs font-semibold uppercase tracking-wide text-[var(--color-paper-tertiary)] mb-3",
490                            "链接"
491                        }
492                        FormInput {
493                            r#type: "text",
494                            placeholder: "自动生成",
495                            value: slug(),
496                            oninput: move |v: String| slug.set(v),
497                        }
498                    }
499                    // 标签节
500                    div { class: "p-6 border-b border-[var(--color-paper-border)]",
501                        label { class: "block text-xs font-semibold uppercase tracking-wide text-[var(--color-paper-tertiary)] mb-3",
502                            "标签"
503                        }
504                        FormInput {
505                            r#type: "text",
506                            placeholder: "逗号分隔...",
507                            value: tags(),
508                            oninput: move |v: String| tags.set(v),
509                        }
510                    }
511                    // 摘要节
512                    div { class: "p-6 border-b border-[var(--color-paper-border)]",
513                        label { class: "block text-xs font-semibold uppercase tracking-wide text-[var(--color-paper-tertiary)] mb-3",
514                            "摘要"
515                        }
516                        textarea {
517                            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-2xl px-3 py-2 transition-all resize-none leading-relaxed",
518                            placeholder: "选填...",
519                            rows: "3",
520                            value: "{summary}",
521                            oninput: move |evt| summary.set(evt.value()),
522                        }
523                    }
524                    // 封面图节
525                    div { class: "p-6",
526                        label { class: "block text-xs font-semibold uppercase tracking-wide text-[var(--color-paper-tertiary)] mb-3",
527                            "封面图"
528                        }
529                        CoverUploader { cover_image, cover_uploading }
530                    }
531                } // 右栏闭合
532            } // 两栏容器闭合
533
534            // 底部操作栏 - flex 分区布局的贴底块:作为 main 直接子元素(flex-shrink-0),
535            // 永远贴卡片底沿,不随内容区滚动。无需 sticky,不会跳动。
536            // px-6 py-4 与内容区水平对齐;border-t 与页头分割线视觉一致。
537            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)]",
538                button {
539                    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",
540                    onclick: move |_| {
541                        let _ = dioxus::router::navigator().push(Route::Posts {});
542                    },
543                    "取消"
544                }
545                div { class: "w-px h-5 bg-[var(--color-paper-border)]" }
546                // 文章状态下拉(胶囊触发器;贴底栏,面板自动向上展开)
547                FormSelect {
548                    trigger_class: Some(
549                        "inline-flex w-auto cursor-pointer select-none text-left text-sm font-medium pl-4 pr-8 py-2 rounded-full border border-[var(--color-paper-border)] bg-[var(--color-paper-entry)] text-[var(--color-paper-primary)] hover:bg-[var(--color-paper-theme)] focus:outline-none focus:border-paper-accent focus:ring-1 focus:ring-paper-accent/30 transition-colors duration-200",
550                    ),
551                    value: status(),
552                    options: vec![
553                        ("draft".to_string(), "存为草稿"),
554                        ("published".to_string(), "直接发布"),
555                    ],
556                    onchange: move |v| status.set(v),
557                }
558                div { class: "w-px h-5 bg-[var(--color-paper-border)]" }
559                LoadingButton {
560                    label: if is_edit { "更新文章".to_string() } else { "发布文章".to_string() },
561                    loading: saving(),
562                    onclick: move |_| on_submit(()),
563                }
564            }
565        }
566    }
567}
568
569/// 封面上传子组件。
570///
571/// 封装封面图的全部状态与交互:拖拽/粘贴/选择文件上传、URL 输入、预览、移除。
572/// 通过两个 signal 与父组件双向绑定:
573/// - `cover_image`:子组件写入最终 URL,父组件读取用于保存;
574/// - `cover_uploading`:子组件上传时置 true,父组件在 `on_submit` 读取以拦截
575///   上传中的保存(避免封面 URL 尚未落定就提交导致封面丢失)。
576/// 其余上传中间态(error/url/drag)对本组件私有。
577///
578/// 从 `write_editor` 抽取以降低 god component 复杂度(见 dioxus-render-purity skill)。
579#[component]
580#[cfg_attr(not(target_arch = "wasm32"), allow(unused_mut, unused_variables))]
581fn CoverUploader(cover_image: Signal<String>, cover_uploading: Signal<bool>) -> Element {
582    // 封面图上传状态:错误消息、URL 输入框展开、拖拽高亮(cover_uploading 由父组件传入)。
583    let mut cover_error = use_signal(|| None::<String>);
584    let mut cover_url_mode = use_signal(|| false);
585    let mut cover_drag_active = use_signal(|| false);
586    // 封面 URL 输入框的临时值(确认前不直接写入 cover_image,避免半截 URL 触发预览加载)。
587    let mut cover_url_input = use_signal(|| "".to_string());
588    // 素材选择 modal 显隐。
589    let mut picker_visible = use_signal(|| false);
590
591    // 封面图上传:spawn 一个 async 调用 upload_image_file。
592    // 三条入口(file input / drop / paste)收敛成拿到 web_sys::File 后统一调用此闭包。
593    // 仅在 WASM 端有意义(upload_image_file 与 spawn 都依赖 WASM 运行时),
594    // server SSR 不渲染上传逻辑,故整体 cfg-gate 避免引用 wasm-only 符号。
595    #[cfg(target_arch = "wasm32")]
596    let mut spawn_cover_upload = move |file: web_sys::File| {
597        cover_uploading.set(true);
598        cover_error.set(None);
599        spawn(async move {
600            match upload_image_file(file).await {
601                Ok(url) => {
602                    cover_image.set(url);
603                }
604                Err(msg) => {
605                    cover_error.set(Some(msg));
606                }
607            }
608            cover_uploading.set(false);
609        });
610    };
611
612    rsx! {
613        // 封面图上传区:空态矮横条(不挤压编辑器),有图时展开成 21:9 超宽预览。
614        // 21:9 与首页卡片封面统一比例,比 16:9 更扁,适合宽屏横幅式封面。
615        // 容器统一绑定拖拽与粘贴事件;内部按 cover_image / cover_uploading 切换空态、上传中、预览。
616        div {
617            class: "relative w-full border border-dashed rounded-2xl overflow-hidden transition-all duration-200 group/cover",
618            // 空态矮横条;有图/上传中展开成 21:9。
619            class: if cover_image().is_empty() && !cover_uploading() { "h-14" } else { "aspect-[21/9]" },
620            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)]" },
621
622            // 整个容器可接收拖拽与粘贴(ondragover 必须 prevent_default,否则浏览器直接打开文件)。
623            ondragover: move |evt| {
624                evt.prevent_default();
625                if !cover_uploading() && cover_image().is_empty() {
626                    cover_drag_active.set(true);
627                }
628            },
629            ondragenter: move |evt| {
630                evt.prevent_default();
631            },
632            ondragleave: move |_| {
633                cover_drag_active.set(false);
634            },
635            ondrop: move |evt| {
636                evt.prevent_default();
637                cover_drag_active.set(false);
638                if !cover_uploading() && cover_image().is_empty() {
639                    #[cfg(target_arch = "wasm32")]
640                    {
641                        if let Some(file) = evt.files().into_iter().next() {
642                            if let Some(web_file) = file.get_web_file() {
643                                spawn_cover_upload(web_file);
644                            }
645                        }
646                    }
647                }
648            },
649            onpaste: move |evt| {
650                evt.prevent_default();
651                if !cover_uploading() && cover_image().is_empty() {
652                    #[cfg(target_arch = "wasm32")]
653                    {
654                        use wasm_bindgen::JsCast;
655                        if let Some(raw) = evt.try_as_web_event() {
656                            if let Some(ce) = raw.dyn_ref::<web_sys::ClipboardEvent>() {
657                                if let Some(dt) = ce.clipboard_data() {
658                                    if let Some(file_list) = dt.files() {
659                                        if let Some(file) = file_list.item(0) {
660                                            spawn_cover_upload(file);
661                                        }
662                                    }
663                                }
664                            }
665                        }
666                    }
667                }
668            },
669
670            // —— 上传中:骨架占位 + 文案 ——
671            if cover_uploading() {
672                div { class: "absolute inset-0 flex flex-col items-center justify-center gap-3 bg-[var(--color-paper-tertiary)]/30 animate-pulse",
673                    span { class: "text-sm font-medium text-[var(--color-paper-secondary)]",
674                        "正在上传..."
675                    }
676                }
677            }
678
679            // —— 有图:预览 + 移除/更换 ——
680            if !cover_image().is_empty() && !cover_uploading() {
681                // 预览图:/uploads/ 路径加 ?w=600 缩略,外链 URL 原样用。
682                // 用条件表达式内联计算,避免 rsx 内 let 块(宏在 server 端解析受限)。
683                img {
684                    class: "absolute inset-0 w-full h-full object-cover",
685                    src: {
686                        let cv = cover_image();
687                        if cv.starts_with("/uploads/") {
688                            let base = cv.split('?').next().unwrap_or(&cv);
689                            if cv.contains('?') {
690                                format!("{}&w=600", base)
691                            } else {
692                                format!("{}?w=600", base)
693                            }
694                        } else {
695                            cv
696                        }
697                    },
698                    alt: "封面预览",
699                    // 外链预览加载失败时提示,避免空白。
700                    onerror: move |_| {
701                        cover_error.set(Some("封面图加载失败,请检查 URL".to_string()));
702                    },
703                }
704                // 右上角移除按钮(hover 出现)。
705                button {
706                    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",
707                    aria_label: "移除封面",
708                    onclick: move |_| {
709                        cover_image.set(String::new());
710                        cover_error.set(None);
711                        cover_url_mode.set(false);
712                        cover_url_input.set(String::new());
713                    },
714                    // 内联 SVG:关闭 X(与 header.rs 关闭按钮同风格,view_box 0 0 24 24)。
715                    svg {
716                        class: "w-4 h-4",
717                        xmlns: "http://www.w3.org/2000/svg",
718                        view_box: "0 0 24 24",
719                        fill: "none",
720                        stroke: "currentColor",
721                        stroke_width: "2",
722                        stroke_linecap: "round",
723                        stroke_linejoin: "round",
724                        path { d: "M6 6l12 12M6 18L18 6" }
725                    }
726                }
727                // 底部渐变遮罩 + "更换封面"提示(hover 出现)。
728                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",
729                    span { class: "text-sm font-medium text-white/90 drop-shadow-md",
730                        "点击更换封面"
731                    }
732                }
733            }
734
735            // —— 空态:横向矮横条,图标+提示+URL 链 ——
736            if cover_image().is_empty() && !cover_uploading() {
737                // label 包裹整个空态:点击天然触发隐藏的 file input,无需 JS。
738                label { class: "absolute inset-0 flex flex-row items-center gap-3 cursor-pointer px-4 text-left",
739                    // 上传图标(Feather 风格线框,与项目现有图标体系一致)。
740                    svg {
741                        class: "w-5 h-5 shrink-0 text-[var(--color-paper-secondary)]",
742                        xmlns: "http://www.w3.org/2000/svg",
743                        view_box: "0 0 24 24",
744                        fill: "none",
745                        stroke: "currentColor",
746                        stroke_width: "1.8",
747                        stroke_linecap: "round",
748                        stroke_linejoin: "round",
749                        path { d: "M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" }
750                        polyline { points: "17 8 12 3 7 8" }
751                        line {
752                            x1: "12",
753                            y1: "3",
754                            x2: "12",
755                            y2: "15",
756                        }
757                    }
758                    span { class: "text-sm font-medium text-[var(--color-paper-secondary)] shrink-0",
759                        "拖拽 / 粘贴 / 点击上传"
760                    }
761                    // 从素材库选择:阻止 label 默认 file 触发,打开选择 modal。
762                    span {
763                        class: "text-sm font-medium text-[var(--color-paper-tertiary)] hover:text-[var(--color-paper-primary)] transition-colors ml-auto shrink-0",
764                        onclick: move |evt| {
765                            evt.prevent_default();
766                            evt.stop_propagation();
767                            picker_visible.set(true);
768                        },
769                        "素材库"
770                    }
771                    // URL 文字链:阻止 label 的默认 file 触发,切换到 URL 输入模式。
772                    span {
773                        class: "text-sm font-medium text-[var(--color-paper-tertiary)] hover:text-[var(--color-paper-primary)] transition-colors shrink-0",
774                        onclick: move |evt| {
775                            evt.prevent_default();
776                            evt.stop_propagation();
777                            cover_url_mode.set(true);
778                            cover_url_input.set(cover_image());
779                        },
780                        "输入链接"
781                    }
782                    // 隐藏的 file input,由 label 点击触发。
783                    input {
784                        r#type: "file",
785                        accept: "image/jpeg,image/png,image/gif,image/webp",
786                        class: "hidden",
787                        onchange: move |evt| {
788                            #[cfg(target_arch = "wasm32")]
789                            {
790                                if let Some(file) = evt.files().into_iter().next() {
791                                    if let Some(web_file) = file.get_web_file() {
792                                        spawn_cover_upload(web_file);
793                                    }
794                                }
795                            }
796                            // 注意:未重置 input.value,重复选择同一文件不会再次触发 onchange。
797                            // 这是 file input 的通用行为,封面场景影响可忽略。
798                        },
799                    }
800                }
801            }
802        }
803
804        // —— URL 输入模式(内联展开,空态时叠加在容器外,避免与拖拽区争抢点击)——
805        if cover_url_mode() && cover_image().is_empty() {
806            div { class: "flex items-center gap-2 mt-2",
807                FormInput {
808                    r#type: "url",
809                    placeholder: "粘贴图片链接...",
810                    value: cover_url_input(),
811                    class: INPUT_INLINE_CLASS,
812                    oninput: move |v: String| cover_url_input.set(v),
813                    onkeydown: move |evt: KeyboardEvent| {
814                        if evt.key() == Key::Enter {
815                            let v = cover_url_input().trim().to_string();
816                            if !v.is_empty() {
817                                cover_image.set(v);
818                                cover_error.set(None);
819                                cover_url_mode.set(false);
820                            }
821                        }
822                    },
823                }
824                button {
825                    class: "shrink-0 {BTN_PRIMARY_SM}",
826                    onclick: move |_| {
827                        let v = cover_url_input().trim().to_string();
828                        if !v.is_empty() {
829                            cover_image.set(v);
830                            cover_error.set(None);
831                            cover_url_mode.set(false);
832                        }
833                    },
834                    "确认"
835                }
836                button {
837                    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",
838                    onclick: move |_| {
839                        cover_url_mode.set(false);
840                        cover_url_input.set(String::new());
841                    },
842                    "取消"
843                }
844            }
845        }
846
847        // 封面上传失败提示:复用页面红色条风格。
848        if let Some(err) = cover_error() {
849            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-2xl text-sm border border-red-100 dark:border-red-900/30 mt-2",
850                span { "封面图: {err}" }
851                button {
852                    class: "{BTN_CLOSE_ICON}",
853                    aria_label: "关闭提示",
854                    onclick: move |_| cover_error.set(None),
855                    "×"
856                }
857            }
858        }
859
860        // 素材选择 modal:选中回填封面 URL(上传新图成功同样回填)。
861        AssetPickerModal {
862            visible: picker_visible,
863            cover_uploading,
864            on_select: move |url: String| {
865                cover_image.set(url);
866                cover_error.set(None);
867            },
868        }
869    }
870}