Skip to main content

yggdrasil/pages/admin/
write.rs

1//! 文章编辑器页面。
2//!
3//! 提供新建文章与编辑文章两种模式,使用基于 Tiptap 的富文本编辑器。
4//! 编辑器通过 [`crate::bridges::tiptap`] 的 wasm-bindgen 绑定在 WASM 前端初始化,
5//! 并与 `window.TiptapEditor` 实例交互,实现 Markdown 内容回填、图片上传与组件卸载时的清理。
6
7#![allow(unused_imports)]
8
9// prelude 在 WASM 构建里直接使用(use_signal/Signal/Element 等);
10// server 构建里 #[component] 宏会重新导出这些符号导致 native 报 unused,故 allow。
11use dioxus::prelude::*;
12
13// 仅在 WASM 前端使用的类型转换与文章 API。
14#[cfg(target_arch = "wasm32")]
15use crate::api::posts::{
16    create_post, get_post_by_id, update_post, CreatePostResponse, SinglePostResponse,
17};
18#[cfg(target_arch = "wasm32")]
19use crate::bridges::tiptap::{consume_upload_event, upload_image_file, EditorHandle};
20// 共享上传状态类型:两端都编译(rsx 在 server SSR 时也要渲染这些结构)。
21use crate::bridges::tiptap::{UploadErrorEntry, UploadsInFlight};
22use crate::components::assets::{AssetPickerModal, AssetSelection};
23use crate::components::forms::{FormInput, FormSelect, INPUT_INLINE_CLASS};
24use crate::components::skeletons::write_skeleton::WriteSkeleton;
25use crate::components::ui::{LoadingButton, BTN_CLOSE_ICON, BTN_PRIMARY_SM};
26use crate::models::post::Post;
27use crate::router::Route;
28use dioxus::router::components::Link;
29#[cfg(target_arch = "wasm32")]
30use wasm_bindgen::closure::Closure;
31// 封面图上传:从 Dioxus 事件拿底层 web_sys::File,以及 paste 事件的原始 web 事件。
32// 这三个 trait 仅在 WASM 端可用(dioxus-web 提供),通过 dioxus 的 re-export 访问:
33// - HasFileData:evt.files()(FormEvent / DragEvent 取文件)— dioxus::html
34// - WebFileExt:file.get_web_file()(FileData 取底层 web_sys::File)— dioxus::web
35// - WebEventExt:evt.try_as_web_event()(ClipboardEvent 取原始 web 事件)— dioxus::web
36#[cfg(target_arch = "wasm32")]
37use crate::utils::js::invoke_optional_global;
38#[cfg(target_arch = "wasm32")]
39use dioxus::html::HasFileData;
40#[cfg(target_arch = "wasm32")]
41use dioxus::web::{WebEventExt, WebFileExt};
42
43/// 新建文章页面组件。
44///
45/// 内部委托给 `write_editor`,以 `None` 表示新建模式。
46#[component]
47#[cfg_attr(not(target_arch = "wasm32"), allow(unused_mut, unused_variables))]
48pub fn Write() -> Element {
49    write_editor(None)
50}
51
52/// 编辑文章页面组件。
53///
54/// `id` 为要编辑的文章 ID,内部委托给 `write_editor` 加载现有数据。
55#[component]
56#[cfg_attr(not(target_arch = "wasm32"), allow(unused_mut, unused_variables))]
57pub fn WriteEdit(id: i32) -> Element {
58    write_editor(Some(id))
59}
60
61/// 文章编辑器核心组件,支持新建(`post_id == None`)与编辑模式。
62///
63/// 负责:
64/// - 编辑模式下通过 server function 拉取文章数据;
65/// - 在 WASM 前端通过 bridges::tiptap 的 closure 回调初始化 Tiptap 富文本编辑器;
66/// - 编辑模式下将 Markdown 内容回填到编辑器;
67/// - 提交时读取编辑器 Markdown、校验并调用 create_post / update_post;
68/// - 组件卸载时销毁 Tiptap 实例(EditorHandle::drop 自动 destroy + 释放 closure)。
69#[cfg_attr(not(target_arch = "wasm32"), allow(unused_mut, unused_variables))]
70fn write_editor(post_id: Option<i32>) -> Element {
71    use crate::bridges::library::{library_ready, use_browser_library, LibraryLoadError};
72    let editor_library = use_browser_library("tiptap", || true);
73    let is_edit = post_id.is_some();
74    let mode_title = if is_edit {
75        if let Some(id) = post_id {
76            format!("编辑文章 #{}", id)
77        } else {
78            "编辑文章".to_string()
79        }
80    } else {
81        "新建文章".to_string()
82    };
83
84    // 文章元信息表单字段。
85    let mut title = use_signal(|| "".to_string());
86    let mut summary = use_signal(|| "".to_string());
87    let mut slug = use_signal(|| "".to_string());
88    let mut tags = use_signal(|| "".to_string());
89    let mut cover_image = use_signal(|| "".to_string());
90    // 封面上传中状态:由子组件 CoverUploader 写入,本组件在 on_submit 读取以拦截保存。
91    // 无需 mut:本组件只读取(L254)与传递(L452),写入都在 CoverUploader 内部,
92    // 而 Dioxus Signal 是 Copy 类型,.set() 不要求 mut 绑定。
93    let cover_uploading = use_signal(|| false);
94    // 正文素材选择弹窗:slash 命令「素材库」经 onPickFromLibrary 回调打开(多选模式);
95    // body_picker_uploading 承载弹窗内上传中状态,供 on_submit 拦截(语义同 cover_uploading)。
96    let body_picker_visible = use_signal(|| false);
97    let body_picker_uploading = use_signal(|| false);
98    let mut status = use_signal(|| "published".to_string());
99    let mut content = use_signal(|| "".to_string());
100    // 专注模式 / 侧栏折叠状态:默认在宽屏展开,支持一键收起进入沉浸式写作。
101    let mut sidebar_open = use_signal(|| true);
102    // 页面与编辑器加载、保存、错误、成功等状态。
103    let mut loading = use_signal(|| true);
104    let mut saving = use_signal(|| false);
105    let mut error = use_signal(|| None::<String>);
106    let mut editor_content_set = use_signal(|| false);
107    let mut has_backfilled = use_signal(|| false);
108    let mut load_error = use_signal(|| None::<String>);
109    // 编辑模式:用于暂存从服务端加载的文章数据。
110    let mut edit_post = use_signal(|| None::<Post>);
111
112    // WASM 前端:编辑器实例句柄(持有实例 + closure,统一生命周期)。
113    #[cfg(target_arch = "wasm32")]
114    let mut editor: Signal<Option<EditorHandle>> = use_signal(|| None);
115    // WASM 前端:编辑器就绪标志(onReady 回调驱动,替代 __tiptap_ready 轮询)。
116    #[cfg(target_arch = "wasm32")]
117    let ready = use_signal(|| false);
118
119    // 上传状态:当前进行中计数(保存拦截)+ 顶部失败提示堆叠(用户手动关闭)
120    let uploads_in_flight = use_signal(UploadsInFlight::default);
121    let upload_errors: Signal<Vec<UploadErrorEntry>> = use_signal(Vec::new);
122
123    // 派生统计:字数与预计阅读时间(纯计算,随 content 响应式更新)。
124    let word_stats = use_memo(move || {
125        let md = content();
126        let chars = md.chars().filter(|c| !c.is_whitespace()).count();
127        let mins = (chars / 300).max(1);
128        (chars, mins)
129    });
130
131    // 派生标签预览列表(纯计算,支持中英文逗号与分号)。
132    let parsed_tags = use_memo(move || {
133        tags()
134            .split([',', ',', ';', ';'])
135            .map(|t| t.trim().to_string())
136            .filter(|t| !t.is_empty())
137            .collect::<Vec<String>>()
138    });
139    // 编辑模式:文章数据加载完成后,将字段回填到表单信号。
140    use_effect(move || {
141        if !is_edit || has_backfilled() {
142            return;
143        }
144        if let Some(ref post) = edit_post() {
145            has_backfilled.set(true);
146            title.set(post.title.clone());
147            summary.set(post.summary.clone().unwrap_or_default());
148            slug.set(post.slug.clone());
149            tags.set(post.tags.join(", "));
150            cover_image.set(post.cover_image.clone().unwrap_or_default());
151            status.set(post.status.as_str().to_string());
152            content.set(post.content_md.clone());
153        }
154    });
155
156    // 编辑模式:仅在 WASM 前端通过 server function 加载文章详情。
157    use_effect(move || {
158        if is_edit {
159            #[cfg(target_arch = "wasm32")]
160            if let Some(id) = post_id {
161                spawn(async move {
162                    match get_post_by_id(id).await {
163                        Ok(SinglePostResponse { post: Some(post) }) => {
164                            edit_post.set(Some(post));
165                        }
166                        Ok(SinglePostResponse { post: None }) => {
167                            load_error.set(Some("文章不存在".to_string()));
168                        }
169                        Err(e) => {
170                            load_error.set(Some(format!("加载失败: {}", e)));
171                        }
172                    }
173                });
174            }
175        }
176    });
177
178    // 组件卸载时清理 Tiptap 实例:EditorHandle::drop 会 destroy 编辑器并释放全部 closure。
179    #[cfg(target_arch = "wasm32")]
180    use_drop(move || {
181        editor.set(None);
182    });
183
184    // Tiptap 编辑器初始化:构造 closure + EditorOptions,调用 bridge.create。
185    // 替代旧版 eval initEditor 脚本 + 100ms 就绪轮询。
186    // use_effect 在首次渲染后跑,此时 #tiptap-editor 容器已挂载。
187    #[cfg(target_arch = "wasm32")]
188    use_effect(move || {
189        // 编辑模式:等数据加载完再初始化(避免空内容覆盖回填)。
190        // 回填内容直接从 edit_post(唯一可信源)取,不走 content signal——
191        // content 由独立的回填 effect 写入,两者在 edit_post 变更同一 tick 触发时
192        // 没有保证的先后顺序:本 effect 可能先于回填 effect 跑,读到仍是空串,
193        // 导致 setMarkdown 被跳过、编辑器空白(且 editor_content_set=true 阻止重试)。
194        if is_edit && edit_post().is_none() {
195            return;
196        }
197        // 防重复 init(effect 可能多次触发)
198        if editor.read().is_some() {
199            return;
200        }
201        if !library_ready(editor_library) {
202            return;
203        }
204
205        // —— 构造 closure ——
206        // 用 FnMut:Dioxus Signal 的 write/set 接收 &mut self,回调需可变借用捕获的 signal。
207        let on_update = Closure::new({
208            let mut content = content;
209            move |md: String| content.set(md)
210        });
211        let on_ready = Closure::new({
212            let mut ready = ready;
213            move || ready.set(true)
214        });
215        let on_image_upload = crate::bridges::tiptap::make_upload_closure();
216        let on_upload_event = Closure::new({
217            let uploads_in_flight = uploads_in_flight;
218            let upload_errors = upload_errors;
219            move |ev: crate::bridges::tiptap::UploadEventJs| {
220                consume_upload_event(&ev, uploads_in_flight, upload_errors);
221            }
222        });
223        // 运行代码 closure:start_exec + 轮询,结果字符串回填 JS 结果区 DOM。
224        let on_run_code = crate::bridges::tiptap::make_run_code_closure();
225        // slash 命令「素材库」closure:打开正文素材选择弹窗
226        // (JS 侧已删掉 /命令 文本;选中回填由弹窗 on_select 完成)。
227        let on_pick_from_library = Closure::new({
228            let mut body_picker_visible = body_picker_visible;
229            move || body_picker_visible.set(true)
230        });
231
232        // —— 构造 options ——
233        let opts = crate::bridges::tiptap::EditorOptions::new();
234        opts.set_placeholder("在此输入内容...");
235        opts.set_on_update(&on_update);
236        opts.set_on_ready(&on_ready);
237        opts.set_on_image_upload(&on_image_upload);
238        opts.set_on_upload_event(&on_upload_event);
239        opts.set_on_run_code(&on_run_code);
240        opts.set_on_pick_from_library(&on_pick_from_library);
241
242        // —— create(同步返回;找不到容器返回 None,构造失败抛异常)——
243        match crate::bridges::tiptap::get_module().create("tiptap-editor", &opts) {
244            Ok(Some(inst)) => {
245                // 编辑模式回填:create 成功立即回填(实例已创建,时机确定)。
246                // 直接从 edit_post 取 content_md:本 effect 已在上面 guard 了 edit_post.is_none(),
247                // 而 edit_post 是异步加载后一次性写入、之后只读的真值源,读取它没有竞态。
248                // 用 editor_content_set 防重复回填(effect 重跑时跳过)。
249                if is_edit && !editor_content_set() {
250                    if let Some(post) = edit_post() {
251                        let md = &post.content_md;
252                        if !md.is_empty() {
253                            inst.set_markdown(md);
254                        }
255                    }
256                    editor_content_set.set(true);
257                }
258                let handle = EditorHandle::new(
259                    inst,
260                    on_update,
261                    on_image_upload,
262                    on_ready,
263                    on_upload_event,
264                    on_run_code,
265                    on_pick_from_library,
266                );
267                editor.set(Some(handle));
268            }
269            Ok(None) => {
270                load_error.set(Some("编辑器容器未就绪".to_string()));
271                loading.set(false);
272            }
273            Err(e) => {
274                load_error.set(Some(format!("编辑器初始化错误: {:?}", e)));
275                loading.set(false);
276            }
277        }
278    });
279
280    // 编辑器就绪后解除 loading(onReady 回调设 ready=true)。
281    // 独立 effect 避免 on_ready 闭包与 loading 写权限在同一作用域冲突。
282    #[cfg(target_arch = "wasm32")]
283    use_effect(move || {
284        if ready() {
285            loading.set(false);
286        }
287    });
288
289    // 非 WASM(server SSR):无编辑器,直接解除 loading。
290    #[cfg(not(target_arch = "wasm32"))]
291    use_effect(move || {
292        loading.set(false);
293    });
294
295    // 提交表单:校验标题与内容,读取 Tiptap 编辑器 Markdown,调用 create_post 或 update_post。
296    let mut on_submit = move |_| {
297        if loading() || !library_ready(editor_library) {
298            return;
299        }
300        // 上传未完成/失败拦截:有占位符时阻止保存
301        let in_flight = uploads_in_flight.read();
302        if in_flight.uploading > 0 || in_flight.error > 0 {
303            let msg = if in_flight.uploading > 0 {
304                format!(
305                    "有 {} 张图片正在上传,请等待完成后再保存",
306                    in_flight.uploading
307                )
308            } else {
309                format!(
310                    "有 {} 张图片上传失败,请移除或重试后再保存",
311                    in_flight.error
312                )
313            };
314            error.set(Some(msg));
315            return;
316        }
317        drop(in_flight);
318
319        // 封面图上传中拦截:防止保存半成品(cover_uploading 由子组件 CoverUploader 写入)。
320        if cover_uploading() {
321            error.set(Some("封面图正在上传,请等待完成后再保存".to_string()));
322            return;
323        }
324
325        // 正文素材弹窗内上传中拦截(与封面同一语义)。
326        if body_picker_uploading() {
327            error.set(Some("图片正在上传,请等待完成后再保存".to_string()));
328            return;
329        }
330
331        if title().trim().is_empty() {
332            error.set(Some("标题不能为空".to_string()));
333        }
334
335        // 仅在 WASM 前端读取编辑器内容并发起保存请求。
336        #[cfg(target_arch = "wasm32")]
337        if !title().trim().is_empty() {
338            // 通过 EditorHandle 实例读取 Markdown;句柄未就绪时退回 content signal。
339            let md = if let Some(handle) = &*editor.read() {
340                handle.instance().get_markdown()
341            } else {
342                content()
343            };
344
345            // 兜底:扫描残留的上传占位符标记(轮询窗口期漏判防护)
346            // 检测 ![](blob:...) 形式的泄漏图片 src,而非裸 "blob:" 字符串,
347            // 避免误伤合法讨论 blob URL 的代码块/正文。
348            if md.contains("](blob:") || md.contains("data-upload-state") {
349                error.set(Some("检测到未完成上传的图片,请处理后保存".to_string()));
350                return;
351            }
352
353            if md.trim().is_empty() {
354                error.set(Some("内容不能为空".to_string()));
355                return;
356            }
357
358            // 将逗号分隔的标签字符串转换为列表。
359            // 同时支持半角/全角逗号与分号,避免中文输入法下的全角标点被误并入单个标签。
360            let tags_list: Vec<String> = tags()
361                .split([',', ',', ';', ';'])
362                .map(|t| t.trim().to_string())
363                .filter(|t| !t.is_empty())
364                .collect();
365
366            let slug_opt = if slug().trim().is_empty() {
367                None
368            } else {
369                Some(slug().trim().to_string())
370            };
371
372            let summary_opt = if summary().trim().is_empty() {
373                None
374            } else {
375                Some(summary().trim().to_string())
376            };
377
378            let cover_image_opt = if cover_image().trim().is_empty() {
379                None
380            } else {
381                Some(cover_image().trim().to_string())
382            };
383
384            saving.set(true);
385            error.set(None);
386
387            if let Some(id) = post_id {
388                // 编辑模式:调用 update_post
389                spawn(async move {
390                    match update_post(
391                        id,
392                        title().trim().to_string(),
393                        slug_opt,
394                        summary_opt,
395                        md,
396                        status(),
397                        tags_list,
398                        cover_image_opt,
399                    )
400                    .await
401                    {
402                        Ok(CreatePostResponse { success: true, .. }) => {
403                            saving.set(false);
404                            let _ = dioxus::router::navigator().push(Route::Posts {});
405                        }
406                        Ok(CreatePostResponse {
407                            success: false,
408                            message,
409                            ..
410                        }) => {
411                            saving.set(false);
412                            error.set(Some(message));
413                        }
414                        Err(e) => {
415                            saving.set(false);
416                            error.set(Some(format!("更新失败: {}", e)));
417                        }
418                    }
419                });
420            } else {
421                // 新建模式:调用 create_post
422                spawn(async move {
423                    match create_post(
424                        title().trim().to_string(),
425                        slug_opt,
426                        summary_opt,
427                        md,
428                        status(),
429                        tags_list,
430                        cover_image_opt,
431                    )
432                    .await
433                    {
434                        Ok(CreatePostResponse { success: true, .. }) => {
435                            saving.set(false);
436                            let _ = dioxus::router::navigator().push(Route::Posts {});
437                        }
438                        Ok(CreatePostResponse {
439                            success: false,
440                            message,
441                            ..
442                        }) => {
443                            saving.set(false);
444                            error.set(Some(message));
445                        }
446                        Err(e) => {
447                            saving.set(false);
448                            error.set(Some(format!("保存失败: {}", e)));
449                        }
450                    }
451                });
452            }
453        }
454
455        // 非 WASM(SSR hydration 前等场景):明确提示无法保存,而非静默无反应。
456        #[cfg(not(target_arch = "wasm32"))]
457        if !title().trim().is_empty() {
458            error.set(Some("此页面需要 JavaScript 才能保存".to_string()));
459        }
460    };
461
462    rsx! {
463        // 根容器:flex 分区布局,包含顶部精致导航条 + 主体两栏写作区 + 底部操作栏。
464        div { class: "animate-page-enter relative flex flex-col w-full min-h-0 flex-1 bg-[var(--color-paper-theme)]",
465            if loading() && !matches!(&*editor_library.read(), Some(Err(_))) {
466                div { class: "absolute inset-0 z-10 bg-[var(--color-paper-theme)] flex flex-col", WriteSkeleton {} }
467            }
468
469            // 顶部精致工具条:面包屑 / 写作模式 / 实时字数与阅读时长 / 侧栏沉浸式切换
470            div { class: "flex-shrink-0 px-6 py-3 border-b border-[var(--color-paper-border)]/60 bg-[var(--color-paper-theme)] flex items-center justify-between gap-4 z-10 select-none",
471                // 左侧:返回文章列表 + 模式指示 + 状态胶囊
472                div { class: "flex items-center gap-3",
473                    Link {
474                        to: Route::Posts {},
475                        class: "inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium text-[var(--color-paper-secondary)] hover:text-[var(--color-paper-primary)] hover:bg-[var(--color-paper-entry)] transition-all cursor-pointer",
476                        svg {
477                            class: "w-4 h-4",
478                            xmlns: "http://www.w3.org/2000/svg",
479                            view_box: "0 0 24 24",
480                            fill: "none",
481                            stroke: "currentColor",
482                            stroke_width: "2",
483                            stroke_linecap: "round",
484                            stroke_linejoin: "round",
485                            path { d: "M19 12H5M12 19l-7-7 7-7" }
486                        }
487                        "文章列表"
488                    }
489                    div { class: "w-px h-4 bg-[var(--color-paper-border)]/60" }
490                    div { class: "flex items-center gap-2",
491                        span { class: "text-sm font-semibold text-[var(--color-paper-primary)]",
492                            "{mode_title}"
493                        }
494                        // 状态胶囊
495                        if status() == "published" {
496                            span { class: "inline-flex items-center px-2 py-0.5 rounded-full text-[11px] font-medium bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border border-emerald-500/20",
497                                "公开"
498                            }
499                        } else {
500                            span { class: "inline-flex items-center px-2 py-0.5 rounded-full text-[11px] font-medium bg-amber-500/10 text-amber-600 dark:text-amber-400 border border-amber-500/20",
501                                "草稿"
502                            }
503                        }
504                    }
505                }
506
507                // 中间:实时字数与预计阅读时间
508                div { class: "hidden md:flex items-center gap-3 text-xs text-[var(--color-paper-tertiary)] font-medium",
509                    div { class: "flex items-center gap-1.5",
510                        span { class: "w-1.5 h-1.5 rounded-full bg-[var(--color-paper-accent)]" }
511                        span { "{word_stats().0} 字" }
512                    }
513                    span { "·" }
514                    span { "预计阅读 {word_stats().1} 分钟" }
515                }
516
517                // 右侧:侧栏 / 专注写作模式切换按钮
518                div { class: "flex items-center gap-2",
519                    button {
520                        class: "inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium border transition-all cursor-pointer",
521                        class: if sidebar_open() {
522                            "border-[var(--color-paper-border)] text-[var(--color-paper-primary)] bg-[var(--color-paper-entry)] hover:bg-[var(--color-paper-border)]/50"
523                        } else {
524                            "border-[var(--color-paper-accent)]/50 text-[var(--color-paper-accent)] bg-[var(--color-paper-accent)]/10 hover:bg-[var(--color-paper-accent)]/20"
525                        },
526                        onclick: move |_| sidebar_open.toggle(),
527                        title: if sidebar_open() { "进入专注写作模式(收起设置)" } else { "展开文章属性面板" },
528                        svg {
529                            class: "w-4 h-4",
530                            xmlns: "http://www.w3.org/2000/svg",
531                            view_box: "0 0 24 24",
532                            fill: "none",
533                            stroke: "currentColor",
534                            stroke_width: "2",
535                            stroke_linecap: "round",
536                            stroke_linejoin: "round",
537                            rect { x: "3", y: "3", width: "18", height: "18", rx: "2", ry: "2" }
538                            line { x1: "15", y1: "3", x2: "15", y2: "21" }
539                        }
540                        if sidebar_open() {
541                            "收起设置"
542                        } else {
543                            "文章设置"
544                        }
545                    }
546                }
547            }
548
549            // 两栏容器:flex-1 分配空间,自身不滚动 (min-h-0),滚动职责下放给左右两栏。
550            div { class: "flex-1 min-h-0 flex",
551                // 左栏 (主写作区):类 Notion/Ghost 极简文档流,居中优雅排版。
552                div { "data-vt-scroll": "admin-write-content", class: "flex-1 min-w-0 min-h-0 overflow-y-auto px-6 sm:px-10 md:px-14 py-8 flex flex-col items-center",
553                    div { class: "w-full max-w-4xl flex-1 flex flex-col",
554                        // 标题输入
555                        input {
556                            class: "w-full text-3xl sm:text-4xl md:text-5xl font-extrabold bg-transparent text-[var(--color-paper-primary)] placeholder-[var(--color-paper-tertiary)]/60 focus:outline-none tracking-tight leading-tight transition-colors py-2 mb-4",
557                            placeholder: "输入文章标题...",
558                            value: "{title}",
559                            oninput: move |evt| title.set(evt.value()),
560                        }
561
562                        // 错误和成功提示
563                        LibraryLoadError { library: editor_library }
564                        if let Some(err) = load_error() {
565                            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-3",
566                                "{err}"
567                            }
568                        }
569
570                        if let Some(err) = error() {
571                            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-3",
572                                "{err}"
573                            }
574                        }
575
576                        // 上传失败提示:多条堆叠,×关闭同时删除编辑器内失败占位符(避免孤儿)
577                        for err in upload_errors().clone() {
578                            div {
579                                key: "{err.id}",
580                                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-3",
581                                span { "图片上传失败: {err.file_name} — {err.message}" }
582                                button {
583                                    class: "{BTN_CLOSE_ICON}",
584                                    aria_label: "关闭提示",
585                                    onclick: {
586                                        let id = err.id.clone();
587                                        let mut upload_errors = upload_errors;
588                                        move |_| {
589                                            #[cfg(target_arch = "wasm32")]
590                                            if let Some(handle) = &*editor.read() {
591                                                handle.instance().remove_upload_by_upload_id(&id);
592                                            }
593                                            upload_errors.write().retain(|e| e.id != id);
594                                        }
595                                    },
596                                    "×"
597                                }
598                            }
599                        }
600
601                        // 编辑器区域:软卡片容器,随窗口自适应扩展。
602                        div { class: "flex-1 min-h-[480px] flex flex-col mb-4",
603                            div {
604                                class: "relative group flex-1 min-h-0 w-full rounded-2xl overflow-hidden bg-[var(--color-paper-entry)]/40 border border-[var(--color-paper-border)]/70 focus-within:border-[var(--color-paper-accent)]/70 focus-within:ring-2 focus-within:ring-[var(--color-paper-accent)]/15 transition-all shadow-xs",
605                                id: "tiptap-editor",
606                                img {
607                                    src: "/images/xiantiaoxiaogou_input_bg.webp",
608                                    alt: "",
609                                    class: "absolute bottom-3 right-3 w-28 opacity-15 pointer-events-none z-0 select-none transition-opacity",
610                                }
611                            }
612                        }
613                    }
614                } // 左栏闭合
615
616                // 右栏 (侧边栏):折叠/展开动画,分节卡片化,带图标与视觉预览
617                if sidebar_open() {
618                    div { "data-vt-scroll": "admin-write-settings", class: "w-80 sm:w-88 flex-shrink-0 min-h-0 overflow-y-auto border-l border-[var(--color-paper-border)]/70 bg-[var(--color-paper-theme)] flex flex-col animate-section-enter",
619                        // 侧栏标题栏
620                        div { class: "px-5 py-4 border-b border-[var(--color-paper-border)]/60 flex items-center justify-between select-none",
621                            div { class: "flex items-center gap-2",
622                                svg {
623                                    class: "w-4 h-4 text-[var(--color-paper-secondary)]",
624                                    xmlns: "http://www.w3.org/2000/svg",
625                                    view_box: "0 0 24 24",
626                                    fill: "none",
627                                    stroke: "currentColor",
628                                    stroke_width: "2",
629                                    stroke_linecap: "round",
630                                    stroke_linejoin: "round",
631                                    line { x1: "4", y1: "21", x2: "4", y2: "14" }
632                                    line { x1: "4", y1: "10", x2: "4", y2: "3" }
633                                    line { x1: "12", y1: "21", x2: "12", y2: "12" }
634                                    line { x1: "12", y1: "8", x2: "12", y2: "3" }
635                                    line { x1: "20", y1: "21", x2: "20", y2: "16" }
636                                    line { x1: "20", y1: "12", x2: "20", y2: "3" }
637                                    line { x1: "1", y1: "14", x2: "7", y2: "14" }
638                                    line { x1: "9", y1: "8", x2: "15", y2: "8" }
639                                    line { x1: "17", y1: "16", x2: "23", y2: "16" }
640                                }
641                                span { class: "text-xs font-bold uppercase tracking-wider text-[var(--color-paper-secondary)]",
642                                    "文章属性"
643                                }
644                            }
645                            button {
646                                class: "text-[var(--color-paper-tertiary)] hover:text-[var(--color-paper-primary)] p-1 rounded-lg hover:bg-[var(--color-paper-entry)] transition-colors cursor-pointer",
647                                title: "收起侧栏",
648                                onclick: move |_| sidebar_open.set(false),
649                                svg {
650                                    class: "w-4 h-4",
651                                    xmlns: "http://www.w3.org/2000/svg",
652                                    view_box: "0 0 24 24",
653                                    fill: "none",
654                                    stroke: "currentColor",
655                                    stroke_width: "2",
656                                    stroke_linecap: "round",
657                                    stroke_linejoin: "round",
658                                    line { x1: "18", y1: "6", x2: "6", y2: "18" }
659                                    line { x1: "6", y1: "6", x2: "18", y2: "18" }
660                                }
661                            }
662                        }
663
664                        // Slug 节
665                        div { class: "p-5 border-b border-[var(--color-paper-border)]/60 flex flex-col gap-2.5",
666                            div { class: "flex items-center justify-between",
667                                label { class: "flex items-center gap-1.5 text-xs font-semibold text-[var(--color-paper-secondary)]",
668                                    svg {
669                                        class: "w-3.5 h-3.5 text-[var(--color-paper-tertiary)]",
670                                        xmlns: "http://www.w3.org/2000/svg",
671                                        view_box: "0 0 24 24",
672                                        fill: "none",
673                                        stroke: "currentColor",
674                                        stroke_width: "2",
675                                        stroke_linecap: "round",
676                                        stroke_linejoin: "round",
677                                        path { d: "M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" }
678                                        path { d: "M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" }
679                                    }
680                                    "永久链接 (Slug)"
681                                }
682                            }
683                            div { class: "flex items-center gap-1 text-[11px] text-[var(--color-paper-tertiary)] font-mono px-1 truncate",
684                                span { class: "text-[var(--color-paper-accent)] font-semibold", "/post/" }
685                                span { class: "truncate",
686                                    if slug().trim().is_empty() {
687                                        "(自动生成)"
688                                    } else {
689                                        "{slug()}"
690                                    }
691                                }
692                            }
693                            FormInput {
694                                r#type: "text",
695                                placeholder: "自定义 URL,留空自动生成...",
696                                value: slug(),
697                                oninput: move |v: String| slug.set(v),
698                            }
699                        }
700
701                        // 标签节
702                        div { class: "p-5 border-b border-[var(--color-paper-border)]/60 flex flex-col gap-2.5",
703                            label { class: "flex items-center gap-1.5 text-xs font-semibold text-[var(--color-paper-secondary)]",
704                                svg {
705                                    class: "w-3.5 h-3.5 text-[var(--color-paper-tertiary)]",
706                                    xmlns: "http://www.w3.org/2000/svg",
707                                    view_box: "0 0 24 24",
708                                    fill: "none",
709                                    stroke: "currentColor",
710                                    stroke_width: "2",
711                                    stroke_linecap: "round",
712                                    stroke_linejoin: "round",
713                                    path { d: "M20.59 13.41l-7.17 7.17a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z" }
714                                    line { x1: "7", y1: "7", x2: "7.01", y2: "7" }
715                                }
716                                "文章标签"
717                            }
718                            FormInput {
719                                r#type: "text",
720                                placeholder: "输入标签,逗号或分号分隔...",
721                                value: tags(),
722                                oninput: move |v: String| tags.set(v),
723                            }
724                            // 标签胶囊预览
725                            if !parsed_tags().is_empty() {
726                                div { class: "flex flex-wrap gap-1.5 mt-1",
727                                    for tag in parsed_tags() {
728                                        span {
729                                            key: "{tag}",
730                                            class: "inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-[var(--color-paper-entry)] text-[var(--color-paper-primary)] border border-[var(--color-paper-border)]/70 shadow-2xs",
731                                            "#{tag}"
732                                        }
733                                    }
734                                }
735                            }
736                        }
737
738                        // 摘要节
739                        div { class: "p-5 border-b border-[var(--color-paper-border)]/60 flex flex-col gap-2.5",
740                            div { class: "flex items-center justify-between",
741                                label { class: "flex items-center gap-1.5 text-xs font-semibold text-[var(--color-paper-secondary)]",
742                                    svg {
743                                        class: "w-3.5 h-3.5 text-[var(--color-paper-tertiary)]",
744                                        xmlns: "http://www.w3.org/2000/svg",
745                                        view_box: "0 0 24 24",
746                                        fill: "none",
747                                        stroke: "currentColor",
748                                        stroke_width: "2",
749                                        stroke_linecap: "round",
750                                        stroke_linejoin: "round",
751                                        path { d: "M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" }
752                                        polyline { points: "14 2 14 8 20 8" }
753                                        line { x1: "16", y1: "13", x2: "8", y2: "13" }
754                                        line { x1: "16", y1: "17", x2: "8", y2: "17" }
755                                    }
756                                    "文章摘要"
757                                }
758                                span { class: "text-[11px] text-[var(--color-paper-tertiary)]",
759                                    "{summary().chars().count()} 字"
760                                }
761                            }
762                            textarea {
763                                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-paper-accent focus:ring-1 focus:ring-paper-accent/30 rounded-2xl px-3.5 py-2.5 transition-all resize-none leading-relaxed",
764                                placeholder: "选填,留空将自动从正文提取...",
765                                rows: "3",
766                                value: "{summary}",
767                                oninput: move |evt| summary.set(evt.value()),
768                            }
769                        }
770
771                        // 封面图节
772                        div { class: "p-5 flex flex-col gap-2.5",
773                            div { class: "flex items-center justify-between",
774                                label { class: "flex items-center gap-1.5 text-xs font-semibold text-[var(--color-paper-secondary)]",
775                                    svg {
776                                        class: "w-3.5 h-3.5 text-[var(--color-paper-tertiary)]",
777                                        xmlns: "http://www.w3.org/2000/svg",
778                                        view_box: "0 0 24 24",
779                                        fill: "none",
780                                        stroke: "currentColor",
781                                        stroke_width: "2",
782                                        stroke_linecap: "round",
783                                        stroke_linejoin: "round",
784                                        rect { x: "3", y: "3", width: "18", height: "18", rx: "2", ry: "2" }
785                                        circle { cx: "8.5", cy: "8.5", r: "1.5" }
786                                        polyline { points: "21 15 16 10 5 21" }
787                                    }
788                                    "文章封面"
789                                }
790                                span { class: "text-[11px] text-[var(--color-paper-tertiary)]",
791                                    "21:9 推荐"
792                                }
793                            }
794                            CoverUploader { cover_image, cover_uploading }
795                        }
796                    }
797                } // 右栏闭合
798            } // 两栏容器闭合
799
800            // 底部操作栏 - flex 分区布局的贴底块:固定在底部,提供发布、草稿状态切换与返回
801            div { class: "flex-shrink-0 px-6 py-3.5 flex items-center justify-between border-t border-[var(--color-paper-border)]/80 bg-[var(--color-paper-theme)] shadow-xs select-none",
802                // 左侧取消返回
803                Link {
804                    to: Route::Posts {},
805                    class: "inline-flex items-center gap-1.5 px-4 py-2 rounded-full text-sm font-medium text-[var(--color-paper-secondary)] hover:text-[var(--color-paper-primary)] hover:bg-[var(--color-paper-entry)] transition-colors cursor-pointer",
806                    "返回列表"
807                }
808
809                // 右侧操作群组
810                div { class: "flex items-center gap-3",
811                    // 文章状态下拉(胶囊触发器)
812                    FormSelect {
813                        trigger_class: Some(
814                            "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",
815                        ),
816                        value: status(),
817                        options: vec![
818                            ("draft".to_string(), "存为草稿"),
819                            ("published".to_string(), "直接发布"),
820                        ],
821                        onchange: move |v| status.set(v),
822                    }
823                    div { class: "w-px h-5 bg-[var(--color-paper-border)]/60" }
824                    LoadingButton {
825                        label: if is_edit { "更新文章".to_string() } else { "发布文章".to_string() },
826                        loading: saving(),
827                        disabled: loading() || !library_ready(editor_library),
828                        onclick: move |_| on_submit(()),
829                    }
830                }
831            } // 底部操作栏闭合
832
833            // 正文素材选择 modal(多选模式)
834            AssetPickerModal {
835                visible: body_picker_visible,
836                cover_uploading: body_picker_uploading,
837                title: "选择图片",
838                multi: true,
839                on_select: move |picks: Vec<AssetSelection>| {
840                    #[cfg(target_arch = "wasm32")]
841                    if let Some(handle) = &*editor.read() {
842                        if let Ok(json) = serde_json::to_string(&picks) {
843                            handle.instance().insert_images_from_library(&json);
844                        }
845                    }
846                    #[cfg(not(target_arch = "wasm32"))]
847                    let _ = picks;
848                },
849            }
850        }
851    }
852}
853
854/// 封面上传子组件。
855///
856/// 封装封面图的全部状态与交互:拖拽/粘贴/选择文件上传、URL 输入、预览、移除。
857/// 通过两个 signal 与父组件双向绑定:
858/// - `cover_image`:子组件写入最终 URL,父组件读取用于保存;
859/// - `cover_uploading`:子组件上传时置 true,父组件在 `on_submit` 读取以拦截
860///   上传中的保存(避免封面 URL 尚未落定就提交导致封面丢失)。
861/// 其余上传中间态(error/url/drag)对本组件私有。
862///
863/// 从 `write_editor` 抽取以降低 god component 复杂度(见 dioxus-render-purity skill)。
864#[component]
865#[cfg_attr(not(target_arch = "wasm32"), allow(unused_mut, unused_variables))]
866fn CoverUploader(cover_image: Signal<String>, cover_uploading: Signal<bool>) -> Element {
867    // 封面图上传状态:错误消息、URL 输入框展开、拖拽高亮(cover_uploading 由父组件传入)。
868    let mut cover_error = use_signal(|| None::<String>);
869    let mut cover_url_mode = use_signal(|| false);
870    let mut cover_drag_active = use_signal(|| false);
871    // 封面 URL 输入框的临时值(确认前不直接写入 cover_image,避免半截 URL 触发预览加载)。
872    let mut cover_url_input = use_signal(|| "".to_string());
873    // 素材选择 modal 显隐。
874    let mut picker_visible = use_signal(|| false);
875    // 封面预览图加载状态:cover_image 变化时置 false,img onload 置 true。
876    // 从素材库选择/拖拽替换/URL 输入后,预览图 ?w=600 版本未缓存需重新加载,
877    // 加载期间显示骨架占位,避免空白无反馈(与上传中的骨架语义独立)。
878    let mut cover_img_loaded = use_signal(|| true);
879
880    // 封面图上传:spawn 一个 async 调用 upload_image_file。
881    // 三条入口(file input / drop / paste)收敛成拿到 web_sys::File 后统一调用此闭包。
882    // 仅在 WASM 端有意义(upload_image_file 与 spawn 都依赖 WASM 运行时),
883    // server SSR 不渲染上传逻辑,故整体 cfg-gate 避免引用 wasm-only 符号。
884    #[cfg(target_arch = "wasm32")]
885    let mut spawn_cover_upload = move |file: web_sys::File| {
886        cover_uploading.set(true);
887        cover_error.set(None);
888        spawn(async move {
889            match upload_image_file(file).await {
890                Ok(url) => {
891                    cover_image.set(url);
892                }
893                Err(msg) => {
894                    cover_error.set(Some(msg));
895                }
896            }
897            cover_uploading.set(false);
898        });
899    };
900    // 灯箱绑定 + 预览图加载占位:封面有图时点击放大查看(复用全局 lightbox.js)。
901    // 订阅 cover_image:图片出现/更换后 effect 重跑,data-lb-bound 守卫保证幂等。
902    // 新 src 待加载时置 cover_img_loaded=false,由 img onload 回填 true。
903    #[cfg(target_arch = "wasm32")]
904    use_effect(move || {
905        let cv = cover_image();
906        if cv.is_empty() {
907            cover_img_loaded.set(true);
908            return;
909        }
910        cover_img_loaded.set(false);
911        let window = web_sys::window()
912            .expect("CoverUploader use_effect 仅在 WASM 浏览器上下文执行:无 window");
913        let selectors = js_sys::Array::of1(&".cover-preview".into());
914        let selectors_val = js_sys::Object::from(selectors).into();
915        let _ = js_sys::Reflect::set(&window, &"__lightboxSelectors".into(), &selectors_val);
916        invoke_optional_global(&window, "__initLightbox", &[selectors_val]);
917    });
918
919    rsx! {
920        // 封面图上传区:空态矮横条(不挤压编辑器),有图时展开成 21:9 超宽预览。
921        // 21:9 与首页卡片封面统一比例,比 16:9 更扁,适合宽屏横幅式封面。
922        // 容器统一绑定拖拽与粘贴事件;内部按 cover_image / cover_uploading 切换空态、上传中、预览。
923        div {
924            class: "relative w-full border border-dashed rounded-2xl overflow-hidden transition-all duration-200 group/cover",
925            class: "cover-preview",
926            // 空态矮横条;有图/上传中展开成 21:9。
927            class: if cover_image().is_empty() && !cover_uploading() { "h-14" } else { "aspect-[21/9]" },
928            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)]" },
929
930            // 整个容器可接收拖拽与粘贴(ondragover 必须 prevent_default,否则浏览器直接打开文件)。
931            ondragover: move |evt| {
932                evt.prevent_default();
933                if !cover_uploading() {
934                    cover_drag_active.set(true);
935                }
936            },
937            ondragenter: move |evt| {
938                evt.prevent_default();
939            },
940            ondragleave: move |_| {
941                cover_drag_active.set(false);
942            },
943            ondrop: move |evt| {
944                evt.prevent_default();
945                if !cover_uploading() {
946                    #[cfg(target_arch = "wasm32")]
947                    {
948                        if let Some(file) = evt.files().into_iter().next() {
949                            if let Some(web_file) = file.get_web_file() {
950                                spawn_cover_upload(web_file);
951                            }
952                        }
953                    }
954                }
955            },
956            onpaste: move |evt| {
957                evt.prevent_default();
958                if !cover_uploading() {
959                    #[cfg(target_arch = "wasm32")]
960                    {
961                        use wasm_bindgen::JsCast;
962                        if let Some(raw) = evt.try_as_web_event() {
963                            if let Some(ce) = raw.dyn_ref::<web_sys::ClipboardEvent>() {
964                                if let Some(dt) = ce.clipboard_data() {
965                                    if let Some(file_list) = dt.files() {
966                                        if let Some(file) = file_list.item(0) {
967                                            spawn_cover_upload(file);
968                                        }
969                                    }
970                                }
971                            }
972                        }
973                    }
974                }
975            },
976
977            // —— 上传中:骨架占位 + 文案 ——
978            if cover_uploading() {
979                div { class: "absolute inset-0 flex flex-col items-center justify-center gap-3 bg-[var(--color-paper-tertiary)]/30 animate-pulse",
980                    span { class: "text-sm font-medium text-[var(--color-paper-secondary)]",
981                        "正在上传..."
982                    }
983                }
984            }
985
986            // —— 有图:预览(点击放大)+ 拖拽替换 + hover 工具栏 ——
987            if !cover_image().is_empty() && !cover_uploading() {
988                // 预览图:/uploads/ 路径加 ?w=600 缩略,外链 URL 原样用。
989                // cursor-zoom-in 提示可点击放大;lightbox-single 标记为单张,
990                // 由全局 lightbox.js 接管点击放大;data-src 为原图 URL。
991                img {
992                    class: "absolute inset-0 w-full h-full object-cover cursor-zoom-in lightbox-single",
993                    // data-src:原图 URL,lightbox.js 点击放大时读取(originalUrl 剥离 /uploads/ 的 ?w= 查询)。
994                    "data-src": "{cover_image()}",
995                    src: {
996                        let cv = cover_image();
997                        if cv.starts_with("/uploads/") {
998                            let base = cv.split('?').next().unwrap_or(&cv);
999                            if cv.contains('?') {
1000                                format!("{}&w=600", base)
1001                            } else {
1002                                format!("{}?w=600", base)
1003                            }
1004                        } else {
1005                            cv
1006                        }
1007                    },
1008                    alt: "封面预览",
1009                    // 加载完成清除骨架占位;失败也清除并提示(避免骨架永驻)。
1010                    onload: move |_| cover_img_loaded.set(true),
1011                    onerror: move |_| {
1012                        cover_img_loaded.set(true);
1013                        cover_error.set(Some("封面图加载失败,请检查 URL".to_string()));
1014                    },
1015                }
1016                // 预览图加载中骨架占位(素材库选择/拖拽替换/URL 切换后,?w=600 未缓存时)。
1017                if !cover_img_loaded() {
1018                    div { class: "absolute inset-0 animate-pulse bg-[var(--color-paper-tertiary)]/40 pointer-events-none" }
1019                }
1020                // 拖拽替换高亮:有图时拖入文件,提示"松开以更换"。
1021                if cover_drag_active() {
1022                    div { class: "absolute inset-0 flex items-center justify-center bg-black/50 pointer-events-none",
1023                        span { class: "text-sm font-medium text-white drop-shadow-md",
1024                            "松开以更换封面"
1025                        }
1026                    }
1027                }
1028                // hover 工具栏:拖拽时隐藏(由拖拽替换高亮接管),避免两个覆盖层叠加。
1029                // pointer-events-none 容器穿透点击到预览图触发灯箱,按钮设 pointer-events-auto。
1030                if !cover_drag_active() {
1031                    div { class: "absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/60 to-transparent opacity-0 group-hover/cover:opacity-100 transition-opacity flex items-center justify-center gap-1 py-2 pointer-events-none",
1032                        // 更换:label 包裹隐藏 file input,点击触发文件选择。
1033                        label { class: "pointer-events-auto px-3 py-1.5 rounded-full text-xs font-medium text-white/90 hover:bg-white/15 cursor-pointer transition-colors",
1034                            "更换"
1035                            input {
1036                                r#type: "file",
1037                                accept: "image/jpeg,image/png,image/gif,image/webp",
1038                                class: "hidden",
1039                                onchange: move |evt| {
1040                                    #[cfg(target_arch = "wasm32")]
1041                                    {
1042                                        if let Some(file) = evt.files().into_iter().next() {
1043                                            if let Some(web_file) = file.get_web_file() {
1044                                                spawn_cover_upload(web_file);
1045                                            }
1046                                        }
1047                                    }
1048                                },
1049                            }
1050                        }
1051                        // 从素材库选择。
1052                        button {
1053                            class: "pointer-events-auto px-3 py-1.5 rounded-full text-xs font-medium text-white/90 hover:bg-white/15 transition-colors cursor-pointer",
1054                            onclick: move |_| picker_visible.set(true),
1055                            "素材库"
1056                        }
1057                        // 移除封面。
1058                        button {
1059                            class: "pointer-events-auto px-3 py-1.5 rounded-full text-xs font-medium text-white/90 hover:bg-white/15 transition-colors cursor-pointer",
1060                            onclick: move |_| {
1061                                cover_image.set(String::new());
1062                                cover_error.set(None);
1063                                cover_url_mode.set(false);
1064                                cover_url_input.set(String::new());
1065                            },
1066                            "移除"
1067                        }
1068                    }
1069                }
1070            }
1071
1072            // —— 空态:横向矮横条,图标+提示+URL 链 ——
1073            if cover_image().is_empty() && !cover_uploading() {
1074                // label 包裹整个空态:点击天然触发隐藏的 file input,无需 JS。
1075                label { class: "absolute inset-0 flex flex-row items-center gap-3 cursor-pointer px-4 text-left",
1076                    // 上传图标(Feather 风格线框,与项目现有图标体系一致)。
1077                    svg {
1078                        class: "w-5 h-5 shrink-0 text-[var(--color-paper-secondary)]",
1079                        xmlns: "http://www.w3.org/2000/svg",
1080                        view_box: "0 0 24 24",
1081                        fill: "none",
1082                        stroke: "currentColor",
1083                        stroke_width: "1.8",
1084                        stroke_linecap: "round",
1085                        stroke_linejoin: "round",
1086                        path { d: "M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" }
1087                        polyline { points: "17 8 12 3 7 8" }
1088                        line {
1089                            x1: "12",
1090                            y1: "3",
1091                            x2: "12",
1092                            y2: "15",
1093                        }
1094                    }
1095                    span { class: "text-sm font-medium text-[var(--color-paper-secondary)] shrink-0",
1096                        "拖拽 / 粘贴 / 点击上传"
1097                    }
1098                    // 从素材库选择:阻止 label 默认 file 触发,打开选择 modal。
1099                    span {
1100                        class: "text-sm font-medium text-[var(--color-paper-tertiary)] hover:text-[var(--color-paper-primary)] transition-colors ml-auto shrink-0",
1101                        onclick: move |evt| {
1102                            evt.prevent_default();
1103                            evt.stop_propagation();
1104                            picker_visible.set(true);
1105                        },
1106                        "素材库"
1107                    }
1108                    // URL 文字链:阻止 label 的默认 file 触发,切换到 URL 输入模式。
1109                    span {
1110                        class: "text-sm font-medium text-[var(--color-paper-tertiary)] hover:text-[var(--color-paper-primary)] transition-colors shrink-0",
1111                        onclick: move |evt| {
1112                            evt.prevent_default();
1113                            evt.stop_propagation();
1114                            cover_url_mode.set(true);
1115                            cover_url_input.set(cover_image());
1116                        },
1117                        "输入链接"
1118                    }
1119                    // 隐藏的 file input,由 label 点击触发。
1120                    input {
1121                        r#type: "file",
1122                        accept: "image/jpeg,image/png,image/gif,image/webp",
1123                        class: "hidden",
1124                        onchange: move |evt| {
1125                            #[cfg(target_arch = "wasm32")]
1126                            {
1127                                if let Some(file) = evt.files().into_iter().next() {
1128                                    if let Some(web_file) = file.get_web_file() {
1129                                        spawn_cover_upload(web_file);
1130                                    }
1131                                }
1132                            }
1133                            // 注意:未重置 input.value,重复选择同一文件不会再次触发 onchange。
1134                            // 这是 file input 的通用行为,封面场景影响可忽略。
1135                        },
1136                    }
1137                }
1138            }
1139        }
1140
1141        // —— URL 输入模式(内联展开,空态时叠加在容器外,避免与拖拽区争抢点击)——
1142        if cover_url_mode() && cover_image().is_empty() {
1143            div { class: "flex items-center gap-2 mt-2",
1144                FormInput {
1145                    r#type: "url",
1146                    placeholder: "粘贴图片链接...",
1147                    value: cover_url_input(),
1148                    class: INPUT_INLINE_CLASS,
1149                    oninput: move |v: String| cover_url_input.set(v),
1150                    onkeydown: move |evt: KeyboardEvent| {
1151                        if evt.key() == Key::Enter {
1152                            let v = cover_url_input().trim().to_string();
1153                            if !v.is_empty() {
1154                                cover_image.set(v);
1155                                cover_error.set(None);
1156                                cover_url_mode.set(false);
1157                            }
1158                        }
1159                    },
1160                }
1161                button {
1162                    class: "shrink-0 {BTN_PRIMARY_SM}",
1163                    onclick: move |_| {
1164                        let v = cover_url_input().trim().to_string();
1165                        if !v.is_empty() {
1166                            cover_image.set(v);
1167                            cover_error.set(None);
1168                            cover_url_mode.set(false);
1169                        }
1170                    },
1171                    "确认"
1172                }
1173                button {
1174                    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",
1175                    onclick: move |_| {
1176                        cover_url_mode.set(false);
1177                        cover_url_input.set(String::new());
1178                    },
1179                    "取消"
1180                }
1181            }
1182        }
1183
1184        // 封面上传失败提示:复用页面红色条风格。
1185        if let Some(err) = cover_error() {
1186            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",
1187                span { "封面图: {err}" }
1188                button {
1189                    class: "{BTN_CLOSE_ICON}",
1190                    aria_label: "关闭提示",
1191                    onclick: move |_| cover_error.set(None),
1192                    "×"
1193                }
1194            }
1195        }
1196
1197        // 素材选择 modal:选中回填封面 URL(上传新图成功同样回填)。
1198        AssetPickerModal {
1199            visible: picker_visible,
1200            cover_uploading,
1201            on_select: move |picks: Vec<AssetSelection>| {
1202                // 单选模式:载荷恰含一个元素。
1203                if let Some(first) = picks.into_iter().next() {
1204                    cover_image.set(first.url);
1205                    cover_error.set(None);
1206                }
1207            },
1208        }
1209    }
1210}