Skip to main content

yggdrasil/components/comments/
section.rs

1//! 评论区段组件
2//!
3//! 管理单篇文章的评论上下文(回复目标、刷新触发器、待审核评论),
4//! 负责加载评论列表、轮询待审核评论状态并渲染表单与列表。
5
6use std::future::Future;
7use std::pin::Pin;
8
9use leptos::prelude::*;
10use leptos::task::spawn_local;
11
12use crate::api::comments::{
13    check_pending_status, create_comment, get_comments, CommentTreeResponse,
14};
15use crate::api::error::ApiError;
16use crate::components::comments::form::CommentForm;
17use crate::components::comments::list::CommentList;
18use crate::components::enhance_assets::LightboxAssets;
19use crate::components::mascot::Mascot;
20use crate::components::skeletons::comment_skeleton::CommentListSkeleton;
21use crate::components::skeletons::delayed_skeleton::DelayedSkeleton;
22
23use crate::models::comment::PublicComment;
24use crate::models::user::PublicUser;
25use crate::utils::comment_storage::{self, AuthorInfo, PendingComment};
26use crate::utils::time::sleep_ms;
27
28/// 待审核评论状态的轮询间隔(毫秒)。
29///
30/// 仅在本地存在待审核评论时才轮询;30s 在「审核通过后尽快反映」与「不触发 strict
31/// 限流(默认 1 req/s, burst 5)」之间取平衡。
32const PENDING_POLL_INTERVAL_MS: u32 = 30_000;
33
34/// 评论上下文,供评论相关组件共享状态。
35///
36/// 字段:
37/// - `active_reply`:当前正在回复的评论 ID
38/// - `refresh_trigger`:刷新触发信号,切换时触发评论列表重新加载
39/// - `pending_comments`:本地存储的待审核评论
40/// - `current_user`:当前登录用户(`None` 为匿名访客);登录后评论表单
41///   显示身份行、免作者信息字段,评论直发免审核
42#[derive(Clone, Copy)]
43pub struct CommentContext {
44    /// 当前正在回复的评论 ID。
45    pub active_reply: RwSignal<Option<i64>>,
46    /// 刷新触发信号,切换时触发评论列表重新加载。
47    pub refresh_trigger: RwSignal<bool>,
48    /// 本地存储的待审核评论。
49    pub pending_comments: RwSignal<Vec<PendingComment>>,
50    /// 当前登录用户;`None` 表示匿名(或尚未完成探测)。
51    pub current_user: RwSignal<Option<PublicUser>>,
52    /// 提交评论;生产走服务端并写本地待审核,图鉴只写内存。返回给读者的提示:Ok 成功、Err 失败。
53    pub submit: Callback<CommentSubmission, CommentSubmitFuture>,
54    /// 是否允许评论插图上传;关闭时隐藏上传按钮,编辑器不接管粘贴/拖放上传。
55    pub image_upload: bool,
56    /// DOM ID 前缀,区分同页多个评论区的表单。
57    pub id_scope: &'static str,
58}
59
60/// 表单交给 `CommentContext::submit` 的评论内容。
61pub struct CommentSubmission {
62    pub post_id: i32,
63    pub parent_id: Option<i64>,
64    /// 匿名访客;登录用户的身份由服务端从会话推导。
65    pub anonymous: bool,
66    pub author_name: String,
67    pub author_email: String,
68    pub author_url: String,
69    pub content_md: String,
70    pub honeypot: String,
71}
72
73/// 评论提交结果:Ok 为成功提示,Err 为失败提示。
74pub type CommentSubmitFuture = Pin<Box<dyn Future<Output = Result<String, String>>>>;
75
76/// 提交到服务端;匿名评论成功后保存作者信息并写入本地待审核列表。
77fn submit_to_server(
78    sub: CommentSubmission,
79    pending_comments: RwSignal<Vec<PendingComment>>,
80) -> CommentSubmitFuture {
81    Box::pin(async move {
82        let author_url = (!sub.author_url.trim().is_empty()).then(|| sub.author_url.clone());
83        let resp = create_comment(
84            sub.post_id,
85            sub.parent_id,
86            sub.author_name.clone(),
87            sub.author_email.clone(),
88            author_url.clone(),
89            sub.content_md.clone(),
90            sub.honeypot,
91        )
92        .await
93        .map_err(|_| "提交失败,请稍后重试".to_string())?;
94        if !resp.success {
95            return Err(resp.message);
96        }
97        // 登录评论直发 approved:不写本地待审核存储,列表刷新后即按正式状态展示。
98        if sub.anonymous {
99            comment_storage::save_author(&AuthorInfo {
100                name: sub.author_name.clone(),
101                email: sub.author_email,
102                url: sub.author_url,
103            });
104            if let Some(id) = resp.comment_id {
105                let now = chrono::Utc::now().to_rfc3339();
106                let pending = PendingComment {
107                    id,
108                    parent_id: sub.parent_id,
109                    depth: resp.depth.unwrap_or(0),
110                    author_name: sub.author_name,
111                    author_url,
112                    avatar_url: resp.avatar_url.unwrap_or_default(),
113                    content_md: sub.content_md,
114                    created_at: now.clone(),
115                    stored_at: now,
116                };
117                comment_storage::save_pending_comment(sub.post_id, pending.clone());
118                pending_comments.update(|list| list.push(pending));
119            }
120        }
121        Ok(resp.message)
122    })
123}
124
125/// 轮询待审核评论状态:只要本地还有待审核评论,就定期查询其审核状态。
126///
127/// 一旦某条评论变为非 pending(通常已通过),就从本地移除并刷新已审核列表,
128/// 使其以正式状态进入评论树。无待审核评论时退出并清除 `polling` 标记,
129/// 不给访客留常驻定时器;组件卸载(信号已释放)时同样退出。
130async fn poll_pending(
131    post_id: i32,
132    pending_comments: RwSignal<Vec<PendingComment>>,
133    refresh_trigger: RwSignal<bool>,
134    polling: RwSignal<bool>,
135) {
136    loop {
137        let Some(ids) = pending_comments
138            .try_with_untracked(|list| list.iter().map(|c| c.id).collect::<Vec<i64>>())
139        else {
140            // 组件已卸载。
141            return;
142        };
143        if ids.is_empty() {
144            // 无待审核评论:停止轮询。pending_comments 再变化时由 Effect 重新启动。
145            let _ = polling.try_set(false);
146            return;
147        }
148
149        if let Ok(statuses) = check_pending_status(ids).await {
150            let to_remove: Vec<i64> = statuses
151                .into_iter()
152                .filter(|s| s.status != "pending")
153                .map(|s| s.id)
154                .collect();
155            if !to_remove.is_empty() {
156                comment_storage::remove_pending_ids(post_id, &to_remove);
157                // 评论状态已变化(多为已通过):刷新已审核列表。
158                let _ = refresh_trigger.try_update(|v| *v = !*v);
159                let _ =
160                    pending_comments.try_update(|list| list.retain(|c| !to_remove.contains(&c.id)));
161            }
162        }
163        // Err(如限流)静默忽略,统一在下方 sleep 后下一轮重试。
164
165        sleep_ms(PENDING_POLL_INTERVAL_MS).await;
166    }
167}
168
169/// 评论区段组件。
170///
171/// Props:
172/// - `post_id`:所属文章 ID
173///
174/// 负责:
175/// - 提供 `CommentContext` 上下文
176/// - 加载本地待审核评论并定期轮询其审核状态
177/// - 加载已审核评论列表并合并展示(仅客户端加载,SSR 输出骨架屏)
178/// - 空评论时展示提示文案
179#[component]
180pub fn CommentSection(post_id: i32) -> impl IntoView {
181    let pending_comments = RwSignal::new(Vec::<PendingComment>::new());
182    let ctx = CommentContext {
183        active_reply: RwSignal::new(None),
184        refresh_trigger: RwSignal::new(false),
185        pending_comments,
186        current_user: RwSignal::new(None),
187        submit: Callback::new(move |sub: CommentSubmission| {
188            submit_to_server(sub, pending_comments)
189        }),
190        image_upload: true,
191        id_scope: "post",
192    };
193    provide_context(ctx);
194
195    // 挂载后从本地存储加载待审核评论以防 SSR Hydration Mismatch
196    Effect::new(move |_| {
197        let pending = comment_storage::load_pending_comments(post_id);
198        comment_storage::prune_all_expired();
199        ctx.pending_comments.set(pending);
200    });
201
202    // 探测登录态:登录用户的评论表单切换为身份行变体(免作者信息字段)。
203    // 刻意使用评论区自己的信号而非全局用户上下文,避免污染后台守卫语义。
204    Effect::new(move |_| {
205        spawn_local(async move {
206            if let Ok(resp) = crate::api::auth::get_current_user().await {
207                if let Some(u) = resp.user {
208                    let _ = ctx.current_user.try_set(Some(u));
209                }
210            }
211        });
212    });
213
214    // 待审核评论轮询:pending_comments 由空变非空(提交 / 载入)时启动单个轮询任务;
215    // `polling` 标记保证同一时刻只有一个轮询循环在跑。
216    let polling = RwSignal::new(false);
217    Effect::new(move |_| {
218        let has_pending = ctx.pending_comments.with(|list| !list.is_empty());
219        if has_pending && !polling.get_untracked() {
220            polling.set(true);
221            spawn_local(poll_pending(
222                post_id,
223                ctx.pending_comments,
224                ctx.refresh_trigger,
225                polling,
226            ));
227        }
228    });
229
230    // 评论数据,refresh_trigger 变化时重新加载(仅客户端,SSR 不请求)。
231    // 不用 LocalResource:Resource 读取会登记到外层 <Suspense>,每次刷新都让 Suspense
232    // 退回 fallback、把整段评论区(含正在输入的编辑器)移出再插回 DOM。这里由 Effect
233    // 直接写镜像信号;刷新期间保留旧数据,这样列表不会在刷新时闪回骨架屏。
234    let comments_resource: RwSignal<Option<Result<CommentTreeResponse, ApiError>>> =
235        RwSignal::new(None);
236    let fetch_generation = StoredValue::new(0u64);
237    Effect::new(move |_| {
238        ctx.refresh_trigger.track();
239        let Some(current) = fetch_generation.try_update_value(|g| {
240            *g += 1;
241            *g
242        }) else {
243            return;
244        };
245        spawn_local(async move {
246            let value = get_comments(post_id).await;
247            if fetch_generation.try_get_value() == Some(current) {
248                let _ = comments_resource.try_set(Some(value));
249            }
250        });
251    });
252
253    // 本地去重兜底:已审核评论列表加载后,凡 id 已出现在已审核集合中的 pending
254    // 占位项立即移除。这是独立于上方轮询的确定性清理——不依赖 check_pending_status
255    // 远程调用成功,只要 get_comments 返回了已通过的评论,对应占位项就会被清除。
256    Effect::new(move |_| {
257        let approved_ids: Option<std::collections::HashSet<i64>> =
258            comments_resource.with(|data| match data {
259                Some(Ok(resp)) => Some(resp.comments.iter().map(|c| c.id).collect()),
260                _ => None,
261            });
262        let Some(approved_ids) = approved_ids else {
263            return;
264        };
265        let to_remove: Vec<i64> = ctx.pending_comments.with(|list| {
266            list.iter()
267                .filter(|p| approved_ids.contains(&p.id))
268                .map(|p| p.id)
269                .collect()
270        });
271        if !to_remove.is_empty() {
272            comment_storage::remove_pending_ids(post_id, &to_remove);
273            ctx.pending_comments
274                .update(|list| list.retain(|p| !to_remove.contains(&p.id)));
275        }
276    });
277
278    // 灯箱绑定:评论图片(审核后渲染的 <img>)点击放大,与正文图一致。
279    // 根选择器只取正文 .comment-body,头像等评论外壳里的图片不进图集。
280    // 评论列表随加载/刷新重建节点后重跑此 effect 重新绑定,TS 端 data-lb-bound
281    // 守卫保证重复绑定幂等。下一帧执行,确保列表 DOM 已提交。
282    #[cfg(target_arch = "wasm32")]
283    Effect::new(move |_| {
284        let loaded = comments_resource.with(|data| matches!(data, Some(Ok(_))));
285        if !loaded {
286            return;
287        }
288        request_animation_frame(|| {
289            // 脚本未加载时入队,lightbox.js 尾部补执行。
290            crate::bridges::globals::init_lightbox(&[".comment-list .comment-body"]);
291        });
292    });
293
294    let comments = Memo::new(move |_| {
295        comments_resource.with(|data| match data {
296            Some(Ok(CommentTreeResponse { comments, .. })) => Some(comments.clone()),
297            _ => None,
298        })
299    });
300    let approved_count = Memo::new(move |_| {
301        comments_resource.with(|data| match data {
302            Some(Ok(CommentTreeResponse { count, .. })) => Some(*count),
303            _ => None,
304        })
305    });
306    let failed = Memo::new(move |_| comments_resource.with(|data| matches!(data, Some(Err(_)))));
307
308    view! {
309        // 评论区图片灯箱按需注入(脚本未就绪时迟到调用入队兜底)。
310        <LightboxAssets/>
311        <CommentSectionContent
312            post_id=post_id
313            comments=comments
314            approved_count=approved_count
315            failed=failed
316        />
317    }
318}
319
320/// 列表区的展示状态(只在状态切换时重建对应分支)。
321#[derive(Clone, Copy, PartialEq)]
322enum ListState {
323    /// 加载中:骨架屏。
324    Loading,
325    /// 加载失败:错误提示。
326    Failed,
327    /// 已加载但无任何评论。
328    Empty,
329    /// 已加载且有评论(含本地待审核)。
330    List,
331}
332
333/// 正式文章和图鉴共同使用的评论区展示;数据获取与持久化由上层负责。
334#[component]
335pub(crate) fn CommentSectionContent(
336    post_id: i32,
337    #[prop(into)] comments: Signal<Option<Vec<PublicComment>>>,
338    #[prop(into)] approved_count: Signal<Option<i64>>,
339    #[prop(into)] failed: Signal<bool>,
340) -> impl IntoView {
341    let ctx = expect_context::<CommentContext>();
342    let pending = ctx.pending_comments;
343    let total_count = move || {
344        approved_count
345            .get()
346            .map(|count| count + pending.with(|list| list.len()) as i64)
347    };
348
349    let list_state =
350        Memo::new(
351            move |_| match comments.with(|list| list.as_ref().map(|list| list.is_empty())) {
352                Some(true) if pending.with(|list| list.is_empty()) => ListState::Empty,
353                Some(_) => ListState::List,
354                None if failed.get() => ListState::Failed,
355                None => ListState::Loading,
356            },
357        );
358    let approved = Signal::derive(move || comments.get().unwrap_or_default());
359
360    view! {
361        <section class="comment-section" aria-labelledby="comments-title">
362            <div class="comment-section-head">
363                <h2 id="comments-title">
364                    "评论"
365                    {move || {
366                        total_count()
367                            .map(|count| {
368                                view! { <span class="comment-count tabular-nums">{count}</span> }
369                            })
370                    }}
371                </h2>
372                <span class="comment-order">"按时间排序"</span>
373            </div>
374
375            // 真实的评论输入表单始终立即可见且可交互,避免 CLS
376            <CommentForm post_id=post_id parent_id=None parent_indent=None/>
377
378            // 根据数据状态渲染列表区、错误提示或骨架屏
379            {move || match list_state.get() {
380                ListState::Empty => {
381                    view! {
382                        <p class="comment-empty has-mascot">
383                            {Mascot::Hearts.img("mascot comment-empty-mascot")}
384                            "还没有评论,来写下第一条吧。"
385                        </p>
386                    }
387                        .into_any()
388                }
389                ListState::List => {
390                    view! { <CommentList comments=approved pending=pending post_id=post_id/> }
391                        .into_any()
392                }
393                ListState::Failed => {
394                    view! {
395                        <p class="comment-empty is-error" role="alert">
396                            "评论暂时没能加载,刷新页面再试一次。"
397                        </p>
398                    }
399                        .into_any()
400                }
401                ListState::Loading => {
402                    view! {
403                        <DelayedSkeleton>
404                            <CommentListSkeleton/>
405                        </DelayedSkeleton>
406                    }
407                        .into_any()
408                }
409            }}
410        </section>
411    }
412}