//! 评论表单组件 //! //! 提供发表评论与回复评论的表单,包含昵称、邮箱、网站、内容与反垃圾蜜罐字段。 use dioxus::prelude::*; use crate::api::comments::create_comment; use crate::components::comments::section::CommentContext; use crate::components::forms::{AlertBox, INPUT_CLASS}; use crate::hooks::comment_storage::{self, PendingComment}; /// 评论提交按钮样式:去掉全宽,改为内联宽度并右对齐。 /// /// 与 `BUTTON_PRIMARY_CLASS` 视觉一致,但不含 `w-full`,并把 `px-4` 加宽为 `px-6`, /// 使按钮宽度跟随文字、更适合文章页内联场景。 const 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"; /// 评论表单组件,用于顶层评论或回复评论。 /// /// Props: /// - `post_id`:所属文章 ID /// - `parent_id`:回复目标评论 ID,`None` 表示顶层评论 /// - `parent_indent`:回复时父评论的缩进像素值,用于用负 margin 把表单拉回内容区左边缘 /// /// 关键事件: /// - 挂载时从本地存储恢复上次填写的作者信息 /// - 提交时校验必填项与蜜罐字段 /// - 提交成功后清空内容、保存作者信息、添加待审核评论并触发列表刷新 #[component] pub fn CommentForm(post_id: i32, parent_id: Option, parent_indent: Option) -> Element { let ctx: CommentContext = use_context(); let mut active_reply = ctx.active_reply; let mut refresh_trigger = ctx.refresh_trigger; let mut pending_comments = ctx.pending_comments; let mut author_name = use_signal(String::new); let mut author_email = use_signal(String::new); let mut author_url = use_signal(String::new); let mut content_md = use_signal(String::new); let mut honeypot = use_signal(String::new); let mut submitting = use_signal(|| false); let mut message = use_signal(|| Option::<(String, &'static str)>::None); let mut loaded = use_signal(|| false); // 首次挂载时从本地存储加载作者信息 use_effect(move || { if loaded() { return; } loaded.set(true); if let Some(info) = comment_storage::load_author() { author_name.set(info.name); author_email.set(info.email); author_url.set(info.url); } }); // 回复表单:当前未激活回复时隐藏 if let Some(pid) = parent_id { if active_reply() != Some(pid) { return rsx! {}; } } let is_reply = parent_id.is_some(); // 用于区分顶层表单与多个回复表单的 id 后缀,保证页面内 label/for 关联唯一。 let id_suffix = match parent_id { Some(pid) => pid.to_string(), None => "root".to_string(), }; // 回复表单抵消父评论缩进,让表单回到内容区左边缘,避免深层回复时被越挤越右。 let negative_margin = match (is_reply, parent_indent) { (true, Some(px)) if px > 0 => format!("margin-left: -{px}px;"), _ => String::new(), }; rsx! { div { class: if is_reply { "mt-3 pt-3 border-t border-gray-100 dark:border-[#333]" } else { "" }, style: "{negative_margin}", role: "form", aria_label: if is_reply { "回复评论" } else { "发表评论" }, if let Some((msg, variant)) = message() { div { aria_live: "polite", AlertBox { message: msg, variant } } } div { class: "space-y-3", div { class: "grid grid-cols-1 sm:grid-cols-2 gap-3", div { label { r#for: "comment-name-{id_suffix}", class: "block text-sm font-medium text-paper-secondary mb-1", "昵称 *" } input { id: "comment-name-{id_suffix}", class: INPUT_CLASS, r#type: "text", placeholder: "你的昵称", value: "{author_name}", disabled: submitting(), oninput: move |e| author_name.set(e.value()), } } div { label { r#for: "comment-email-{id_suffix}", class: "block text-sm font-medium text-paper-secondary mb-1", "邮箱 *" } input { id: "comment-email-{id_suffix}", class: INPUT_CLASS, r#type: "email", placeholder: "your@email.com", value: "{author_email}", disabled: submitting(), oninput: move |e| author_email.set(e.value()), } } } div { label { r#for: "comment-url-{id_suffix}", class: "block text-sm font-medium text-paper-secondary mb-1", "网站" } input { id: "comment-url-{id_suffix}", class: INPUT_CLASS, r#type: "url", placeholder: "https://example.com(可选)", value: "{author_url}", disabled: submitting(), oninput: move |e| author_url.set(e.value()), } } div { label { r#for: "comment-content-{id_suffix}", class: "block text-sm font-medium text-paper-secondary mb-1", "内容 *" } div { class: "relative", textarea { id: "comment-content-{id_suffix}", class: "{INPUT_CLASS} peer min-h-[100px] resize-y", value: "{content_md}", disabled: submitting(), oninput: move |e| content_md.set(e.value()), } img { src: "/images/xiantiaoxiaogou_03.webp", alt: "", class: "absolute bottom-2 right-2 w-24 pointer-events-none opacity-60 peer-focus:opacity-10 transition-opacity duration-300", } } } p { class: "text-xs text-paper-tertiary", "支持 Markdown 语法" } // 蜜罐字段:对普通用户隐藏,用于拦截简单机器人 textarea { class: "hidden", aria_hidden: "true", tabindex: "-1", value: "{honeypot}", oninput: move |e| honeypot.set(e.value()), } div { class: "flex justify-end", button { class: COMMENT_SUBMIT_CLASS, disabled: submitting(), onclick: move |_| { if submitting() { return; } let post_id = post_id; let parent_id = parent_id; let name = author_name(); let email = author_email(); let url_val = author_url(); let content = content_md(); let hp = honeypot(); // 蜜罐被填充则直接丢弃 if !hp.is_empty() { return; } if name.trim().is_empty() || email.trim().is_empty() || content.trim().is_empty() { message.set(Some(("请填写所有必填项".to_string(), "error"))); return; } submitting.set(true); message.set(None); spawn(async move { let result = create_comment( post_id, parent_id, name.clone(), email.clone(), if url_val.trim().is_empty() { None } else { Some(url_val.clone()) }, content.clone(), hp.clone(), ) .await; submitting.set(false); match result { Ok(resp) => { if resp.success { comment_storage::save_author(&name, &email, &url_val); if let Some(comment_id) = resp.comment_id { let avatar_url = resp.avatar_url.unwrap_or_default(); let depth = resp.depth.unwrap_or(0); let now = chrono::Utc::now().to_rfc3339(); let pending = PendingComment { id: comment_id, parent_id, depth, author_name: name.clone(), author_url: if url_val.trim().is_empty() { None } else { Some(url_val) }, avatar_url, content_md: content, created_at: now.clone(), stored_at: now, }; comment_storage::save_pending_comment( post_id, pending.clone(), ); pending_comments.write().push(pending); } content_md.set(String::new()); message.set(Some((resp.message, "success"))); if parent_id.is_some() { active_reply.set(None); } refresh_trigger.set(!refresh_trigger()); } else { message.set(Some((resp.message, "error"))); } } Err(_) => { message .set( Some(("提交失败,请稍后重试".to_string(), "error")), ); } } }); }, if submitting() { "提交中…" } else if is_reply { "回复" } else { "发表评论" } } } } } } }