Skip to main content

yggdrasil/components/comments/
form.rs

1//! 评论表单组件
2//!
3//! 提供发表评论与回复评论的表单,包含昵称、邮箱、网站、内容与反垃圾蜜罐字段。
4
5use dioxus::prelude::*;
6
7use crate::api::comments::create_comment;
8use crate::components::comments::section::CommentContext;
9use crate::components::forms::{AlertBox, INPUT_CLASS};
10use crate::utils::comment_storage::{self, PendingComment};
11
12/// 评论提交按钮样式:去掉全宽,改为内联宽度并右对齐。
13///
14/// 与 `BUTTON_PRIMARY_CLASS` 视觉一致,但不含 `w-full`,并把 `px-4` 加宽为 `px-6`,
15/// 使按钮宽度跟随文字、更适合文章页内联场景。
16const COMMENT_SUBMIT_CLASS: &str = "py-2.5 px-6 bg-paper-accent text-white font-medium rounded-full hover:brightness-110 active:scale-[0.98] transition-all duration-200 cursor-pointer";
17
18/// 评论表单组件,用于顶层评论或回复评论。
19///
20/// Props:
21/// - `post_id`:所属文章 ID
22/// - `parent_id`:回复目标评论 ID,`None` 表示顶层评论
23/// - `parent_indent`:回复时父评论的缩进像素值,用于用负 margin 把表单拉回内容区左边缘
24///
25/// 关键事件:
26/// - 挂载时从本地存储恢复上次填写的作者信息
27/// - 提交时校验必填项与蜜罐字段
28/// - 提交成功后清空内容、保存作者信息、添加待审核评论并触发列表刷新
29#[component]
30pub fn CommentForm(post_id: i32, parent_id: Option<i64>, parent_indent: Option<i32>) -> Element {
31    let ctx: CommentContext = use_context();
32    let mut active_reply = ctx.active_reply;
33    let mut refresh_trigger = ctx.refresh_trigger;
34    let mut pending_comments = ctx.pending_comments;
35
36    let mut author_name = use_signal(String::new);
37    let mut author_email = use_signal(String::new);
38    let mut author_url = use_signal(String::new);
39    let mut content_md = use_signal(String::new);
40    let mut honeypot = use_signal(String::new);
41    let mut submitting = use_signal(|| false);
42    let mut message = use_signal(|| Option::<(String, &'static str)>::None);
43    let mut loaded = use_signal(|| false);
44
45    // 首次挂载时从本地存储加载作者信息
46    use_effect(move || {
47        if loaded() {
48            return;
49        }
50        loaded.set(true);
51        if let Some(info) = comment_storage::load_author() {
52            author_name.set(info.name);
53            author_email.set(info.email);
54            author_url.set(info.url);
55        }
56    });
57
58    // 回复表单:当前未激活回复时隐藏
59    if let Some(pid) = parent_id {
60        if active_reply() != Some(pid) {
61            return rsx! {};
62        }
63    }
64
65    let is_reply = parent_id.is_some();
66
67    // 用于区分顶层表单与多个回复表单的 id 后缀,保证页面内 label/for 关联唯一。
68    let id_suffix = match parent_id {
69        Some(pid) => pid.to_string(),
70        None => "root".to_string(),
71    };
72
73    // 回复表单抵消父评论缩进,让表单回到内容区左边缘,避免深层回复时被越挤越右。
74    let negative_margin = match (is_reply, parent_indent) {
75        (true, Some(px)) if px > 0 => format!("margin-left: -{px}px;"),
76        _ => String::new(),
77    };
78
79    rsx! {
80        div {
81            class: if is_reply { "mt-3 pt-3 border-t border-gray-100 dark:border-gray-700" } else { "" },
82            style: "{negative_margin}",
83            role: "form",
84            aria_label: if is_reply { "回复评论" } else { "发表评论" },
85
86            if let Some((msg, variant)) = message() {
87                div { aria_live: "polite",
88                    AlertBox { message: msg, variant }
89                }
90            }
91
92            div { class: "space-y-3",
93                div { class: "grid grid-cols-1 sm:grid-cols-2 gap-3",
94                    div {
95                        label {
96                            r#for: "comment-name-{id_suffix}",
97                            class: "block text-sm font-medium text-paper-secondary mb-1",
98                            "昵称 *"
99                        }
100                        input {
101                            id: "comment-name-{id_suffix}",
102                            class: INPUT_CLASS,
103                            r#type: "text",
104                            placeholder: "你的昵称",
105                            value: "{author_name}",
106                            disabled: submitting(),
107                            oninput: move |e| author_name.set(e.value()),
108                        }
109                    }
110                    div {
111                        label {
112                            r#for: "comment-email-{id_suffix}",
113                            class: "block text-sm font-medium text-paper-secondary mb-1",
114                            "邮箱 *"
115                        }
116                        input {
117                            id: "comment-email-{id_suffix}",
118                            class: INPUT_CLASS,
119                            r#type: "email",
120                            placeholder: "your@email.com",
121                            value: "{author_email}",
122                            disabled: submitting(),
123                            oninput: move |e| author_email.set(e.value()),
124                        }
125                    }
126                }
127                div {
128                    label {
129                        r#for: "comment-url-{id_suffix}",
130                        class: "block text-sm font-medium text-paper-secondary mb-1",
131                        "网站"
132                    }
133                    input {
134                        id: "comment-url-{id_suffix}",
135                        class: INPUT_CLASS,
136                        r#type: "url",
137                        placeholder: "https://example.com(可选)",
138                        value: "{author_url}",
139                        disabled: submitting(),
140                        oninput: move |e| author_url.set(e.value()),
141                    }
142                }
143
144                div {
145                    label {
146                        r#for: "comment-content-{id_suffix}",
147                        class: "block text-sm font-medium text-paper-secondary mb-1",
148                        "内容 *"
149                    }
150                    div { class: "relative bg-paper-entry rounded-lg",
151                        textarea {
152                            id: "comment-content-{id_suffix}",
153                            class: "{INPUT_CLASS} !bg-transparent relative z-10 peer block min-h-[100px] resize-y",
154                            value: "{content_md}",
155                            disabled: submitting(),
156                            oninput: move |e| content_md.set(e.value()),
157                        }
158                        img {
159                            src: "/images/xiantiaoxiaogou_input_bg.webp",
160                            alt: "",
161                            class: "absolute bottom-1.5 right-1.5 w-24 opacity-10 pointer-events-none z-0",
162                        }
163                    }
164                }
165
166                p { class: "text-xs text-paper-tertiary", "支持 Markdown 语法" }
167
168                // 蜜罐字段:对普通用户隐藏,用于拦截简单机器人
169                textarea {
170                    class: "hidden",
171                    aria_hidden: "true",
172                    tabindex: "-1",
173                    value: "{honeypot}",
174                    oninput: move |e| honeypot.set(e.value()),
175                }
176
177                div { class: "flex justify-end",
178                    button {
179                        class: COMMENT_SUBMIT_CLASS,
180                        disabled: submitting(),
181                        onclick: move |_| {
182                            if submitting() {
183                                return;
184                            }
185
186                            let post_id = post_id;
187                            let parent_id = parent_id;
188                            let name = author_name();
189                            let email = author_email();
190                            let url_val = author_url();
191                            let content = content_md();
192                            let hp = honeypot();
193
194                            // 蜜罐被填充则直接丢弃
195                            if !hp.is_empty() {
196                                return;
197                            }
198
199                            if name.trim().is_empty() || email.trim().is_empty() || content.trim().is_empty()
200                            {
201                                message.set(Some(("请填写所有必填项".to_string(), "error")));
202                                return;
203                            }
204                            submitting.set(true);
205                            message.set(None);
206                            spawn(async move {
207                                let result = create_comment(
208                                        post_id,
209                                        parent_id,
210                                        name.clone(),
211                                        email.clone(),
212                                        if url_val.trim().is_empty() { None } else { Some(url_val.clone()) },
213                                        content.clone(),
214                                        hp.clone(),
215                                    )
216                                    .await;
217                                submitting.set(false);
218                                match result {
219                                    Ok(resp) => {
220                                        if resp.success {
221                                            comment_storage::save_author(&name, &email, &url_val);
222                                            if let Some(comment_id) = resp.comment_id {
223                                                let avatar_url = resp.avatar_url.unwrap_or_default();
224                                                let depth = resp.depth.unwrap_or(0);
225                                                let now = chrono::Utc::now().to_rfc3339();
226                                                let pending = PendingComment {
227                                                    id: comment_id,
228                                                    parent_id,
229                                                    depth,
230                                                    author_name: name.clone(),
231                                                    author_url: if url_val.trim().is_empty() {
232                                                        None
233                                                    } else {
234                                                        Some(url_val)
235                                                    },
236                                                    avatar_url,
237                                                    content_md: content,
238                                                    created_at: now.clone(),
239                                                    stored_at: now,
240                                                };
241                                                comment_storage::save_pending_comment(
242                                                    post_id,
243                                                    pending.clone(),
244                                                );
245                                                pending_comments.write().push(pending);
246                                            }
247                                            content_md.set(String::new());
248                                            message.set(Some((resp.message, "success")));
249                                            if parent_id.is_some() {
250                                                active_reply.set(None);
251                                            }
252                                            refresh_trigger.set(!refresh_trigger());
253                                        } else {
254                                            message.set(Some((resp.message, "error")));
255                                        }
256                                    }
257                                    Err(_) => {
258                                        message
259                                            .set(
260                                                Some(("提交失败,请稍后重试".to_string(), "error")),
261                                            );
262                                    }
263                                }
264                            });
265                        },
266
267                        if submitting() {
268                            "提交中…"
269                        } else if is_reply {
270                            "回复"
271                        } else {
272                            "发表评论"
273                        }
274                    }
275                }
276            }
277        }
278    }
279}