Skip to main content

yggdrasil/components/comments/
form.rs

1//! 评论表单组件
2//!
3//! 提供发表评论与回复评论的表单,包含昵称、邮箱、网站、内容与反垃圾蜜罐字段。
4//! 内容编辑用 tiptap 所见即所得编辑器(comment variant——后台文章编辑器的
5//! 精简子集):选中浮出气泡菜单(B/I/S/code/link)、StarterKit 输入规则
6//! (`> `、`- `、``` 等)、数学公式、图片上传(点击按钮/粘贴/拖放均走
7//! coordinator 占位符上传,loading/error 态与后台完全一致)。
8
9use dioxus::prelude::*;
10
11use crate::api::comments::create_comment;
12use crate::bridges::library::{library_ready, use_browser_library, LibraryLoadError};
13use crate::bridges::tiptap::{UploadErrorEntry, UploadsInFlight};
14use crate::components::comments::section::{CommentContext, CommentSource};
15use crate::components::forms::{AlertBox, FormInput};
16use crate::components::ui::{UserAvatar, BTN_PRIMARY_SM, SPINNER_SVG};
17use crate::models::comment::PublicComment;
18use crate::utils::comment_storage::{self, AuthorInfo, PendingComment};
19#[cfg(target_arch = "wasm32")]
20use wasm_bindgen::closure::Closure;
21
22/// 图鉴提交仅生成内存中的待审核项,复用正式列表的合并和渲染路径。
23fn local_pending_comment(
24    approved: &[PublicComment],
25    existing: &[PendingComment],
26    parent_id: Option<i64>,
27    author_name: String,
28    author_url: String,
29    content_md: String,
30) -> PendingComment {
31    let parent_depth = parent_id.and_then(|id| {
32        approved
33            .iter()
34            .find(|comment| comment.id == id)
35            .map(|comment| comment.depth)
36            .or_else(|| {
37                existing
38                    .iter()
39                    .find(|comment| comment.id == id)
40                    .map(|comment| comment.depth)
41            })
42    });
43    let next_id = existing
44        .iter()
45        .map(|comment| comment.id)
46        .min()
47        .unwrap_or(0)
48        .min(0)
49        - 1;
50    let now = chrono::Utc::now().to_rfc3339();
51    PendingComment {
52        id: next_id,
53        parent_id,
54        depth: parent_depth.map_or(0, |depth| depth + 1),
55        author_name,
56        author_url: (!author_url.trim().is_empty()).then_some(author_url),
57        avatar_url: String::new(),
58        content_md,
59        created_at: now.clone(),
60        stored_at: now,
61    }
62}
63
64/// 评论表单组件,用于顶层评论或回复评论。
65///
66/// Props:
67/// - `post_id`:所属文章 ID
68/// - `parent_id`:回复目标评论 ID,`None` 表示顶层评论
69/// - `parent_indent`:回复时父评论的缩进像素值,用于用负 margin 把表单拉回内容区左边缘
70///
71/// 关键事件:
72/// - 挂载时从本地存储恢复上次填写的作者信息
73/// - 提交时校验必填项与蜜罐字段
74/// - 提交成功后清空内容、保存作者信息、添加待审核评论并触发列表刷新
75#[component]
76// 图片上传逻辑整体 cfg(target_arch = "wasm32") 门控:server 构建下
77// pending_uploads/upload_seq 的 mut、事件参数 e 均无实际用途。
78#[cfg_attr(not(target_arch = "wasm32"), allow(unused_mut, unused_variables))]
79pub fn CommentForm(post_id: i32, parent_id: Option<i64>, parent_indent: Option<i32>) -> Element {
80    let ctx: CommentContext = use_context();
81    let mut active_reply = ctx.active_reply;
82    let mut refresh_trigger = ctx.refresh_trigger;
83    let mut pending_comments = ctx.pending_comments;
84    let viewer = ctx.current_user;
85
86    let mut author_name = use_signal(String::new);
87    let mut author_email = use_signal(String::new);
88    let mut author_url = use_signal(String::new);
89    let mut content_md = use_signal(|| match ctx.source {
90        CommentSource::Production => String::new(),
91        CommentSource::Local { .. } => "这是一条只在当前预览中显示的评论。".to_string(),
92    });
93    let mut honeypot = use_signal(String::new);
94    let mut submitting = use_signal(|| false);
95    let mut message = use_signal(|| Option::<(String, &'static str)>::None);
96    let mut loaded = use_signal(|| false);
97    let editor_library = use_browser_library("tiptap", move || {
98        parent_id.is_none_or(|pid| active_reply() == Some(pid))
99    });
100    // 图片上传状态(tiptap coordinator 事件驱动,与后台 write.rs 同一类型):
101    // 进行中计数用于提交门控;失败条目由编辑器内错误态兜底(重试/移除)。
102    let uploads_in_flight = use_signal(UploadsInFlight::default);
103    let upload_errors: Signal<Vec<UploadErrorEntry>> = use_signal(Vec::new);
104
105    // 首次挂载时从本地存储加载作者信息
106    use_effect(move || {
107        if loaded() {
108            return;
109        }
110        loaded.set(true);
111        if matches!(ctx.source, CommentSource::Production) {
112            if let Some(info) = comment_storage::load_author() {
113                author_name.set(info.name);
114                author_email.set(info.email);
115                author_url.set(info.url);
116            }
117        }
118    });
119
120    // 回复表单:当前未激活回复时隐藏
121    if let Some(pid) = parent_id {
122        if active_reply() != Some(pid) {
123            return rsx! {};
124        }
125    }
126
127    let is_reply = parent_id.is_some();
128
129    // 用于区分顶层表单与多个回复表单的 id 后缀,保证页面内 label/for 关联唯一。
130    let id_suffix = parent_id.map_or_else(|| "root".to_string(), |pid| pid.to_string());
131    let id_suffix = match ctx.source {
132        CommentSource::Production => id_suffix,
133        CommentSource::Local { .. } => format!("{}-{id_suffix}", ctx.id_scope),
134    };
135    // 图片上传 file input 的 DOM id(label r#for 关联 + onchange 重置 value 共用)。
136    let image_input_dom_id = format!("comment-image-{id_suffix}");
137
138    // 回复表单抵消父评论缩进,让表单回到内容区左边缘,避免深层回复时被越挤越右。
139    let negative_margin = match (is_reply, parent_indent) {
140        (true, Some(px)) if px > 0 => format!("margin-left: -{px}px;"),
141        _ => String::new(),
142    };
143
144    // tiptap 编辑器挂载容器 id(顶层表单与多个回复表单并存,按 id_suffix 隔离)。
145    let editor_dom_id = format!("comment-editor-{id_suffix}");
146
147    // 编辑器实例句柄(WASM 端):持有 JS 实例与全部 closure,drop 时销毁。
148    #[cfg(target_arch = "wasm32")]
149    let mut editor_handle: Signal<Option<crate::bridges::tiptap::EditorHandle>> =
150        use_signal(|| None);
151
152    // 挂载/销毁编辑器。顶层表单立即挂载;回复表单随 active_reply 激活挂载、
153    // 取消时销毁(容器 div 随 rsx 早退消失,JS 实例必须同步回收)。
154    // content_md 在组件存活期内持久,重挂载时经 set_markdown 回填(取消不丢草稿)。
155    #[cfg(target_arch = "wasm32")]
156    let editor_dom_id_for_mount = editor_dom_id.clone();
157    #[cfg(target_arch = "wasm32")]
158    use_effect(move || {
159        let editor_dom_id = editor_dom_id_for_mount.clone();
160        let active = match parent_id {
161            Some(pid) => active_reply() == Some(pid),
162            None => true,
163        };
164        if !active {
165            // 隐藏:销毁已挂载的编辑器(handle drop → JS destroy + closure 释放)。
166            if editor_handle.peek().is_some() {
167                editor_handle.set(None);
168            }
169            return;
170        }
171        // 防重复挂载(effect 可能因订阅的信号多次触发)。
172        if editor_handle.peek().is_some() {
173            return;
174        }
175        if !library_ready(editor_library) {
176            return;
177        }
178
179        // 用 FnMut:Dioxus Signal 的 set 接收 &mut self,回调需可变借用捕获的 signal。
180        let on_update = Closure::new({
181            let mut content_md = content_md;
182            move |md: String| content_md.set(md)
183        });
184        let on_ready = Closure::new(|| {});
185        let on_image_upload = match ctx.source {
186            CommentSource::Production => crate::bridges::tiptap::make_comment_upload_closure(),
187            CommentSource::Local { .. } => Closure::new(|_file: web_sys::File| {
188                js_sys::Promise::reject(&"本地演示不上传图片".into())
189            }),
190        };
191        let on_upload_event = Closure::new({
192            let mut message = message;
193            move |ev: crate::bridges::tiptap::UploadEventJs| {
194                // 失败时借表单 AlertBox 同步一行错误(编辑器内错误态为主,
195                // 这里兜底可见性);成功/移除时若当前消息恰是上传错误则清除。
196                match ev.kind().as_str() {
197                    "error" => {
198                        let msg = ev.error_msg().unwrap_or_else(|| "上传失败".to_string());
199                        message.set(Some((format!("图片上传失败:{msg}"), "error")));
200                    }
201                    "success" | "removed"
202                        if message
203                            .peek()
204                            .as_ref()
205                            .is_some_and(|(m, _)| m.starts_with("图片上传失败")) =>
206                    {
207                        message.set(None);
208                    }
209                    "success" | "removed" => {}
210                    _ => {}
211                }
212                crate::bridges::tiptap::consume_upload_event(&ev, uploads_in_flight, upload_errors);
213            }
214        });
215
216        let opts = crate::bridges::tiptap::EditorOptions::new();
217        opts.set_variant("comment");
218        opts.set_placeholder(if is_reply {
219            "写下你的回复..."
220        } else {
221            "写下你的想法..."
222        });
223        opts.set_on_update(&on_update);
224        opts.set_on_ready(&on_ready);
225        if matches!(ctx.source, CommentSource::Production) {
226            opts.set_on_image_upload(&on_image_upload);
227        }
228        opts.set_on_upload_event(&on_upload_event);
229
230        // create 同步返回;找不到容器返回 None(rsx 早退时容器不在 DOM)。
231        match crate::bridges::tiptap::get_module().create(&editor_dom_id, &opts) {
232            Ok(Some(inst)) => {
233                // 草稿回填:回复表单取消再激活时恢复 content_md(组件未卸载,
234                // signal 持久)。顶层表单为空字符串时 no-op。
235                let draft = content_md.peek().clone();
236                if !draft.is_empty() {
237                    inst.set_markdown(&draft);
238                }
239                let handle = crate::bridges::tiptap::EditorHandle::new_comment(
240                    inst,
241                    on_update,
242                    on_image_upload,
243                    on_ready,
244                    on_upload_event,
245                );
246                editor_handle.set(Some(handle));
247            }
248            Ok(None) => {
249                web_sys::console::warn_1(&format!("评论编辑器容器未找到: #{editor_dom_id}").into());
250            }
251            Err(e) => {
252                message.set(Some((format!("编辑器初始化失败: {e:?}"), "error")));
253            }
254        }
255    });
256
257    let mut do_submit = move || {
258        if submitting() || !library_ready(editor_library) {
259            return;
260        }
261
262        let post_id = post_id;
263        let parent_id = parent_id;
264        let is_anon = viewer().is_none();
265        // 登录用户的身份字段由服务端从会话推导,表单值仅匿名路径使用。
266        let (name, email, url_val) = if is_anon {
267            (author_name(), author_email(), author_url())
268        } else {
269            (String::new(), String::new(), String::new())
270        };
271        let content = content_md();
272        let hp = honeypot();
273
274        // 图片上传未完成拦截:与后台 write.rs 的保存拦截同语义——占位节点
275        // 未落定前提交会丢图/留下 blob 半成品。
276        let in_flight = uploads_in_flight();
277        if in_flight.uploading > 0 || in_flight.error > 0 {
278            let msg = if in_flight.uploading > 0 {
279                format!(
280                    "有 {} 张图片正在上传,请等待完成后再发表",
281                    in_flight.uploading
282                )
283            } else {
284                format!(
285                    "有 {} 张图片上传失败,请重试或移除后再发表",
286                    in_flight.error
287                )
288            };
289            message.set(Some((msg, "error")));
290            return;
291        }
292        // 防御:markdown 中检出 blob 图片 URL(异常路径,如上传事件丢失)时拦截。
293        // (与 write.rs 的 blob: 检出同款兜底。)
294        if content.contains("](blob:") {
295            message.set(Some((
296                "检测到未完成上传的图片,请处理后再发表".to_string(),
297                "error",
298            )));
299            return;
300        }
301
302        // 蜜罐被填充则直接丢弃
303        if !hp.is_empty() {
304            return;
305        }
306
307        if content.trim().is_empty() {
308            message.set(Some(("请填写评论内容".to_string(), "error")));
309            return;
310        }
311        if is_anon && (name.trim().is_empty() || email.trim().is_empty()) {
312            message.set(Some(("请填写昵称和邮箱".to_string(), "error")));
313            return;
314        }
315        submitting.set(true);
316        message.set(None);
317        if let CommentSource::Local { approved } = ctx.source {
318            let pending = local_pending_comment(
319                &approved.read(),
320                &pending_comments.read(),
321                parent_id,
322                name,
323                url_val,
324                content,
325            );
326            pending_comments.write().push(pending);
327            content_md.set(String::new());
328            #[cfg(target_arch = "wasm32")]
329            if let Some(handle) = &*editor_handle.peek() {
330                handle.instance().set_markdown("");
331            }
332            submitting.set(false);
333            message.set(Some((
334                "已加入本地演示列表,内容不会保存".to_string(),
335                "success",
336            )));
337            if parent_id.is_some() {
338                active_reply.set(None);
339            }
340            return;
341        }
342        spawn(async move {
343            let result = create_comment(
344                post_id,
345                parent_id,
346                name.clone(),
347                email.clone(),
348                if url_val.trim().is_empty() {
349                    None
350                } else {
351                    Some(url_val.clone())
352                },
353                content.clone(),
354                hp.clone(),
355            )
356            .await;
357            submitting.set(false);
358            match result {
359                Ok(resp) => {
360                    if resp.success {
361                        // 登录评论直发 approved:不写本地待审核
362                        // 存储,列表刷新后即按正式状态展示。
363                        if is_anon {
364                            comment_storage::save_author(&AuthorInfo {
365                                name: name.clone(),
366                                email: email.clone(),
367                                url: url_val.clone(),
368                            });
369                            if let Some(comment_id) = resp.comment_id {
370                                let avatar_url = resp.avatar_url.unwrap_or_default();
371                                let depth = resp.depth.unwrap_or(0);
372                                let now = chrono::Utc::now().to_rfc3339();
373                                let pending = PendingComment {
374                                    id: comment_id,
375                                    parent_id,
376                                    depth,
377                                    author_name: name.clone(),
378                                    author_url: if url_val.trim().is_empty() {
379                                        None
380                                    } else {
381                                        Some(url_val)
382                                    },
383                                    avatar_url,
384                                    content_md: content,
385                                    created_at: now.clone(),
386                                    stored_at: now,
387                                };
388                                comment_storage::save_pending_comment(post_id, pending.clone());
389                                pending_comments.write().push(pending);
390                            }
391                        }
392                        content_md.set(String::new());
393                        // 同步清空编辑器文档(onUpdate 会把空 markdown 回写 signal)。
394                        #[cfg(target_arch = "wasm32")]
395                        if let Some(handle) = &*editor_handle.peek() {
396                            handle.instance().set_markdown("");
397                        }
398                        message.set(Some((resp.message, "success")));
399                        if parent_id.is_some() {
400                            active_reply.set(None);
401                        }
402                        refresh_trigger.set(!refresh_trigger());
403                    } else {
404                        message.set(Some((resp.message, "error")));
405                    }
406                }
407                Err(_) => {
408                    message.set(Some(("提交失败,请稍后重试".to_string(), "error")));
409                }
410            }
411        });
412    };
413
414    rsx! {
415        div {
416            class: if is_reply { "mt-3" } else { "" },
417            style: "{negative_margin}",
418            role: "form",
419            aria_label: if is_reply { "回复评论" } else { "发表评论" },
420            // Ctrl/Cmd+Enter 提交:编辑器内按键事件冒泡到表单根(ProseMirror
421            // 不消费 Mod-Enter),身份输入栏同样生效。
422            onkeydown: move |e: KeyboardEvent| {
423                if (e.modifiers().ctrl() || e.modifiers().meta()) && e.key() == Key::Enter {
424                    do_submit();
425                }
426            },
427
428            if let Some((msg, variant)) = message() {
429                div { class: "mb-3", aria_live: "polite",
430                    AlertBox { message: msg, variant }
431                }
432            }
433            LibraryLoadError { library: editor_library }
434
435            // 一体化聚焦卡片容器 (All-in-One Focus Card)
436            div { class: "rounded-2xl bg-[var(--color-paper-entry)] border border-[var(--color-paper-border)]/60 shadow-xs focus-within:border-[var(--color-paper-accent)]/60 focus-within:ring-2 focus-within:ring-[var(--color-paper-accent)]/20 transition-all duration-200 overflow-hidden",
437                // 头部区域:登录用户身份行或访客轻量三栏输入行
438                if let Some(user) = viewer() {
439                    {
440                        let label = user.display_label().to_string();
441                        let action = if is_reply { "回复" } else { "发表评论" };
442                        rsx! {
443                            div { class: "flex items-center justify-between px-4 py-2.5 bg-[var(--color-paper-theme)]/40 border-b border-[var(--color-paper-border)]/40 text-xs text-paper-secondary",
444                                div { class: "flex items-center gap-2.5",
445                                    UserAvatar {
446                                        name: label.clone(),
447                                        avatar_url: user.avatar_url.clone(),
448                                        class: "w-6 h-6 rounded-full text-xs ring-1 ring-[var(--color-paper-border)]/60 shrink-0",
449                                    }
450                                    span { class: "text-paper-secondary",
451                                        "以 "
452                                        span { class: "font-semibold text-paper-primary", "{label}" }
453                                        " 的身份{action}"
454                                    }
455                                    span { class: "hidden sm:inline-flex items-center px-1.5 py-0.5 rounded-full text-[10px] font-medium bg-[var(--color-paper-accent)]/15 text-[var(--color-paper-accent)]",
456                                        "已登录"
457                                    }
458                                }
459                                div { class: "flex items-center gap-1 text-paper-tertiary",
460                                    span { class: "hidden sm:inline text-[11px]", "Markdown 语法就绪" }
461                                }
462                            }
463                        }
464                    }
465                } else {
466                    div { class: "grid grid-cols-1 sm:grid-cols-3 divide-y sm:divide-y-0 sm:divide-x divide-[var(--color-paper-border)]/40 bg-[var(--color-paper-theme)]/30 border-b border-[var(--color-paper-border)]/40",
467                        div { class: "relative",
468                            FormInput {
469                                id: Some(format!("comment-name-{id_suffix}")),
470                                r#type: "text",
471                                placeholder: "昵称 *",
472                                // FormInput 无 aria_label prop:借 title 承载原 aria-label 文案
473                                // (原生 placeholder 亦可作无障碍名回退)。
474                                title: Some("昵称"),
475                                value: author_name(),
476                                disabled: submitting(),
477                                class: Some("w-full px-3.5 py-2 bg-transparent text-sm text-paper-primary placeholder:text-paper-tertiary focus:outline-none focus:bg-[var(--color-paper-entry)]/60 transition-colors"),
478                                oninput: move |v| author_name.set(v),
479                            }
480                        }
481                        div { class: "relative",
482                            FormInput {
483                                id: Some(format!("comment-email-{id_suffix}")),
484                                r#type: "email",
485                                placeholder: "邮箱 * (保密)",
486                                title: Some("邮箱"),
487                                value: author_email(),
488                                disabled: submitting(),
489                                class: Some("w-full px-3.5 py-2 bg-transparent text-sm text-paper-primary placeholder:text-paper-tertiary focus:outline-none focus:bg-[var(--color-paper-entry)]/60 transition-colors"),
490                                oninput: move |v| author_email.set(v),
491                            }
492                        }
493                        div { class: "relative",
494                            FormInput {
495                                id: Some(format!("comment-url-{id_suffix}")),
496                                r#type: "url",
497                                placeholder: "网站 (https://,可选)",
498                                title: Some("网站"),
499                                value: author_url(),
500                                disabled: submitting(),
501                                class: Some("w-full px-3.5 py-2 bg-transparent text-sm text-paper-primary placeholder:text-paper-tertiary focus:outline-none focus:bg-[var(--color-paper-entry)]/60 transition-colors"),
502                                oninput: move |v| author_url.set(v),
503                            }
504                        }
505                    }
506                }
507
508                // 编辑区 (Textarea)
509                // 编辑区(WYSIWYG:tiptap comment variant)。SSR 输出空容器,
510                // hydration 后由编辑器接管;min-height 与编辑器对齐避免 CLS。
511                div { class: "relative bg-transparent",
512                    div {
513                        id: "{editor_dom_id}",
514                        class: "comment-editor-mount min-h-[96px]",
515                    }
516                    if !library_ready(editor_library) {
517                        span { class: "absolute top-4 left-4 text-sm text-[var(--color-paper-tertiary)]", "正在加载编辑器…" }
518                    }
519                    img {
520                        src: "/images/xiantiaoxiaogou_input_bg.webp",
521                        alt: "",
522                        class: "absolute bottom-2 right-2 w-20 sm:w-24 opacity-15 dark:opacity-20 pointer-events-none select-none z-0",
523                    }
524                }
525
526                // 蜜罐字段:对普通用户隐藏,用于拦截简单机器人(仅匿名渲染;
527                // 登录用户的身份由会话保证,服务端也跳过蜜罐校验)。
528                if viewer().is_none() {
529                    textarea {
530                        class: "hidden",
531                        aria_hidden: "true",
532                        tabindex: "-1",
533                        value: "{honeypot}",
534                        oninput: move |e| honeypot.set(e.value()),
535                    }
536                }
537
538                // 底部操作与快捷栏 (Action Toolbar)
539                div { class: "flex items-center justify-between px-3.5 py-2.5 bg-[var(--color-paper-theme)]/40 border-t border-[var(--color-paper-border)]/40 text-xs",
540                    // 左侧:图片上传(点击选择 / 直接粘贴图片到输入框)
541                    div { class: "flex items-center gap-1.5 text-paper-tertiary",
542                        // label + 隐藏 file input:点击天然触发文件选择对话框,无需 JS。
543                        if matches!(ctx.source, CommentSource::Production) {
544                        label {
545                            r#for: "{image_input_dom_id}",
546                            class: "p-1.5 rounded-md hover:text-paper-primary hover:bg-[var(--color-paper-entry)] transition-colors cursor-pointer",
547                            title: "上传图片(也可直接粘贴到输入框)",
548                            aria_label: "上传图片",
549                            svg {
550                                class: "w-3.5 h-3.5",
551                                fill: "currentColor",
552                                view_box: "0 -960 960 960",
553                                path { d: "M180-120q-24 0-42-18t-18-42v-600q0-24 18-42t42-18h600q24 0 42 18t18 42v600q0 24-18 42t-42 18H180Zm0-60h600v-600H180v600Zm56-97h489L578-473 446-302l-93-127-117 152Zm-56 97v-600 600Z" }
554                            }
555                        }
556                        input {
557                            id: "{image_input_dom_id}",
558                            class: "hidden",
559                            r#type: "file",
560                            accept: "image/jpeg,image/png,image/gif,image/webp",
561                            multiple: true,
562                            onchange: {
563                                // cfg 门控使 server 构建(无 editor_handle 绑定)闭包为空捕获。
564                                #[cfg(target_arch = "wasm32")]
565                                let input_dom_id = image_input_dom_id.clone();
566                                move |e| {
567                                    #[cfg(target_arch = "wasm32")]
568                                    {
569                                        use dioxus::web::WebFileExt;
570                                        // 与粘贴/拖放同一条 coordinator 占位上传路径。
571                                        for f in e.files() {
572                                            if let Some(web_file) = f.get_web_file() {
573                                                if let Some(handle) = &*editor_handle.peek() {
574                                                    handle.instance().insert_uploading(web_file);
575                                                }
576                                            }
577                                        }
578                                        // 重置 value:连续选择同一文件也能再次触发 change。
579                                        if let Some(el) = web_sys::window()
580                                            .and_then(|w| w.document())
581                                            .and_then(|d| d.get_element_by_id(&input_dom_id))
582                                        {
583                                            use wasm_bindgen::JsCast;
584                                            if let Some(input) = el.dyn_ref::<web_sys::HtmlInputElement>() {
585                                                input.set_value("");
586                                            }
587                                        }
588                                    }
589                                }
590                            },
591                        }
592                        }
593                        // 上传中指示(与后台 tiptap 的「上传中…」遮罩同文案)。
594                        if uploads_in_flight().uploading > 0 {
595                            span { class: "inline-flex items-center gap-1.5 text-[11px] text-paper-secondary ml-1",
596                                span { class: "inline-block", dangerous_inner_html: "{SPINNER_SVG}" }
597                                "图片上传中…"
598                            }
599                        }
600                    }
601
602                    // 右侧:快捷键提示 + 取消(若为回复)+ 提交按钮
603                    div { class: "flex items-center gap-2",
604                        span { class: "hidden sm:inline-block text-[11px] text-paper-tertiary font-mono mr-1", "Ctrl + ↵" }
605                        if is_reply {
606                            button {
607                                r#type: "button",
608                                class: "px-3 py-1.5 text-xs text-paper-secondary hover:text-paper-primary hover:bg-[var(--color-paper-entry)] rounded-full transition-colors cursor-pointer",
609                                onclick: move |_| active_reply.set(None),
610                                "取消"
611                            }
612                        }
613                        button {
614                            r#type: "button",
615                            class: "{BTN_PRIMARY_SM}",
616                            // 提交中或图片上传未完成(含失败态)均禁用:占位节点未落定前
617                            // 提交会丢图或残留 blob URL。
618                            class: if submitting() || uploads_in_flight().uploading > 0 || uploads_in_flight().error > 0 { "opacity-60 cursor-not-allowed pointer-events-none" } else { "" },
619                            disabled: !library_ready(editor_library) || submitting() || uploads_in_flight().uploading > 0 || uploads_in_flight().error > 0,
620                            onclick: move |_| {
621                                do_submit();
622                            },
623                            if submitting() {
624                                span { class: "inline-flex items-center gap-1.5",
625                                    span { class: "inline-block", dangerous_inner_html: "{SPINNER_SVG}" }
626                                    "提交中…"
627                                }
628                            } else if is_reply {
629                                "回复"
630                            } else {
631                                "发表评论"
632                            }
633                        }
634                    }
635                }
636            }
637        }
638    }
639}
640
641#[cfg(test)]
642mod tests {
643    use super::*;
644
645    #[test]
646    fn local_reply_uses_parent_depth_and_unique_negative_id() {
647        let parent = PendingComment {
648            id: -10,
649            parent_id: None,
650            depth: 1,
651            author_name: "样例".to_string(),
652            author_url: None,
653            avatar_url: String::new(),
654            content_md: "原评论".to_string(),
655            created_at: "2026-01-01T00:00:00Z".to_string(),
656            stored_at: "2026-01-01T00:00:00Z".to_string(),
657        };
658        let reply = local_pending_comment(
659            &[],
660            &[parent],
661            Some(-10),
662            "访客".to_string(),
663            String::new(),
664            "回复内容".to_string(),
665        );
666        assert_eq!(
667            (reply.id, reply.parent_id, reply.depth),
668            (-11, Some(-10), 2)
669        );
670        assert_eq!(reply.author_url, None);
671        assert_eq!(reply.content_md, "回复内容");
672
673        let approved = PublicComment {
674            id: 101,
675            parent_id: None,
676            depth: 0,
677            author_name: "作者".to_string(),
678            author_url: None,
679            avatar_url: String::new(),
680            is_author: true,
681            content_html: Some("<p>原评论</p>".to_string()),
682            created_at: "昨天".to_string(),
683            created_at_iso: "2026-01-01T00:00:00Z".to_string(),
684        };
685        let reply = local_pending_comment(
686            &[approved],
687            &[],
688            Some(101),
689            "访客".to_string(),
690            String::new(),
691            "回复".to_string(),
692        );
693        assert_eq!((reply.id, reply.parent_id, reply.depth), (-1, Some(101), 1));
694    }
695}