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