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/// - `current_user`:当前登录用户(`None` 为匿名访客);登录后评论表单
29///   显示身份行、免作者信息字段,评论直发免审核
30#[derive(Clone, Copy)]
31pub struct CommentContext {
32    /// 当前正在回复的评论 ID。
33    pub active_reply: Signal<Option<i64>>,
34    /// 刷新触发信号,切换时触发评论列表重新加载。
35    pub refresh_trigger: Signal<bool>,
36    /// 本地存储的待审核评论。
37    pub pending_comments: Signal<Vec<PendingComment>>,
38    /// 当前登录用户;`None` 表示匿名(或尚未完成探测)。
39    pub current_user: Signal<Option<crate::models::user::PublicUser>>,
40}
41
42/// 评论区段组件。
43///
44/// Props:
45/// - `post_id`:所属文章 ID
46///
47/// 负责:
48/// - 提供 `CommentContext` 上下文
49/// - 加载本地待审核评论并定期轮询其审核状态
50/// - 加载已审核评论列表并合并展示
51/// - 空评论时展示提示文案
52#[component]
53pub fn CommentSection(post_id: i32) -> Element {
54    let mut ctx = use_context_provider(|| CommentContext {
55        active_reply: Signal::new(None),
56        refresh_trigger: Signal::new(false),
57        pending_comments: Signal::new(Vec::new()),
58        current_user: Signal::new(None),
59    });
60
61    // 挂载后从本地存储异步加载待审核评论以防 SSR Hydration Mismatch
62    use_effect(move || {
63        let pending = comment_storage::load_pending_comments(post_id);
64        comment_storage::prune_all_expired();
65        ctx.pending_comments.set(pending);
66    });
67
68    // 探测登录态:登录用户的评论表单切换为身份行变体(免作者信息字段)。
69    // 刻意使用评论区自己的信号而非全局 UserContext——AdminLayout 的守卫以
70    // 「checked=true 且 user=None」表示已确认未登录并跳转登录页,在前台探测
71    // 会污染该语义(匿名访客此后进入 /admin 将永远卡在骨架屏)。
72    use_effect(move || {
73        spawn(async move {
74            if let Ok(resp) = crate::api::auth::get_current_user().await {
75                if let Some(u) = resp.user {
76                    ctx.current_user.set(Some(u));
77                }
78            }
79        });
80    });
81
82    // 轮询待审核评论状态:只要本地还有待审核评论,就定期查询其审核状态。
83    //
84    // 必须用 use_resource 而非 use_future:use_future 不跟踪响应式依赖——闭包仅运行
85    // 一次,async 结束后即便依赖信号变化也不会重启(Dioxus 0.7.10 use_future 源码
86    // 证实其无 ReactiveContext)。上一版修复(8268546)误以为在同步段读取
87    // pending_comments 能让 use_future 自动重启,实际并不能:页面刷新时 use_effect
88    // 异步载入 localStorage 的 pending,而此时已 return 退出的 future 永不重启,
89    // 轮询彻底失效,「审核中」徽章永久残留(issue #9 回归)。use_resource 内置
90    // ReactiveContext,pending_comments 变化(提交 / 载入 / 本轮移除)时自动取消旧
91    // 任务并重启;无待审核评论时 return 退出,不给访客留常驻定时器。一旦某条评论
92    // 变为非 pending(通常已通过),就从本地移除并刷新已审核列表,使其以正式状态
93    // 进入评论树。
94    let _pending_poll = use_resource(move || {
95        let mut pending_comments = ctx.pending_comments;
96        let mut refresh_trigger = ctx.refresh_trigger;
97        async move {
98            loop {
99                let ids: Vec<i64> = pending_comments.read().iter().map(|c| c.id).collect();
100                if ids.is_empty() {
101                    // 无待审核评论:停止轮询。pending_comments 再变化时 use_resource 自动重启。
102                    return;
103                }
104
105                if let Ok(statuses) = check_pending_status(ids).await {
106                    let to_remove: Vec<i64> = statuses
107                        .into_iter()
108                        .filter(|s| s.status != "pending")
109                        .map(|s| s.id)
110                        .collect();
111                    if !to_remove.is_empty() {
112                        comment_storage::remove_pending_ids(post_id, &to_remove);
113                        // 评论状态已变化(多为已通过):刷新已审核列表。peek 不订阅信号,
114                        // 避免给本 resource 引入额外依赖;先取值再 set,规避借用冲突。
115                        let next = !*refresh_trigger.peek();
116                        refresh_trigger.set(next);
117                        pending_comments
118                            .write()
119                            .retain(|c| !to_remove.contains(&c.id));
120                    }
121                }
122                // Err(如限流)静默忽略,统一在下方 sleep 后下一轮重试。
123
124                sleep_ms(PENDING_POLL_INTERVAL_MS).await;
125            }
126        }
127    });
128
129    // 评论数据资源,refresh_trigger 变化时自动重新加载
130    let comments_resource = use_resource(move || {
131        let _ = (ctx.refresh_trigger)();
132        async move { get_comments(post_id).await }
133    });
134
135    // 本地去重兜底:已审核评论列表加载后,凡 id 已出现在已审核集合中的 pending
136    // 占位项立即移除。这是独立于上方轮询的确定性清理——不依赖 check_pending_status
137    // 远程调用成功(限流 / 网络失败时轮询无法移除占位项),只要 get_comments 返回了
138    // 已通过的评论,对应占位项就会被清除,根治「审核中」徽章残留(issue #9)。
139    use_effect(move || {
140        let data = comments_resource.read();
141        if let Some(Ok(CommentTreeResponse { comments, .. })) = &*data {
142            let approved_ids: std::collections::HashSet<i64> =
143                comments.iter().map(|c| c.id).collect();
144            let to_remove: Vec<i64> = ctx
145                .pending_comments
146                .read()
147                .iter()
148                .filter(|p| approved_ids.contains(&p.id))
149                .map(|p| p.id)
150                .collect();
151            if !to_remove.is_empty() {
152                comment_storage::remove_pending_ids(post_id, &to_remove);
153                ctx.pending_comments
154                    .write()
155                    .retain(|p| !to_remove.contains(&p.id));
156            }
157        }
158    });
159
160    // 灯箱绑定:评论图片(审核后渲染的 <img>)点击放大,与正文图一致。
161    // lightbox.js 由 Dioxus.toml 全局注入;评论列表随加载/刷新重建节点后重跑此
162    // effect 重新绑定,TS 端 data-lb-bound 守卫保证重复绑定幂等(同 assets.rs 模式)。
163    #[cfg(target_arch = "wasm32")]
164    use_effect(move || {
165        // 订阅 comments_resource:数据落地(DOM 提交后)重绑。
166        let data = comments_resource.read();
167        if !matches!(&*data, Some(Ok(_))) {
168            return;
169        }
170        let window = web_sys::window()
171            .expect("CommentSection use_effect 仅在 WASM 浏览器上下文执行:无 window");
172        let sel: wasm_bindgen::JsValue = ".comment-list".into();
173        // 合并而非覆盖 __lightboxSelectors:PostContent 已把 .post-content /
174        // .entry-cover 写入同一全局;覆盖会让 lightbox.js 晚加载时的 IIFE 自启动
175        // 丢掉正文图绑定。缺数组成员时按新数组处理。
176        let existing =
177            js_sys::Reflect::get(&window, &"__lightboxSelectors".into()).unwrap_or_default();
178        let arr = if existing.is_array() {
179            js_sys::Array::from(&existing)
180        } else {
181            js_sys::Array::new()
182        };
183        if !arr.includes(&sel, 0) {
184            arr.push(&sel);
185        }
186        let selectors_val = js_sys::Object::from(arr).into();
187        let _ = js_sys::Reflect::set(&window, &"__lightboxSelectors".into(), &selectors_val);
188        // 显式绑定评论区(重复调用由 TS 端守卫幂等);脚本未加载时 no-op,
189        // 由自启动读取上方合并后的配置兜底。
190        let call_arg = js_sys::Array::of1(&sel);
191        crate::utils::js::invoke_optional_global(&window, "__initLightbox", &[call_arg.into()]);
192    });
193
194    let data = comments_resource.read();
195
196    // 动态计算总评论数(已审核 + 本地待审核)
197    let total_count = if let Some(Ok(CommentTreeResponse { count, .. })) = &*data {
198        let approved_count = *count;
199        let pending_count = ctx.pending_comments.read().len() as i64;
200        Some(approved_count + pending_count)
201    } else {
202        None
203    };
204
205    rsx! {
206        div { class: "space-y-6",
207            // 标题栏:精致图标 + 评论区 + 数量徽章
208            div { class: "flex items-center justify-between",
209                div { class: "flex items-center gap-2.5",
210                    svg {
211                        class: "w-5 h-5 text-[var(--color-paper-accent)]",
212                        fill: "none",
213                        stroke: "currentColor",
214                        stroke_width: "2",
215                        view_box: "0 0 24 24",
216                        path {
217                            stroke_linecap: "round",
218                            stroke_linejoin: "round",
219                            d: "M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z",
220                        }
221                    }
222                    h2 { class: "text-lg font-bold text-paper-primary tracking-tight", "评论区" }
223                    if let Some(count) = total_count {
224                        span { class: "px-2 py-0.5 rounded-full text-xs font-semibold bg-[var(--color-paper-accent)]/15 text-[var(--color-paper-accent)]",
225                            "{count}"
226                        }
227                    }
228                }
229            }
230
231            // 真实的评论输入表单始终立即可见且可交互,避免 CLS
232            CommentForm { post_id, parent_id: None, parent_indent: None }
233
234            // 根据数据状态渲染列表区、错误提示或骨架屏
235            match &*data {
236                Some(Ok(CommentTreeResponse { comments, .. })) => {
237                    let approved_count = comments.len();
238                    let pending_count = ctx.pending_comments.read().len();
239                    let has_any = approved_count > 0 || pending_count > 0;
240                    if !has_any {
241                        rsx! {
242                            div { class: "text-center py-10 px-4 rounded-2xl bg-[var(--color-paper-entry)]/40 border border-dashed border-[var(--color-paper-border)]/60 my-4",
243                                p { class: "text-sm text-paper-secondary font-medium", "暂无评论" }
244                                p { class: "text-xs text-paper-tertiary mt-1", "成为第一个分享想法的人吧!" }
245                            }
246                        }
247                    } else {
248                        rsx! {
249                            CommentList {
250                                comments: comments.clone(),
251                                pending: ctx.pending_comments.read().clone(),
252                                post_id,
253                            }
254                        }
255                    }
256                }
257                Some(Err(_)) => rsx! {
258                    div { class: "text-center text-red-500 dark:text-red-400 py-8 text-sm", "评论加载失败,请刷新重试" }
259                },
260                None => rsx! {
261                    DelayedSkeleton { CommentListSkeleton {} }
262                },
263            }
264        }
265    }
266}