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