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