Skip to main content

yggdrasil/components/comments/
section.rs

1//! 评论区段组件
2//!
3//! 管理单篇文章的评论上下文(回复目标、刷新触发器、待审核评论),
4//! 负责加载评论列表、轮询待审核评论状态并渲染表单与列表。
5
6use dioxus::prelude::*;
7
8use crate::api::comments::{check_pending_status, get_comments, CommentTreeResponse};
9use crate::components::comments::form::CommentForm;
10use crate::components::comments::list::CommentList;
11use crate::components::skeletons::comment_skeleton::CommentListSkeleton;
12use crate::components::skeletons::delayed_skeleton::DelayedSkeleton;
13use crate::utils::comment_storage::{self, PendingComment};
14use crate::utils::time::sleep_ms;
15
16/// 待审核评论状态的轮询间隔(毫秒)。
17///
18/// 仅在本地存在待审核评论时才轮询;30s 在「审核通过后尽快反映」与「不触发 strict
19/// 限流(默认 1 req/s, burst 5)」之间取平衡。
20const PENDING_POLL_INTERVAL_MS: u32 = 30_000;
21
22/// 评论上下文,供评论相关组件共享状态。
23///
24/// 字段:
25/// - `active_reply`:当前正在回复的评论 ID
26/// - `refresh_trigger`:刷新触发信号,切换时触发评论列表重新加载
27/// - `pending_comments`:本地存储的待审核评论
28#[derive(Clone, Copy)]
29pub struct CommentContext {
30    /// 当前正在回复的评论 ID。
31    pub active_reply: Signal<Option<i64>>,
32    /// 刷新触发信号,切换时触发评论列表重新加载。
33    pub refresh_trigger: Signal<bool>,
34    /// 本地存储的待审核评论。
35    pub pending_comments: Signal<Vec<PendingComment>>,
36}
37
38/// 评论区段组件。
39///
40/// Props:
41/// - `post_id`:所属文章 ID
42///
43/// 负责:
44/// - 提供 `CommentContext` 上下文
45/// - 加载本地待审核评论并定期轮询其审核状态
46/// - 加载已审核评论列表并合并展示
47/// - 空评论时展示提示文案
48#[component]
49pub fn CommentSection(post_id: i32) -> Element {
50    let mut ctx = use_context_provider(|| CommentContext {
51        active_reply: Signal::new(None),
52        refresh_trigger: Signal::new(false),
53        pending_comments: Signal::new(Vec::new()),
54    });
55
56    // 挂载后从本地存储异步加载待审核评论以防 SSR Hydration Mismatch
57    use_effect(move || {
58        let pending = comment_storage::load_pending_comments(post_id);
59        comment_storage::prune_all_expired();
60        ctx.pending_comments.set(pending);
61    });
62
63    // 轮询待审核评论状态:只要本地还有待审核评论,就定期查询其审核状态。
64    //
65    // 必须用 use_resource 而非 use_future:use_future 不跟踪响应式依赖——闭包仅运行
66    // 一次,async 结束后即便依赖信号变化也不会重启(Dioxus 0.7.9 use_future 源码
67    // 证实其无 ReactiveContext)。上一版修复(8268546)误以为在同步段读取
68    // pending_comments 能让 use_future 自动重启,实际并不能:页面刷新时 use_effect
69    // 异步载入 localStorage 的 pending,而此时已 return 退出的 future 永不重启,
70    // 轮询彻底失效,「审核中」徽章永久残留(issue #9 回归)。use_resource 内置
71    // ReactiveContext,pending_comments 变化(提交 / 载入 / 本轮移除)时自动取消旧
72    // 任务并重启;无待审核评论时 return 退出,不给访客留常驻定时器。一旦某条评论
73    // 变为非 pending(通常已通过),就从本地移除并刷新已审核列表,使其以正式状态
74    // 进入评论树。
75    let _pending_poll = use_resource(move || {
76        let mut pending_comments = ctx.pending_comments;
77        let mut refresh_trigger = ctx.refresh_trigger;
78        async move {
79            loop {
80                let ids: Vec<i64> = pending_comments.read().iter().map(|c| c.id).collect();
81                if ids.is_empty() {
82                    // 无待审核评论:停止轮询。pending_comments 再变化时 use_resource 自动重启。
83                    return;
84                }
85
86                if let Ok(statuses) = check_pending_status(ids).await {
87                    let to_remove: Vec<i64> = statuses
88                        .into_iter()
89                        .filter(|s| s.status != "pending")
90                        .map(|s| s.id)
91                        .collect();
92                    if !to_remove.is_empty() {
93                        comment_storage::remove_pending_ids(post_id, &to_remove);
94                        // 评论状态已变化(多为已通过):刷新已审核列表。peek 不订阅信号,
95                        // 避免给本 resource 引入额外依赖;先取值再 set,规避借用冲突。
96                        let next = !*refresh_trigger.peek();
97                        refresh_trigger.set(next);
98                        pending_comments
99                            .write()
100                            .retain(|c| !to_remove.contains(&c.id));
101                    }
102                }
103                // Err(如限流)静默忽略,统一在下方 sleep 后下一轮重试。
104
105                sleep_ms(PENDING_POLL_INTERVAL_MS).await;
106            }
107        }
108    });
109
110    // 评论数据资源,refresh_trigger 变化时自动重新加载
111    let comments_resource = use_resource(move || {
112        let _ = (ctx.refresh_trigger)();
113        async move { get_comments(post_id).await }
114    });
115
116    // 本地去重兜底:已审核评论列表加载后,凡 id 已出现在已审核集合中的 pending
117    // 占位项立即移除。这是独立于上方轮询的确定性清理——不依赖 check_pending_status
118    // 远程调用成功(限流 / 网络失败时轮询无法移除占位项),只要 get_comments 返回了
119    // 已通过的评论,对应占位项就会被清除,根治「审核中」徽章残留(issue #9)。
120    use_effect(move || {
121        let data = comments_resource.read();
122        if let Some(Ok(CommentTreeResponse { comments, .. })) = &*data {
123            let approved_ids: std::collections::HashSet<i64> =
124                comments.iter().map(|c| c.id).collect();
125            let to_remove: Vec<i64> = ctx
126                .pending_comments
127                .read()
128                .iter()
129                .filter(|p| approved_ids.contains(&p.id))
130                .map(|p| p.id)
131                .collect();
132            if !to_remove.is_empty() {
133                comment_storage::remove_pending_ids(post_id, &to_remove);
134                ctx.pending_comments
135                    .write()
136                    .retain(|p| !to_remove.contains(&p.id));
137            }
138        }
139    });
140
141    let data = comments_resource.read();
142
143    // 动态计算总评论数(已审核 + 本地待审核)
144    let total_count = if let Some(Ok(CommentTreeResponse { count, .. })) = &*data {
145        let approved_count = *count;
146        let pending_count = ctx.pending_comments.read().len() as i64;
147        Some(approved_count + pending_count)
148    } else {
149        None
150    };
151
152    rsx! {
153        div { class: "space-y-8",
154            // 标题:加载中显示通用“评论区”,加载成功后附加数量
155            if let Some(count) = total_count {
156                h2 { class: "text-xl font-bold text-paper-primary", "评论区 ({count})" }
157            } else {
158                h2 { class: "text-xl font-bold text-paper-primary", "评论区" }
159            }
160
161            // 真实的评论输入表单始终立即可见且可交互,避免 CLS
162            CommentForm { post_id, parent_id: None, parent_indent: None }
163
164            // 根据数据状态渲染列表区、错误提示或骨架屏
165            match &*data {
166                Some(Ok(CommentTreeResponse { comments, .. })) => {
167                    let approved_count = comments.len();
168                    let pending_count = ctx.pending_comments.read().len();
169                    let has_any = approved_count > 0 || pending_count > 0;
170                    if !has_any {
171                        rsx! {
172                            p { class: "text-paper-tertiary text-center py-8", "暂无评论,成为第一个评论的人吧!" }
173                        }
174                    } else {
175                        rsx! {
176                            CommentList {
177                                comments: comments.clone(),
178                                pending: ctx.pending_comments.read().clone(),
179                                post_id,
180                            }
181                        }
182                    }
183                }
184                Some(Err(_)) => rsx! {
185                    div { class: "text-center text-red-500 dark:text-red-400 py-8", "评论加载失败" }
186                },
187                None => rsx! {
188                    DelayedSkeleton { CommentListSkeleton {} }
189                },
190            }
191        }
192    }
193}