Skip to main content

yggdrasil/pages/components_showcase/
comment_previews.rs

1//! 评论组件图鉴:固定样例与局部上下文,展示节点仍由正式评论组件渲染。
2
3use leptos::prelude::*;
4
5use crate::components::comments::card::CommentCardShell;
6use crate::components::comments::form::CommentForm;
7use crate::components::comments::item::CommentItem;
8use crate::components::comments::list::CommentList;
9use crate::components::comments::pending_item::PendingCommentItem;
10use crate::components::comments::section::{
11    CommentContext, CommentSectionContent, CommentSubmission, CommentSubmitFuture,
12};
13use crate::models::comment::PublicComment;
14use crate::utils::comment_storage::PendingComment;
15
16const MODE_ACTIVE_CLASS: &str = "px-3 py-1.5 rounded-full border border-clay-text text-xs font-medium text-clay-text bg-clay-text/10 transition-colors cursor-pointer";
17const MODE_IDLE_CLASS: &str = "px-3 py-1.5 rounded-full border border-ink-3 text-xs text-fg-2 hover:text-fg hover:border-ink-3/80 transition-colors cursor-pointer";
18const ACTION_CLASS: &str =
19    "px-3 py-1.5 rounded-full border border-ink-3 text-xs text-fg-2 hover:text-fg";
20const PANEL_CLASS: &str = "rounded-2xl border border-ink-3/70 bg-surface-2/30 p-4 sm:p-5 shadow-xs";
21
22#[cfg(target_arch = "wasm32")]
23struct VisibleObserver {
24    observer: web_sys::IntersectionObserver,
25    _callback:
26        wasm_bindgen::closure::Closure<dyn FnMut(js_sys::Array, web_sys::IntersectionObserver)>,
27}
28
29#[cfg(target_arch = "wasm32")]
30impl Drop for VisibleObserver {
31    fn drop(&mut self) {
32        self.observer.disconnect();
33    }
34}
35
36pub(super) fn preview(slug: &str, detail: bool) -> Option<AnyView> {
37    match slug {
38        "comment-card-shell" => Some(view! { <CommentCardShellPreview detail=detail/> }.into_any()),
39        "comment-item" => Some(view! { <CommentItemPreview detail=detail/> }.into_any()),
40        "comment-list" => Some(view! { <CommentListPreview detail=detail/> }.into_any()),
41        "pending-comment-item" | "comment-form" | "comment-section" => {
42            Some(view! { <CommentPreview slug=slug.to_string() detail=detail/> }.into_any())
43        }
44        _ => None,
45    }
46}
47
48/// 固定样例评论的公共字段;其余字段由调用处用结构体更新语法覆盖。
49fn base_comment(
50    id: i64,
51    parent_id: Option<i64>,
52    depth: i32,
53    author: &str,
54    html: &str,
55) -> PublicComment {
56    PublicComment {
57        id,
58        parent_id,
59        depth,
60        author_name: author.to_string(),
61        author_url: None,
62        avatar_url: String::new(),
63        is_author: false,
64        content_html: Some(html.to_string()),
65        created_at: String::new(),
66        created_at_iso: String::new(),
67    }
68}
69
70fn approved_sample() -> Vec<PublicComment> {
71    [
72        (
73            101,
74            None,
75            0,
76            "青禾",
77            "春天的第一片叶子,总会记得来时的风。",
78            "昨天 17:30",
79        ),
80        (102, Some(101), 1, "阿木", "我也喜欢这句话。", "昨天 18:02"),
81        (
82            103,
83            Some(102),
84            2,
85            "青禾",
86            "谢谢,愿你也有好天气。",
87            "今天 09:15",
88        ),
89    ]
90    .into_iter()
91    .map(
92        |(id, parent_id, depth, author_name, content, created_at)| PublicComment {
93            id,
94            parent_id,
95            depth,
96            author_name: author_name.to_string(),
97            author_url: None,
98            avatar_url: String::new(),
99            is_author: id != 102,
100            content_html: Some(format!("<p>{content}</p>")),
101            created_at: created_at.to_string(),
102            created_at_iso: format!("2026-09-22T{:02}:00:00Z", 10 + depth),
103        },
104    )
105    .collect()
106}
107
108fn pending_sample() -> PendingComment {
109    PendingComment {
110        id: -10,
111        parent_id: Some(102),
112        depth: 2,
113        author_name: "叶子访客".to_string(),
114        author_url: None,
115        avatar_url: String::new(),
116        content_md: "这条样例评论正在等待审核。".to_string(),
117        created_at: "2026-01-01T08:00:00Z".to_string(),
118        stored_at: "2026-01-01T08:00:00Z".to_string(),
119    }
120}
121
122fn initial_approved(slug: &str, detail: bool) -> Vec<PublicComment> {
123    match slug {
124        "comment-section" if !detail => approved_sample().into_iter().take(1).collect(),
125        "comment-form" | "pending-comment-item" => Vec::new(),
126        _ => approved_sample(),
127    }
128}
129
130fn initial_pending(slug: &str, detail: bool) -> Vec<PendingComment> {
131    match slug {
132        "comment-section" if detail => vec![pending_sample()],
133        _ => Vec::new(),
134    }
135}
136
137fn scope(slug: &str) -> &'static str {
138    match slug {
139        "pending-comment-item" => "showcase-pending-comment-item",
140        "comment-form" => "showcase-comment-form",
141        _ => "showcase-comment-section",
142    }
143}
144
145/// 图鉴提交仅生成内存中的待审核项,复用正式列表的合并和渲染路径。
146fn local_pending_comment(
147    approved: &[PublicComment],
148    existing: &[PendingComment],
149    parent_id: Option<i64>,
150    author_name: String,
151    author_url: String,
152    content_md: String,
153) -> PendingComment {
154    let parent_depth = parent_id.and_then(|id| {
155        approved
156            .iter()
157            .find(|comment| comment.id == id)
158            .map(|comment| comment.depth)
159            .or_else(|| {
160                existing
161                    .iter()
162                    .find(|comment| comment.id == id)
163                    .map(|comment| comment.depth)
164            })
165    });
166    let next_id = existing
167        .iter()
168        .map(|comment| comment.id)
169        .min()
170        .unwrap_or(0)
171        .min(0)
172        - 1;
173    let now = chrono::Utc::now().to_rfc3339();
174    PendingComment {
175        id: next_id,
176        parent_id,
177        depth: parent_depth.map_or(0, |depth| depth + 1),
178        author_name,
179        author_url: (!author_url.trim().is_empty()).then_some(author_url),
180        avatar_url: String::new(),
181        content_md,
182        created_at: now.clone(),
183        stored_at: now,
184    }
185}
186
187/// 图鉴提交:只写入当前预览的内存列表,不请求、不持久化。
188fn local_submit(
189    approved: RwSignal<Vec<PublicComment>>,
190    pending: RwSignal<Vec<PendingComment>>,
191) -> Callback<CommentSubmission, CommentSubmitFuture> {
192    Callback::new(move |s: CommentSubmission| -> CommentSubmitFuture {
193        let comment = approved.with_untracked(|approved| {
194            pending.with_untracked(|existing| {
195                local_pending_comment(
196                    approved,
197                    existing,
198                    s.parent_id,
199                    s.author_name,
200                    s.author_url,
201                    s.content_md,
202                )
203            })
204        });
205        let _ = pending.try_update(|list| list.push(comment));
206        Box::pin(std::future::ready(Ok(
207            "已加入本地演示列表,内容不会保存".to_string()
208        )))
209    })
210}
211
212/// 图鉴用的本地评论上下文:不触达评论接口、不持久化、不允许上传图片。
213fn provide_local_context(
214    active_reply: RwSignal<Option<i64>>,
215    approved: RwSignal<Vec<PublicComment>>,
216    pending: RwSignal<Vec<PendingComment>>,
217    id_scope: &'static str,
218) {
219    provide_context(CommentContext {
220        active_reply,
221        refresh_trigger: RwSignal::new(false),
222        pending_comments: pending,
223        current_user: RwSignal::new(None),
224        submit: local_submit(approved, pending),
225        image_upload: false,
226        id_scope,
227    });
228}
229
230/// 形态切换按钮组;点击先写入 `mode`,再回调 `on_pick` 处理附带状态。
231fn mode_buttons<M>(
232    mode: RwSignal<M>,
233    items: Vec<(M, &'static str)>,
234    on_pick: Callback<M>,
235) -> impl IntoView
236where
237    M: Copy + PartialEq + Send + Sync + 'static,
238{
239    items
240        .into_iter()
241        .map(|(mode_val, label)| {
242            view! {
243                <button
244                    type="button"
245                    class=move || {
246                        if mode.get() == mode_val { MODE_ACTIVE_CLASS } else { MODE_IDLE_CLASS }
247                    }
248                    aria-pressed=move || (mode.get() == mode_val).to_string()
249                    on:click=move |_| {
250                        mode.set(mode_val);
251                        on_pick.run(mode_val);
252                    }
253                >
254                    {label}
255                </button>
256            }
257        })
258        .collect_view()
259}
260
261#[component]
262fn CommentPreview(slug: String, detail: bool) -> impl IntoView {
263    let defer_editor = !detail && matches!(slug.as_str(), "comment-form" | "comment-section");
264    let visible = RwSignal::new(!defer_editor);
265    let approved = RwSignal::new(initial_approved(&slug, detail));
266    let pending = RwSignal::new(initial_pending(&slug, detail));
267    provide_local_context(RwSignal::new(None), approved, pending, scope(&slug));
268
269    let placeholder = NodeRef::<leptos::html::Div>::new();
270    // 编辑器延迟到卡片进入视口再挂载;观察器随组件所有者释放时断开。
271    #[cfg(target_arch = "wasm32")]
272    {
273        let observer = StoredValue::new_local(None::<VisibleObserver>);
274        Effect::new(move |_| {
275            use wasm_bindgen::JsCast;
276            let Some(element) = placeholder.get() else {
277                return;
278            };
279            if observer.with_value(|current| current.is_some()) {
280                return;
281            }
282            let callback = wasm_bindgen::closure::Closure::<
283                dyn FnMut(js_sys::Array, web_sys::IntersectionObserver),
284            >::new(
285                move |entries: js_sys::Array, watcher: web_sys::IntersectionObserver| {
286                    if entries.iter().any(|entry| {
287                        entry
288                            .dyn_into::<web_sys::IntersectionObserverEntry>()
289                            .is_ok_and(|entry| entry.is_intersecting())
290                    }) {
291                        watcher.disconnect();
292                        let _ = visible.try_set(true);
293                    }
294                },
295            );
296            match web_sys::IntersectionObserver::new(callback.as_ref().unchecked_ref()) {
297                Ok(watcher) => {
298                    watcher.observe(&element);
299                    observer.set_value(Some(VisibleObserver {
300                        observer: watcher,
301                        _callback: callback,
302                    }));
303                }
304                Err(_) => visible.set(true),
305            }
306        });
307    }
308
309    move || {
310        if !visible.get() {
311            return view! {
312                <div
313                    node_ref=placeholder
314                    class="min-h-[190px] w-full rounded-xl border border-ink-3 bg-surface-2/40 p-4"
315                >
316                    <div class="flex items-center gap-2 text-xs text-fg-3">
317                        <span class="size-2 rounded-full bg-clay-text/50"></span>
318                        "评论编辑器将在卡片出现时加载"
319                    </div>
320                    <div class="mt-4 h-9 rounded-lg bg-surface-0/70"></div>
321                    <div class="mt-3 h-20 rounded-lg bg-surface-0/70"></div>
322                </div>
323            }
324            .into_any();
325        }
326
327        let body = match slug.as_str() {
328            "pending-comment-item" => {
329                let mut comment = pending_sample();
330                comment.parent_id = None;
331                comment.depth = 0;
332                view! { <PendingCommentItem comment=comment post_id=0/> }.into_any()
333            }
334            "comment-form" => view! {
335                <CommentForm post_id=0 parent_id=None parent_indent=None/>
336                {move || {
337                    (!pending.with(|list| list.is_empty()))
338                        .then(|| {
339                            view! {
340                                <CommentList
341                                    comments=Signal::stored(Vec::<PublicComment>::new())
342                                    pending=pending
343                                    post_id=0
344                                />
345                            }
346                        })
347                }}
348            }
349            .into_any(),
350            "comment-section" => view! {
351                <CommentSectionContent
352                    post_id=0
353                    comments=Signal::derive(move || Some(approved.get()))
354                    approved_count=Signal::derive(move || {
355                        Some(approved.with(|list| list.len()) as i64)
356                    })
357                    failed=Signal::stored(false)
358                />
359            }
360            .into_any(),
361            _ => ().into_any(),
362        };
363        let actions = (detail && slug == "comment-section").then(|| {
364            view! {
365                <div class="flex flex-wrap gap-2 mb-4">
366                    <button
367                        type="button"
368                        class=ACTION_CLASS
369                        on:click=move |_| {
370                            approved.set(Vec::new());
371                            pending.set(Vec::new());
372                        }
373                    >
374                        "空列表"
375                    </button>
376                    <button
377                        type="button"
378                        class=ACTION_CLASS
379                        on:click=move |_| {
380                            approved.set(approved_sample());
381                            pending.set(Vec::new());
382                        }
383                    >
384                        "仅已审核"
385                    </button>
386                    <button
387                        type="button"
388                        class=ACTION_CLASS
389                        on:click=move |_| {
390                            approved.set(approved_sample());
391                            pending.set(vec![pending_sample()]);
392                        }
393                    >
394                        "评论与待审核"
395                    </button>
396                </div>
397            }
398        });
399        let note = detail
400            .then(|| {
401                view! { <p class="mb-4 text-xs text-fg-3">"本地演示,评论不会保存,也不会上传图片。"</p> }
402            });
403        view! {
404            <div class="w-full min-w-0">
405                {note}
406                {actions}
407                {body}
408            </div>
409        }
410        .into_any()
411    }
412}
413
414#[derive(Clone, Copy, PartialEq, Eq)]
415enum CommentItemMode {
416    Standard,
417    ReplyDraft,
418    AuthorTypes,
419    DeepNesting,
420}
421
422/// 标准回复链(顶层 + 一条回复),`reply_html` 为回复正文。
423fn reply_chain(reply_html: &str) -> AnyView {
424    let parent = PublicComment {
425        is_author: true,
426        created_at: "昨天 17:30".to_string(),
427        created_at_iso: "2026-09-22T10:00:00Z".to_string(),
428        ..base_comment(
429            101,
430            None,
431            0,
432            "青禾",
433            "<p>春天的第一片叶子,总会记得来时的风。</p>",
434        )
435    };
436    let reply = PublicComment {
437        created_at: "昨天 18:02".to_string(),
438        created_at_iso: "2026-09-22T11:00:00Z".to_string(),
439        ..base_comment(102, Some(101), 1, "阿木", reply_html)
440    };
441    view! {
442        <CommentItem comment=parent post_id=0/>
443        <CommentItem comment=reply post_id=0/>
444    }
445    .into_any()
446}
447
448fn item_mode_rows(mode: CommentItemMode) -> AnyView {
449    match mode {
450        CommentItemMode::Standard => reply_chain("<p>我也喜欢这句话,愿新的一年枝繁叶茂。</p>"),
451        CommentItemMode::ReplyDraft => {
452            reply_chain("<p>我也喜欢这句话,点击下方回复体验草稿保留功能。</p>")
453        }
454        CommentItemMode::AuthorTypes => {
455            let author_comment = PublicComment {
456                is_author: true,
457                created_at: "昨天 17:30".to_string(),
458                created_at_iso: "2026-09-22T10:00:00Z".to_string(),
459                ..base_comment(
460                    301,
461                    None,
462                    0,
463                    "青禾",
464                    "<p>博主身份演示:带有专属绿色高亮的<strong>作者</strong>徽章。</p>",
465                )
466            };
467            let visitor_with_url = PublicComment {
468                author_url: Some("https://example.com".to_string()),
469                created_at: "昨天 18:20".to_string(),
470                created_at_iso: "2026-09-22T11:00:00Z".to_string(),
471                ..base_comment(
472                    302,
473                    None,
474                    0,
475                    "远方的小树",
476                    "<p>附带外链访客:作者昵称展示为外部主页链接(在新标签页安全打开,带 <code>rel=\"nofollow noopener\"</code> 属性)。</p>",
477                )
478            };
479            let plain_visitor = PublicComment {
480                created_at: "昨天 19:00".to_string(),
481                created_at_iso: "2026-09-22T12:00:00Z".to_string(),
482                ..base_comment(
483                    303,
484                    None,
485                    0,
486                    "林间漫步",
487                    "<p>普通访客:未提供个人主页链接,昵称渲染为普通加粗文本。</p>",
488                )
489            };
490            view! {
491                <CommentItem comment=author_comment post_id=0/>
492                <CommentItem comment=visitor_with_url post_id=0/>
493                <CommentItem comment=plain_visitor post_id=0/>
494            }
495            .into_any()
496        }
497        CommentItemMode::DeepNesting => {
498            let c0 = PublicComment {
499                is_author: true,
500                created_at: "10 分钟前".to_string(),
501                created_at_iso: "2026-09-22T10:00:00Z".to_string(),
502                ..base_comment(
503                    401,
504                    None,
505                    0,
506                    "青禾",
507                    "<p>顶层评论(depth = 0):无左侧边框引导线与 margin 缩进。</p>",
508                )
509            };
510            let c1 = PublicComment {
511                created_at: "8 分钟前".to_string(),
512                created_at_iso: "2026-09-22T10:02:00Z".to_string(),
513                ..base_comment(
514                    402,
515                    Some(401),
516                    1,
517                    "阿木",
518                    "<p>一级嵌套(depth = 1):启用左侧引导线 <code>border-l-2</code>,建立父子回复视觉联系。</p>",
519                )
520            };
521            let c2 = PublicComment {
522                created_at: "6 分钟前".to_string(),
523                created_at_iso: "2026-09-22T10:04:00Z".to_string(),
524                ..base_comment(
525                    403,
526                    Some(402),
527                    2,
528                    "山风",
529                    "<p>二级嵌套(depth = 2):在引导线基础上叠加 16px 阶梯缩进,凸显回复层级。</p>",
530                )
531            };
532            let c3 = PublicComment {
533                created_at: "4 分钟前".to_string(),
534                created_at_iso: "2026-09-22T10:06:00Z".to_string(),
535                ..base_comment(
536                    404,
537                    Some(403),
538                    3,
539                    "海棠",
540                    "<p>三级嵌套(depth = 3):叠加 32px 阶梯缩进,树形关系更深入。</p>",
541                )
542            };
543            let c20 = PublicComment {
544                created_at: "刚刚".to_string(),
545                created_at_iso: "2026-09-22T10:10:00Z".to_string(),
546                ..base_comment(
547                    420,
548                    Some(404),
549                    20,
550                    "边界防护测试",
551                    "<p>极限深度(depth = 20):到达最大嵌套深度防护限制,下方回复按钮自动隐藏,防止无限嵌套破坏页面排版。</p>",
552                )
553            };
554            view! {
555                <CommentItem comment=c0 post_id=0/>
556                <CommentItem comment=c1 post_id=0/>
557                <CommentItem comment=c2 post_id=0/>
558                <CommentItem comment=c3 post_id=0/>
559                <CommentItem comment=c20 post_id=0/>
560            }
561            .into_any()
562        }
563    }
564}
565
566#[component]
567fn CommentItemPreview(detail: bool) -> impl IntoView {
568    let mode = RwSignal::new(CommentItemMode::Standard);
569    let active_reply = RwSignal::new(None::<i64>);
570    let approved_store = RwSignal::new(Vec::<PublicComment>::new());
571    let pending_store = RwSignal::new(Vec::<PendingComment>::new());
572    provide_local_context(
573        active_reply,
574        approved_store,
575        pending_store,
576        "showcase-comment-item",
577    );
578
579    if !detail {
580        let parent = PublicComment {
581            is_author: true,
582            created_at: "昨天 17:30".to_string(),
583            created_at_iso: "2026-09-22T10:00:00Z".to_string(),
584            ..base_comment(
585                101,
586                None,
587                0,
588                "青禾",
589                "<p>春天的第一片叶子,总会记得来时的风。</p>",
590            )
591        };
592        let reply = PublicComment {
593            created_at: "昨天 18:02".to_string(),
594            created_at_iso: "2026-09-22T11:00:00Z".to_string(),
595            ..base_comment(102, Some(101), 1, "阿木", "<p>我也喜欢这句话。</p>")
596        };
597        return view! {
598            <div class="comment-list w-full min-w-0 px-1">
599                <CommentItem comment=parent post_id=0/>
600                <CommentItem comment=reply post_id=0/>
601            </div>
602        }
603        .into_any();
604    }
605
606    let buttons = mode_buttons(
607        mode,
608        vec![
609            (CommentItemMode::Standard, "标准回复链"),
610            (CommentItemMode::ReplyDraft, "展开回复与草稿"),
611            (CommentItemMode::AuthorTypes, "身份与外链"),
612            (CommentItemMode::DeepNesting, "深层缩进与边界 (depth=20)"),
613        ],
614        Callback::new(move |picked: CommentItemMode| {
615            if picked == CommentItemMode::ReplyDraft {
616                active_reply.set(Some(101));
617            } else {
618                active_reply.set(None);
619            }
620        }),
621    );
622
623    view! {
624        <div class="w-full min-w-0">
625            <p class="mb-3 text-xs text-fg-3">
626                "本地演示,可切换不同形态体验 CommentItem 的回复表单联动、草稿保留与身份样式。"
627            </p>
628            <div class="flex flex-wrap items-center gap-2 mb-3">{buttons}</div>
629            <p class="text-xs text-fg-2 mb-4 leading-relaxed">
630                {move || match mode.get() {
631                    CommentItemMode::Standard => "标准嵌套链:展示博主顶层评论与嵌套子回复,点击“回复”可展开内嵌表单并输入草稿。",
632                    CommentItemMode::ReplyDraft => "回复展开与草稿保留:展开内嵌 CommentForm 表单,表单自动通过负 margin 抵消嵌套缩进;取消回复再重新打开时保留草稿。",
633                    CommentItemMode::AuthorTypes => "身份与外链差异:对比博主专属“作者”徽章、访客个人主页外链(在新标签打开)、以及普通访客纯文本昵称。",
634                    CommentItemMode::DeepNesting => "深层缩进与边界防护:展示 depth=1 至 depth=3 的递进缩进与引导线;当深度达到 depth >= 20 时,自动隐藏回复按钮防止无限嵌套破坏版面。",
635                }}
636            </p>
637            <div class=PANEL_CLASS>
638                <div class="comment-list space-y-0">
639                    {move || item_mode_rows(mode.get())}
640                </div>
641            </div>
642        </div>
643    }
644    .into_any()
645}
646
647#[derive(Clone, Copy, PartialEq, Eq)]
648enum CommentListMode {
649    Merged,
650    ApprovedOnly,
651    Empty,
652    ComplexTree,
653}
654
655fn list_mode_data(mode: CommentListMode) -> (Vec<PublicComment>, Vec<PendingComment>) {
656    match mode {
657        CommentListMode::Merged => (approved_sample(), vec![pending_sample()]),
658        CommentListMode::ApprovedOnly => (approved_sample(), Vec::new()),
659        CommentListMode::Empty => (Vec::new(), Vec::new()),
660        CommentListMode::ComplexTree => {
661            let tree_approved = vec![
662                PublicComment {
663                    is_author: true,
664                    created_at: "昨天 10:00".to_string(),
665                    created_at_iso: "2026-09-22T10:00:00Z".to_string(),
666                    ..base_comment(201, None, 0, "青禾", "<p>新功能组件图鉴上线,欢迎大家体验!</p>")
667                },
668                PublicComment {
669                    created_at: "昨天 11:30".to_string(),
670                    created_at_iso: "2026-09-22T11:30:00Z".to_string(),
671                    ..base_comment(
672                        202,
673                        Some(201),
674                        1,
675                        "阿木",
676                        "<p>交互非常流畅,尤其细节动画很精致。</p>",
677                    )
678                },
679                PublicComment {
680                    is_author: true,
681                    created_at: "昨天 12:00".to_string(),
682                    created_at_iso: "2026-09-22T12:00:00Z".to_string(),
683                    ..base_comment(
684                        203,
685                        Some(202),
686                        2,
687                        "青禾",
688                        "<p>感谢支持,我们会继续完善体验。</p>",
689                    )
690                },
691                PublicComment {
692                    created_at: "昨天 14:00".to_string(),
693                    created_at_iso: "2026-09-22T14:00:00Z".to_string(),
694                    ..base_comment(
695                        204,
696                        None,
697                        0,
698                        "林间微风",
699                        "<p>请问后台支持自定义主题色配置吗?</p>",
700                    )
701                },
702                PublicComment {
703                    created_at: "昨天 15:00".to_string(),
704                    created_at_iso: "2026-09-22T15:00:00Z".to_string(),
705                    // 孤儿评论:指向不存在的父评论
706                    ..base_comment(
707                        205,
708                        Some(99999),
709                        1,
710                        "探索者",
711                        "<p>这是指向已失效父评论的孤儿评论(parent_id = 99999)。CommentList 自动将其降为顶层展示,确保评论不会静默丢失。</p>",
712                    )
713                },
714            ];
715            let tree_pending = vec![PendingComment {
716                id: -20,
717                parent_id: Some(204),
718                depth: 1,
719                author_name: "叶子访客".to_string(),
720                author_url: None,
721                avatar_url: String::new(),
722                content_md: "支持的,可以在系统设置中调整调色板。".to_string(),
723                created_at: "2026-09-22T14:30:00Z".to_string(),
724                stored_at: "2026-09-22T14:30:00Z".to_string(),
725            }];
726            (tree_approved, tree_pending)
727        }
728    }
729}
730
731#[component]
732fn CommentListPreview(detail: bool) -> impl IntoView {
733    let mode = RwSignal::new(CommentListMode::Merged);
734    let active_reply = RwSignal::new(None::<i64>);
735    let approved_store = RwSignal::new(Vec::<PublicComment>::new());
736    let pending_store = RwSignal::new(Vec::<PendingComment>::new());
737    provide_local_context(
738        active_reply,
739        approved_store,
740        pending_store,
741        "showcase-comment-list",
742    );
743
744    if !detail {
745        let comments = vec![PublicComment {
746            is_author: true,
747            created_at: "昨天 17:30".to_string(),
748            created_at_iso: "2026-09-22T10:00:00Z".to_string(),
749            ..base_comment(
750                101,
751                None,
752                0,
753                "青禾",
754                "<p>春天的第一片叶子,总会记得来时的风。</p>",
755            )
756        }];
757        let pending = vec![PendingComment {
758            id: -10,
759            parent_id: Some(101),
760            depth: 1,
761            author_name: "叶子访客".to_string(),
762            author_url: None,
763            avatar_url: String::new(),
764            content_md: "这条样例评论正在等待审核。".to_string(),
765            created_at: "2026-09-22T10:30:00Z".to_string(),
766            stored_at: "2026-09-22T10:30:00Z".to_string(),
767        }];
768        return view! {
769            <div class="w-full min-w-0 px-1">
770                <CommentList comments=Signal::stored(comments) pending=Signal::stored(pending) post_id=0/>
771            </div>
772        }
773        .into_any();
774    }
775
776    let buttons = mode_buttons(
777        mode,
778        vec![
779            (CommentListMode::Merged, "评论与待审核"),
780            (CommentListMode::ApprovedOnly, "仅已审核"),
781            (CommentListMode::Empty, "空列表"),
782            (CommentListMode::ComplexTree, "多分支与孤儿容错"),
783        ],
784        Callback::new(|_| {}),
785    );
786
787    view! {
788        <div class="w-full min-w-0">
789            <p class="mb-3 text-xs text-fg-3">
790                "本地演示,可切换不同形态体验 CommentList 的树形合并、排序与孤儿容错。"
791            </p>
792            <div class="flex flex-wrap items-center gap-2 mb-3">{buttons}</div>
793            <p class="text-xs text-fg-2 mb-4 leading-relaxed">
794                {move || match mode.get() {
795                    CommentListMode::Merged => "评论与待审核:服务端已审核评论与本地待审核评论统一合并为树形结构;待审核评论展示为带呼吸指示灯的半透明卡片,按时间升序插入对应层级。",
796                    CommentListMode::ApprovedOnly => "仅已审核:纯已审核评论树,展现多级回复的深度缩进、左侧引导线与完整回复交互。",
797                    CommentListMode::Empty => "空列表:无任何评论时的缺省占位展示,引导访客成为首位留言者。",
798                    CommentListMode::ComplexTree => "多分支与孤儿容错:展示多个并行顶层主题、多级嵌套回复,以及父评论缺失时的孤儿评论容错恢复(自动升级为顶层展示)。",
799                }}
800            </p>
801            <div class=PANEL_CLASS>
802                {move || {
803                    let (comments, pending) = list_mode_data(mode.get());
804                    if comments.is_empty() && pending.is_empty() {
805                        view! {
806                            <div class="text-center py-8 px-4 rounded-2xl border border-dashed border-ink-3 text-fg-2">
807                                "暂无评论"
808                            </div>
809                        }
810                            .into_any()
811                    } else {
812                        view! {
813                            <CommentList
814                                comments=Signal::stored(comments)
815                                pending=Signal::stored(pending)
816                                post_id=0
817                            />
818                        }
819                            .into_any()
820                    }
821                }}
822            </div>
823        </div>
824    }
825    .into_any()
826}
827
828#[derive(Clone, Copy, PartialEq, Eq)]
829enum CardShellMode {
830    All,
831    TopLevel,
832    NestedReply,
833    DeepIndent,
834    Pending,
835}
836
837#[component]
838fn ShellReplyButton(
839    comment_id: i32,
840    active_id: RwSignal<Option<i32>>,
841    author_name: String,
842) -> impl IntoView {
843    let is_active = move || active_id.get() == Some(comment_id);
844    view! {
845        <div class="comment-actions">
846            <button
847                type="button"
848                class="comment-reply"
849                aria-expanded=move || is_active().to_string()
850                aria-label=format!("回复 {author_name} 的评论")
851                on:click=move |_| {
852                    if active_id.get_untracked() == Some(comment_id) {
853                        active_id.set(None);
854                    } else {
855                        active_id.set(Some(comment_id));
856                    }
857                }
858            >
859                {move || if is_active() { "收起回复" } else { "回复" }}
860            </button>
861            {move || {
862                is_active()
863                    .then(|| {
864                        view! {
865                            <div class="mt-2.5 flex items-center gap-2 p-2.5 rounded-xl border border-ink-3 bg-surface-2/60 text-xs text-fg-2 animate-in fade-in duration-200">
866                                <span class="size-1.5 rounded-full bg-clay-text shrink-0"></span>
867                                <span>"children 插槽生效中:可挂载回复表单或快捷操作栏。"</span>
868                            </div>
869                        }
870                    })
871            }}
872        </div>
873    }
874}
875
876/// 卡片外壳的一条固定样例。
877#[derive(Clone, Copy)]
878struct ShellSample {
879    id: i32,
880    depth: i32,
881    muted: bool,
882    author: &'static str,
883    is_author: bool,
884    time: &'static str,
885    is_pending: bool,
886    html: &'static str,
887}
888
889impl ShellSample {
890    const fn new(
891        id: i32,
892        depth: i32,
893        author: &'static str,
894        is_author: bool,
895        time: &'static str,
896        html: &'static str,
897    ) -> Self {
898        Self {
899            id,
900            depth,
901            muted: false,
902            author,
903            is_author,
904            time,
905            is_pending: false,
906            html,
907        }
908    }
909
910    const fn pending(mut self) -> Self {
911        self.muted = true;
912        self.is_pending = true;
913        self
914    }
915}
916
917fn render_shell_comment(replying_to: RwSignal<Option<i32>>, sample: ShellSample) -> AnyView {
918    let author = sample.author;
919    let author_element = view! { <span class="comment-author">{author}</span> }.into_any();
920    let author_badge = if sample.is_author {
921        view! { <span class="comment-badge">"作者"</span> }.into_any()
922    } else {
923        ().into_any()
924    };
925    let timestamp = view! { <time class="comment-time">{sample.time}</time> }.into_any();
926    let status_badge = if sample.is_pending {
927        view! { <span class="comment-badge is-pending">"审核中"</span> }.into_any()
928    } else {
929        ().into_any()
930    };
931    if sample.is_pending {
932        view! {
933            <CommentCardShell
934                depth=sample.depth
935                muted=sample.muted
936                avatar_url=String::new()
937                author_name=author.to_string()
938                author_element=author_element
939                author_badge=author_badge
940                timestamp=timestamp
941                status_badge=status_badge
942                content_html=sample.html.to_string()
943            />
944        }
945        .into_any()
946    } else {
947        view! {
948            <CommentCardShell
949                depth=sample.depth
950                muted=sample.muted
951                avatar_url=String::new()
952                author_name=author.to_string()
953                author_element=author_element
954                author_badge=author_badge
955                timestamp=timestamp
956                status_badge=status_badge
957                content_html=sample.html.to_string()
958                content_extra_class="md-content"
959            >
960                <ShellReplyButton
961                    comment_id=sample.id
962                    active_id=replying_to
963                    author_name=author.to_string()
964                />
965            </CommentCardShell>
966        }
967        .into_any()
968    }
969}
970
971fn render_shell_samples(replying_to: RwSignal<Option<i32>>, samples: &[ShellSample]) -> AnyView {
972    samples
973        .iter()
974        .map(|sample| render_shell_comment(replying_to, *sample))
975        .collect_view()
976        .into_any()
977}
978
979fn shell_mode_rows(mode: CardShellMode, replying_to: RwSignal<Option<i32>>) -> AnyView {
980    let top = ShellSample::new(
981        1,
982        0,
983        "青禾",
984        true,
985        "昨天 17:30",
986        "<p>春天的第一片叶子,总会记得来时的风。</p>",
987    );
988    let nested = ShellSample::new(
989        2,
990        1,
991        "阿木",
992        false,
993        "昨天 18:02",
994        "<p>我也喜欢这句话,愿新的一年枝繁叶茂。</p>",
995    );
996    let deep = ShellSample::new(
997        3,
998        2,
999        "青禾",
1000        true,
1001        "今天 09:15",
1002        "<p>谢谢,愿你也有好天气。</p>",
1003    );
1004    match mode {
1005        CardShellMode::All => render_shell_samples(
1006            replying_to,
1007            &[
1008                top,
1009                nested,
1010                deep,
1011                ShellSample::new(
1012                    4,
1013                    2,
1014                    "叶子访客",
1015                    false,
1016                    "刚刚",
1017                    "<p>这条样例回复正在等待审核,卡片整体呈半透明淡化展示。</p>",
1018                )
1019                .pending(),
1020            ],
1021        ),
1022        CardShellMode::TopLevel => render_shell_samples(replying_to, &[top]),
1023        CardShellMode::NestedReply => render_shell_samples(replying_to, &[nested]),
1024        CardShellMode::DeepIndent => render_shell_samples(replying_to, &[deep]),
1025        CardShellMode::Pending => render_shell_samples(
1026            replying_to,
1027            &[ShellSample::new(
1028                4,
1029                0,
1030                "叶子访客",
1031                false,
1032                "刚刚",
1033                "<p>这条样例评论正在等待审核,整卡呈现 75% 不透明度与审核中徽章。</p>",
1034            )
1035            .pending()],
1036        ),
1037    }
1038}
1039
1040#[component]
1041fn CommentCardShellPreview(detail: bool) -> impl IntoView {
1042    let mode = RwSignal::new(CardShellMode::All);
1043    let replying_to = RwSignal::new(None::<i32>);
1044
1045    if !detail {
1046        let samples = [
1047            ShellSample::new(
1048                1,
1049                0,
1050                "青禾",
1051                true,
1052                "昨天 17:30",
1053                "<p>春天的第一片叶子,总会记得来时的风。</p>",
1054            ),
1055            ShellSample::new(
1056                2,
1057                1,
1058                "阿木",
1059                false,
1060                "昨天 18:02",
1061                "<p>我也喜欢这句话,愿枝繁叶茂。</p>",
1062            ),
1063        ];
1064        return view! {
1065            <div class="comment-list w-full min-w-0 px-1">
1066                {render_shell_samples(replying_to, &samples)}
1067            </div>
1068        }
1069        .into_any();
1070    }
1071
1072    let buttons = mode_buttons(
1073        mode,
1074        vec![
1075            (CardShellMode::All, "全部形态"),
1076            (CardShellMode::TopLevel, "顶层评论 (depth=0)"),
1077            (CardShellMode::NestedReply, "嵌套回复 (depth=1)"),
1078            (CardShellMode::DeepIndent, "深层嵌套 (depth=2)"),
1079            (CardShellMode::Pending, "待审核 (muted)"),
1080        ],
1081        Callback::new(|_| {}),
1082    );
1083
1084    view! {
1085        <div class="w-full min-w-0">
1086            <p class="mb-3 text-xs text-fg-3">
1087                "本地演示,切换下方的形态标签可预览卡片骨架的不同状态与缩进效果。"
1088            </p>
1089            <div class="flex flex-wrap items-center gap-2 mb-3">{buttons}</div>
1090            <p class="text-xs text-fg-2 mb-4 leading-relaxed">
1091                {move || match mode.get() {
1092                    CardShellMode::All => "完整树形展示:包含顶层评论、一级嵌套引导线、二级阶梯缩进与待审核半透明状态。",
1093                    CardShellMode::TopLevel => "顶层评论(depth = 0):无左侧边框与缩进,包含头像、作者徽章、绝对时间与回复操作。",
1094                    CardShellMode::NestedReply => "一级嵌套(depth = 1):启用左侧引导线,区分父子回复关系。",
1095                    CardShellMode::DeepIndent => "深层嵌套(depth = 2):在左侧引导线基础上叠加 margin-left 阶梯缩进。",
1096                    CardShellMode::Pending => "待审核状态(muted = true):整卡淡化,配合“审核中”徽章与状态点。",
1097                }}
1098            </p>
1099            <div class="comment-list rounded-2xl border border-ink-3/70 bg-surface-2/30 p-4 sm:p-5 shadow-xs">
1100                {move || shell_mode_rows(mode.get(), replying_to)}
1101            </div>
1102        </div>
1103    }
1104    .into_any()
1105}
1106
1107#[cfg(test)]
1108mod tests {
1109    use super::*;
1110
1111    #[test]
1112    fn comment_previews_dispatch_properly() {
1113        Owner::new().with(|| {
1114            assert!(preview("comment-card-shell", false).is_some());
1115            assert!(preview("comment-card-shell", true).is_some());
1116            assert!(preview("comment-item", false).is_some());
1117            assert!(preview("comment-item", true).is_some());
1118            assert!(preview("comment-list", false).is_some());
1119            assert!(preview("comment-list", true).is_some());
1120            assert!(preview("pending-comment-item", false).is_some());
1121            assert!(preview("comment-form", false).is_some());
1122            assert!(preview("comment-section", false).is_some());
1123            assert!(preview("unknown-slug", false).is_none());
1124        });
1125    }
1126
1127    #[test]
1128    fn deferred_editor_renders_placeholder_on_server() {
1129        let html = Owner::new().with(|| {
1130            preview("comment-form", false)
1131                .expect("评论预览必须实现")
1132                .to_html()
1133        });
1134        assert!(html.contains("评论编辑器将在卡片出现时加载"), "{html}");
1135    }
1136
1137    #[test]
1138    fn local_reply_uses_parent_depth_and_unique_negative_id() {
1139        let parent = PendingComment {
1140            id: -10,
1141            parent_id: None,
1142            depth: 1,
1143            author_name: "样例".to_string(),
1144            author_url: None,
1145            avatar_url: String::new(),
1146            content_md: "原评论".to_string(),
1147            created_at: "2026-01-01T00:00:00Z".to_string(),
1148            stored_at: "2026-01-01T00:00:00Z".to_string(),
1149        };
1150        let reply = local_pending_comment(
1151            &[],
1152            &[parent],
1153            Some(-10),
1154            "访客".to_string(),
1155            String::new(),
1156            "回复内容".to_string(),
1157        );
1158        assert_eq!(
1159            (reply.id, reply.parent_id, reply.depth),
1160            (-11, Some(-10), 2)
1161        );
1162        assert_eq!(reply.author_url, None);
1163        assert_eq!(reply.content_md, "回复内容");
1164
1165        let approved = PublicComment {
1166            is_author: true,
1167            created_at: "昨天".to_string(),
1168            created_at_iso: "2026-01-01T00:00:00Z".to_string(),
1169            ..base_comment(101, None, 0, "作者", "<p>原评论</p>")
1170        };
1171        let reply = local_pending_comment(
1172            &[approved],
1173            &[],
1174            Some(101),
1175            "访客".to_string(),
1176            String::new(),
1177            "回复".to_string(),
1178        );
1179        assert_eq!((reply.id, reply.parent_id, reply.depth), (-1, Some(101), 1));
1180    }
1181}