xfy 10ef52bede
Some checks failed
CI / check (push) Failing after 5m20s
CI / build (push) Has been skipped
fix(comment): harden comment form with server honeypot, a11y labels, and reply layout
四项评论区输入框修复:

1. 蜜罐服务端二次校验
   - helpers.rs 新增 validate_comment_honeypot 校验器 + 单元测试
   - create_comment 加 honeypot 参数,在限流后做服务端校验
   - 即便机器人禁用 JS 绕过前端拦截,服务端仍返回 spam_detected 错误
   - 与 rate limit 共同构成纵深防御

2. label/input 关联 (可访问性)
   - 四个字段补 id + r#for 属性,用 id_suffix(顶层 root/回复用 parent_id)保证页面唯一
   - textarea 原本无 label,补上"内容 *"label

3. 提交按钮右对齐 + 宽度自适应
   - 新常量 COMMENT_SUBMIT_CLASS 去掉 w-full、px-4 改为 px-6
   - 外层包 flex justify-end,按钮跟随文字宽度
   - login/register 的全宽按钮保持不变(模态卡片场景)

4. 深层回复表单回到内容区左边缘
   - CommentForm 新增 parent_indent prop,回复时用负 margin 抵消父评论缩进
   - 避免 depth 越深表单被越挤越右

验证: cargo test (376 passed) / cargo clippy --all-targets (无警告) / dx check (无问题)
2026-06-22 13:45:28 +08:00

127 lines
4.6 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! 评论区段组件
//!
//! 管理单篇文章的评论上下文(回复目标、刷新触发器、待审核评论),
//! 负责加载评论列表、轮询待审核评论状态并渲染表单与列表。
use dioxus::prelude::*;
use crate::api::comments::{check_pending_status, get_comments, CommentTreeResponse};
use crate::components::comments::form::CommentForm;
use crate::components::comments::list::CommentList;
use crate::components::skeletons::comment_skeleton::CommentListSkeleton;
use crate::hooks::comment_storage::{self, PendingComment};
/// 评论上下文,供评论相关组件共享状态。
///
/// 字段:
/// - `active_reply`:当前正在回复的评论 ID
/// - `refresh_trigger`:刷新触发信号,切换时触发评论列表重新加载
/// - `pending_comments`:本地存储的待审核评论
#[derive(Clone, Copy)]
pub struct CommentContext {
/// 当前正在回复的评论 ID。
pub active_reply: Signal<Option<i64>>,
/// 刷新触发信号,切换时触发评论列表重新加载。
pub refresh_trigger: Signal<bool>,
/// 本地存储的待审核评论。
pub pending_comments: Signal<Vec<PendingComment>>,
}
/// 评论区段组件。
///
/// Props
/// - `post_id`:所属文章 ID
///
/// 负责:
/// - 提供 `CommentContext` 上下文
/// - 加载本地待审核评论并定期轮询其审核状态
/// - 加载已审核评论列表并合并展示
/// - 空评论时展示提示文案
#[component]
pub fn CommentSection(post_id: i32) -> Element {
let ctx = use_context_provider(|| {
let pending: Vec<PendingComment> = comment_storage::load_pending_comments(post_id);
comment_storage::prune_all_expired();
CommentContext {
active_reply: Signal::new(None),
refresh_trigger: Signal::new(false),
pending_comments: Signal::new(pending),
}
});
// 轮询待审核评论状态,已处理(非 pending的评论从本地移除
use_future(move || {
let mut pending = ctx.pending_comments;
async move {
let ids: Vec<i64> = pending().iter().map(|c| c.id).collect();
if ids.is_empty() {
return;
}
match check_pending_status(ids).await {
Ok(statuses) => {
let to_remove: Vec<i64> = statuses
.into_iter()
.filter(|s| s.status != "pending")
.map(|s| s.id)
.collect();
if !to_remove.is_empty() {
comment_storage::remove_pending_ids(post_id, &to_remove);
pending.write().retain(|c| !to_remove.contains(&c.id));
}
}
Err(_e) => {
// 在 WASM 环境下静默忽略,服务器端日志不可用
}
}
}
});
// 评论数据资源refresh_trigger 变化时自动重新加载
let comments_resource = use_resource(move || async move {
let _ = (ctx.refresh_trigger)();
get_comments(post_id).await
});
let data = comments_resource.read();
// 根据加载结果渲染评论区、错误提示或骨架屏
match &*data {
Some(Ok(CommentTreeResponse { comments, count })) => {
let approved_count = *count;
let pending_count = ctx.pending_comments.read().len() as i64;
let total_count = approved_count + pending_count;
let has_any = approved_count > 0 || pending_count > 0;
rsx! {
div { class: "space-y-8",
h2 { class: "text-xl font-bold text-paper-primary",
"评论区 ({total_count})"
}
CommentForm { post_id, parent_id: None, parent_indent: None }
if !has_any {
p { class: "text-paper-tertiary text-center py-8",
"暂无评论,成为第一个评论的人吧!"
}
} else {
CommentList {
comments: comments.clone(),
pending: ctx.pending_comments.read().clone(),
post_id,
}
}
}
}
}
Some(Err(_)) => {
rsx! {
div { class: "text-center text-red-500 dark:text-red-400 py-8",
"评论加载失败"
}
}
}
None => rsx! { CommentListSkeleton {} },
}
}