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