Skip to main content

yggdrasil/pages/components_showcase/
comment_previews.rs

1//! 评论组件图鉴:固定样例与局部上下文,展示节点仍由正式评论组件渲染。
2
3use dioxus::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::{CommentContext, CommentSectionContent, CommentSource};
11use crate::models::comment::PublicComment;
12use crate::utils::comment_storage::PendingComment;
13
14#[cfg(target_arch = "wasm32")]
15struct VisibleObserver {
16    observer: web_sys::IntersectionObserver,
17    _callback:
18        wasm_bindgen::closure::Closure<dyn FnMut(js_sys::Array, web_sys::IntersectionObserver)>,
19}
20
21#[cfg(target_arch = "wasm32")]
22impl Drop for VisibleObserver {
23    fn drop(&mut self) {
24        self.observer.disconnect();
25    }
26}
27
28pub(super) fn preview(slug: &str, detail: bool) -> Option<Element> {
29    match slug {
30        "comment-card-shell" => Some(rsx! {
31            CommentCardShellPreview { detail }
32        }),
33        "comment-item" => Some(rsx! {
34            CommentItemPreview { detail }
35        }),
36        "comment-list" => Some(rsx! {
37            CommentListPreview { detail }
38        }),
39        "pending-comment-item" | "comment-form" | "comment-section" => Some(rsx! {
40            CommentPreview { key: "{slug}", slug: slug.to_string(), detail }
41        }),
42        _ => None,
43    }
44}
45
46fn approved_sample() -> Vec<PublicComment> {
47    [
48        (
49            101,
50            None,
51            0,
52            "青禾",
53            "春天的第一片叶子,总会记得来时的风。",
54            "昨天 17:30",
55        ),
56        (102, Some(101), 1, "阿木", "我也喜欢这句话。", "昨天 18:02"),
57        (
58            103,
59            Some(102),
60            2,
61            "青禾",
62            "谢谢,愿你也有好天气。",
63            "今天 09:15",
64        ),
65    ]
66    .into_iter()
67    .map(
68        |(id, parent_id, depth, author_name, content, created_at)| PublicComment {
69            id,
70            parent_id,
71            depth,
72            author_name: author_name.to_string(),
73            author_url: None,
74            avatar_url: String::new(),
75            is_author: id != 102,
76            content_html: Some(format!("<p>{content}</p>")),
77            created_at: created_at.to_string(),
78            created_at_iso: format!("2026-09-22T{:02}:00:00Z", 10 + depth),
79        },
80    )
81    .collect()
82}
83
84fn pending_sample() -> PendingComment {
85    PendingComment {
86        id: -10,
87        parent_id: Some(102),
88        depth: 2,
89        author_name: "叶子访客".to_string(),
90        author_url: None,
91        avatar_url: String::new(),
92        content_md: "这条样例评论正在等待审核。".to_string(),
93        created_at: "2026-01-01T08:00:00Z".to_string(),
94        stored_at: "2026-01-01T08:00:00Z".to_string(),
95    }
96}
97
98fn initial_approved(slug: &str, detail: bool) -> Vec<PublicComment> {
99    match slug {
100        "comment-section" if !detail => approved_sample().into_iter().take(1).collect(),
101        "comment-form" | "pending-comment-item" => Vec::new(),
102        _ => approved_sample(),
103    }
104}
105
106fn initial_pending(slug: &str, detail: bool) -> Vec<PendingComment> {
107    match slug {
108        "comment-section" if detail => vec![pending_sample()],
109        _ => Vec::new(),
110    }
111}
112
113fn scope(slug: &str) -> &'static str {
114    match slug {
115        "pending-comment-item" => "showcase-pending-comment-item",
116        "comment-form" => "showcase-comment-form",
117        _ => "showcase-comment-section",
118    }
119}
120
121#[component]
122fn CommentPreview(slug: String, detail: bool) -> Element {
123    let defer_editor = !detail && matches!(slug.as_str(), "comment-form" | "comment-section");
124    #[cfg_attr(not(target_arch = "wasm32"), allow(unused_mut))]
125    let mut visible = use_signal(|| !defer_editor);
126    #[cfg(target_arch = "wasm32")]
127    let mut observer: Signal<Option<VisibleObserver>> = use_signal(|| None);
128    let ctx = use_context_provider(|| {
129        let approved = Signal::new(initial_approved(&slug, detail));
130        CommentContext {
131            active_reply: Signal::new(None),
132            refresh_trigger: Signal::new(false),
133            pending_comments: Signal::new(initial_pending(&slug, detail)),
134            current_user: Signal::new(None),
135            source: CommentSource::Local { approved },
136            id_scope: scope(&slug),
137        }
138    });
139    if !visible() {
140        return rsx! {
141            div {
142                class: "min-h-[190px] w-full rounded-xl border border-[var(--color-paper-border)] bg-[var(--color-paper-entry)]/40 p-4",
143                onmounted: move |_event| {
144                    #[cfg(target_arch = "wasm32")]
145                    {
146                        use wasm_bindgen::JsCast;
147                        if observer.peek().is_some() { return; }
148                        let mounted = _event.data();
149                        let Some(element) = mounted.downcast::<web_sys::Element>() else {
150                            visible.set(true);
151                            return;
152                        };
153                        let callback = wasm_bindgen::closure::Closure::<
154                            dyn FnMut(js_sys::Array, web_sys::IntersectionObserver),
155                        >::new(move |entries: js_sys::Array, watcher: web_sys::IntersectionObserver| {
156                            if entries.iter().any(|entry| {
157                                entry
158                                    .dyn_into::<web_sys::IntersectionObserverEntry>()
159                                    .is_ok_and(|entry| entry.is_intersecting())
160                            }) {
161                                watcher.disconnect();
162                                visible.set(true);
163                            }
164                        });
165                        match web_sys::IntersectionObserver::new(callback.as_ref().unchecked_ref()) {
166                            Ok(watcher) => {
167                                watcher.observe(element);
168                                observer.set(Some(VisibleObserver { observer: watcher, _callback: callback }));
169                            }
170                            Err(_) => visible.set(true),
171                        }
172                    }
173                },
174                div { class: "flex items-center gap-2 text-xs text-paper-tertiary", span { class: "size-2 rounded-full bg-[var(--color-paper-accent)]/50" } "评论编辑器将在卡片出现时加载" }
175                div { class: "mt-4 h-9 rounded-lg bg-[var(--color-paper-theme)]/70" }
176                div { class: "mt-3 h-20 rounded-lg bg-[var(--color-paper-theme)]/70" }
177            }
178        };
179    }
180    let CommentSource::Local { mut approved } = ctx.source else {
181        unreachable!("图鉴上下文只使用本地数据源")
182    };
183    let mut pending = ctx.pending_comments;
184    let sample = approved_sample();
185    let visible = match slug.as_str() {
186        "pending-comment-item" => {
187            let mut comment = pending_sample();
188            comment.parent_id = None;
189            comment.depth = 0;
190            rsx! { PendingCommentItem { comment, post_id: 0 } }
191        }
192        "comment-form" => rsx! {
193            CommentForm { post_id: 0, parent_id: None, parent_indent: None }
194            if !pending().is_empty() {
195                CommentList { comments: Vec::new(), pending: pending(), post_id: 0 }
196            }
197        },
198        "comment-section" => rsx! {
199            CommentSectionContent {
200                post_id: 0,
201                comments: Some(approved()),
202                approved_count: Some(approved().len() as i64),
203            }
204        },
205        _ => unreachable!("预览入口已按 slug 过滤"),
206    };
207
208    rsx! {
209        div { class: "w-full min-w-0",
210            if detail {
211                p { class: "mb-4 text-xs text-paper-tertiary", "本地演示,评论不会保存,也不会上传图片。" }
212                if slug == "comment-section" {
213                    div { class: "flex flex-wrap gap-2 mb-4",
214                        button {
215                            r#type: "button",
216                            class: "px-3 py-1.5 rounded-full border border-[var(--color-paper-border)] text-xs text-paper-secondary hover:text-paper-primary",
217                            onclick: move |_| { approved.set(Vec::new()); pending.set(Vec::new()); },
218                            "空列表"
219                        }
220                        button {
221                            r#type: "button",
222                            class: "px-3 py-1.5 rounded-full border border-[var(--color-paper-border)] text-xs text-paper-secondary hover:text-paper-primary",
223                            onclick: move |_| { approved.set(approved_sample()); pending.set(Vec::new()); },
224                            "仅已审核"
225                        }
226                        button {
227                            r#type: "button",
228                            class: "px-3 py-1.5 rounded-full border border-[var(--color-paper-border)] text-xs text-paper-secondary hover:text-paper-primary",
229                            onclick: move |_| { approved.set(sample.clone()); pending.set(vec![pending_sample()]); },
230                            "评论与待审核"
231                        }
232                    }
233                }
234            }
235            {visible}
236        }
237    }
238}
239
240#[derive(Clone, Copy, PartialEq, Eq)]
241enum CommentItemMode {
242    Standard,
243    ReplyDraft,
244    AuthorTypes,
245    DeepNesting,
246}
247
248#[component]
249fn CommentItemPreview(detail: bool) -> Element {
250    let mut mode = use_signal(|| CommentItemMode::Standard);
251    let mut active_reply_sig = use_signal(|| None::<i64>);
252    let approved_store = use_signal(Vec::<PublicComment>::new);
253
254    let _ctx = use_context_provider(|| CommentContext {
255        active_reply: active_reply_sig,
256        refresh_trigger: Signal::new(false),
257        pending_comments: Signal::new(Vec::new()),
258        current_user: Signal::new(None),
259        source: CommentSource::Local {
260            approved: approved_store,
261        },
262        id_scope: "showcase-comment-item",
263    });
264
265    if !detail {
266        let parent = PublicComment {
267            id: 101,
268            parent_id: None,
269            depth: 0,
270            author_name: "青禾".to_string(),
271            author_url: None,
272            avatar_url: String::new(),
273            is_author: true,
274            content_html: Some("<p>春天的第一片叶子,总会记得来时的风。</p>".to_string()),
275            created_at: "昨天 17:30".to_string(),
276            created_at_iso: "2026-09-22T10:00:00Z".to_string(),
277        };
278        let reply = PublicComment {
279            id: 102,
280            parent_id: Some(101),
281            depth: 1,
282            author_name: "阿木".to_string(),
283            author_url: None,
284            avatar_url: String::new(),
285            is_author: false,
286            content_html: Some("<p>我也喜欢这句话。</p>".to_string()),
287            created_at: "昨天 18:02".to_string(),
288            created_at_iso: "2026-09-22T11:00:00Z".to_string(),
289        };
290        return rsx! {
291            div { class: "comment-list w-full min-w-0 divide-y divide-[var(--color-paper-border)]/40 px-1",
292                CommentItem { comment: parent, post_id: 0 }
293                CommentItem { comment: reply, post_id: 0 }
294            }
295        };
296    }
297
298    let items = match mode() {
299        CommentItemMode::Standard => {
300            let parent = PublicComment {
301                id: 101,
302                parent_id: None,
303                depth: 0,
304                author_name: "青禾".to_string(),
305                author_url: None,
306                avatar_url: String::new(),
307                is_author: true,
308                content_html: Some("<p>春天的第一片叶子,总会记得来时的风。</p>".to_string()),
309                created_at: "昨天 17:30".to_string(),
310                created_at_iso: "2026-09-22T10:00:00Z".to_string(),
311            };
312            let reply = PublicComment {
313                id: 102,
314                parent_id: Some(101),
315                depth: 1,
316                author_name: "阿木".to_string(),
317                author_url: None,
318                avatar_url: String::new(),
319                is_author: false,
320                content_html: Some("<p>我也喜欢这句话,愿新的一年枝繁叶茂。</p>".to_string()),
321                created_at: "昨天 18:02".to_string(),
322                created_at_iso: "2026-09-22T11:00:00Z".to_string(),
323            };
324            rsx! {
325                CommentItem { comment: parent, post_id: 0 }
326                CommentItem { comment: reply, post_id: 0 }
327            }
328        }
329        CommentItemMode::ReplyDraft => {
330            let parent = PublicComment {
331                id: 101,
332                parent_id: None,
333                depth: 0,
334                author_name: "青禾".to_string(),
335                author_url: None,
336                avatar_url: String::new(),
337                is_author: true,
338                content_html: Some("<p>春天的第一片叶子,总会记得来时的风。</p>".to_string()),
339                created_at: "昨天 17:30".to_string(),
340                created_at_iso: "2026-09-22T10:00:00Z".to_string(),
341            };
342            let reply = PublicComment {
343                id: 102,
344                parent_id: Some(101),
345                depth: 1,
346                author_name: "阿木".to_string(),
347                author_url: None,
348                avatar_url: String::new(),
349                is_author: false,
350                content_html: Some(
351                    "<p>我也喜欢这句话,点击下方回复体验草稿保留功能。</p>".to_string(),
352                ),
353                created_at: "昨天 18:02".to_string(),
354                created_at_iso: "2026-09-22T11:00:00Z".to_string(),
355            };
356            rsx! {
357                CommentItem { comment: parent, post_id: 0 }
358                CommentItem { comment: reply, post_id: 0 }
359            }
360        }
361        CommentItemMode::AuthorTypes => {
362            let author_comment = PublicComment {
363                id: 301,
364                parent_id: None,
365                depth: 0,
366                author_name: "青禾".to_string(),
367                author_url: None,
368                avatar_url: String::new(),
369                is_author: true,
370                content_html: Some(
371                    "<p>博主身份演示:带有专属绿色高亮的<strong>作者</strong>徽章。</p>"
372                        .to_string(),
373                ),
374                created_at: "昨天 17:30".to_string(),
375                created_at_iso: "2026-09-22T10:00:00Z".to_string(),
376            };
377            let visitor_with_url = PublicComment {
378                id: 302,
379                parent_id: None,
380                depth: 0,
381                author_name: "远方的小树".to_string(),
382                author_url: Some("https://example.com".to_string()),
383                avatar_url: String::new(),
384                is_author: false,
385                content_html: Some(
386                    "<p>附带外链访客:作者昵称展示为外部主页链接(在新标签页安全打开,带 <code>rel=\"nofollow noopener\"</code> 属性)。</p>"
387                        .to_string(),
388                ),
389                created_at: "昨天 18:20".to_string(),
390                created_at_iso: "2026-09-22T11:00:00Z".to_string(),
391            };
392            let plain_visitor = PublicComment {
393                id: 303,
394                parent_id: None,
395                depth: 0,
396                author_name: "林间漫步".to_string(),
397                author_url: None,
398                avatar_url: String::new(),
399                is_author: false,
400                content_html: Some(
401                    "<p>普通访客:未提供个人主页链接,昵称渲染为普通加粗文本。</p>".to_string(),
402                ),
403                created_at: "昨天 19:00".to_string(),
404                created_at_iso: "2026-09-22T12:00:00Z".to_string(),
405            };
406            rsx! {
407                CommentItem { comment: author_comment, post_id: 0 }
408                CommentItem { comment: visitor_with_url, post_id: 0 }
409                CommentItem { comment: plain_visitor, post_id: 0 }
410            }
411        }
412        CommentItemMode::DeepNesting => {
413            let c0 = PublicComment {
414                id: 401,
415                parent_id: None,
416                depth: 0,
417                author_name: "青禾".to_string(),
418                author_url: None,
419                avatar_url: String::new(),
420                is_author: true,
421                content_html: Some(
422                    "<p>顶层评论(depth = 0):无左侧边框引导线与 margin 缩进。</p>".to_string(),
423                ),
424                created_at: "10 分钟前".to_string(),
425                created_at_iso: "2026-09-22T10:00:00Z".to_string(),
426            };
427            let c1 = PublicComment {
428                id: 402,
429                parent_id: Some(401),
430                depth: 1,
431                author_name: "阿木".to_string(),
432                author_url: None,
433                avatar_url: String::new(),
434                is_author: false,
435                content_html: Some(
436                    "<p>一级嵌套(depth = 1):启用左侧引导线 <code>border-l-2</code>,建立父子回复视觉联系。</p>"
437                        .to_string(),
438                ),
439                created_at: "8 分钟前".to_string(),
440                created_at_iso: "2026-09-22T10:02:00Z".to_string(),
441            };
442            let c2 = PublicComment {
443                id: 403,
444                parent_id: Some(402),
445                depth: 2,
446                author_name: "山风".to_string(),
447                author_url: None,
448                avatar_url: String::new(),
449                is_author: false,
450                content_html: Some(
451                    "<p>二级嵌套(depth = 2):在引导线基础上叠加 16px 阶梯缩进,凸显回复层级。</p>"
452                        .to_string(),
453                ),
454                created_at: "6 分钟前".to_string(),
455                created_at_iso: "2026-09-22T10:04:00Z".to_string(),
456            };
457            let c3 = PublicComment {
458                id: 404,
459                parent_id: Some(403),
460                depth: 3,
461                author_name: "海棠".to_string(),
462                author_url: None,
463                avatar_url: String::new(),
464                is_author: false,
465                content_html: Some(
466                    "<p>三级嵌套(depth = 3):叠加 32px 阶梯缩进,树形关系更深入。</p>"
467                        .to_string(),
468                ),
469                created_at: "4 分钟前".to_string(),
470                created_at_iso: "2026-09-22T10:06:00Z".to_string(),
471            };
472            let c20 = PublicComment {
473                id: 420,
474                parent_id: Some(404),
475                depth: 20,
476                author_name: "边界防护测试".to_string(),
477                author_url: None,
478                avatar_url: String::new(),
479                is_author: false,
480                content_html: Some(
481                    "<p>极限深度(depth = 20):到达最大嵌套深度防护限制,下方回复按钮自动隐藏,防止无限嵌套破坏页面排版。</p>"
482                        .to_string(),
483                ),
484                created_at: "刚刚".to_string(),
485                created_at_iso: "2026-09-22T10:10:00Z".to_string(),
486            };
487            rsx! {
488                CommentItem { comment: c0, post_id: 0 }
489                CommentItem { comment: c1, post_id: 0 }
490                CommentItem { comment: c2, post_id: 0 }
491                CommentItem { comment: c3, post_id: 0 }
492                CommentItem { comment: c20, post_id: 0 }
493            }
494        }
495    };
496
497    rsx! {
498        div { class: "w-full min-w-0",
499            p { class: "mb-3 text-xs text-paper-tertiary", "本地演示,可切换不同形态体验 CommentItem 的回复表单联动、草稿保留与身份样式。" }
500            div { class: "flex flex-wrap items-center gap-2 mb-3",
501                for (mode_val, label) in [
502                    (CommentItemMode::Standard, "标准回复链"),
503                    (CommentItemMode::ReplyDraft, "展开回复与草稿"),
504                    (CommentItemMode::AuthorTypes, "身份与外链"),
505                    (CommentItemMode::DeepNesting, "深层缩进与边界 (depth=20)"),
506                ] {
507                    button {
508                        key: "{label}",
509                        r#type: "button",
510                        class: if mode() == mode_val {
511                            "px-3 py-1.5 rounded-full border border-[var(--color-paper-accent)] text-xs font-medium text-[var(--color-paper-accent)] bg-[var(--color-paper-accent)]/10 transition-colors cursor-pointer"
512                        } else {
513                            "px-3 py-1.5 rounded-full border border-[var(--color-paper-border)] text-xs text-paper-secondary hover:text-paper-primary hover:border-[var(--color-paper-border)]/80 transition-colors cursor-pointer"
514                        },
515                        aria_pressed: "{mode() == mode_val}",
516                        onclick: move |_| {
517                            mode.set(mode_val);
518                            if mode_val == CommentItemMode::ReplyDraft {
519                                active_reply_sig.set(Some(101));
520                            } else {
521                                active_reply_sig.set(None);
522                            }
523                        },
524                        "{label}"
525                    }
526                }
527            }
528            p { class: "text-xs text-paper-secondary mb-4 leading-relaxed",
529                match mode() {
530                    CommentItemMode::Standard => "标准嵌套链:展示博主顶层评论与嵌套子回复,点击“回复”可展开内嵌表单并输入草稿。",
531                    CommentItemMode::ReplyDraft => "回复展开与草稿保留:展开内嵌 CommentForm 表单,表单自动通过负 margin 抵消嵌套缩进;取消回复再重新打开时保留草稿。",
532                    CommentItemMode::AuthorTypes => "身份与外链差异:对比博主专属“作者”徽章、访客个人主页外链(在新标签打开)、以及普通访客纯文本昵称。",
533                    CommentItemMode::DeepNesting => "深层缩进与边界防护:展示 depth=1 至 depth=3 的递进缩进与引导线;当深度达到 depth >= 20 时,自动隐藏回复按钮防止无限嵌套破坏版面。",
534                }
535            }
536            div { class: "rounded-2xl border border-[var(--color-paper-border)]/70 bg-[var(--color-paper-entry)]/30 p-4 sm:p-5 shadow-xs",
537                div { class: "comment-list space-y-0 divide-y divide-[var(--color-paper-border)]/40",
538                    {items}
539                }
540            }
541        }
542    }
543}
544
545#[derive(Clone, Copy, PartialEq, Eq)]
546enum CommentListMode {
547    Merged,
548    ApprovedOnly,
549    Empty,
550    ComplexTree,
551}
552
553#[component]
554fn CommentListPreview(detail: bool) -> Element {
555    let mut mode = use_signal(|| CommentListMode::Merged);
556    let active_reply_sig = use_signal(|| None::<i64>);
557    let approved_store = use_signal(Vec::<PublicComment>::new);
558
559    let _ctx = use_context_provider(|| CommentContext {
560        active_reply: active_reply_sig,
561        refresh_trigger: Signal::new(false),
562        pending_comments: Signal::new(Vec::new()),
563        current_user: Signal::new(None),
564        source: CommentSource::Local {
565            approved: approved_store,
566        },
567        id_scope: "showcase-comment-list",
568    });
569
570    if !detail {
571        let comments = vec![PublicComment {
572            id: 101,
573            parent_id: None,
574            depth: 0,
575            author_name: "青禾".to_string(),
576            author_url: None,
577            avatar_url: String::new(),
578            is_author: true,
579            content_html: Some("<p>春天的第一片叶子,总会记得来时的风。</p>".to_string()),
580            created_at: "昨天 17:30".to_string(),
581            created_at_iso: "2026-09-22T10:00:00Z".to_string(),
582        }];
583        let pending = vec![PendingComment {
584            id: -10,
585            parent_id: Some(101),
586            depth: 1,
587            author_name: "叶子访客".to_string(),
588            author_url: None,
589            avatar_url: String::new(),
590            content_md: "这条样例评论正在等待审核。".to_string(),
591            created_at: "2026-09-22T10:30:00Z".to_string(),
592            stored_at: "2026-09-22T10:30:00Z".to_string(),
593        }];
594        return rsx! {
595            div { class: "w-full min-w-0 px-1",
596                CommentList { comments, pending, post_id: 0 }
597            }
598        };
599    }
600
601    let (comments, pending) = match mode() {
602        CommentListMode::Merged => (approved_sample(), vec![pending_sample()]),
603        CommentListMode::ApprovedOnly => (approved_sample(), Vec::new()),
604        CommentListMode::Empty => (Vec::new(), Vec::new()),
605        CommentListMode::ComplexTree => {
606            let tree_approved = vec![
607                PublicComment {
608                    id: 201,
609                    parent_id: None,
610                    depth: 0,
611                    author_name: "青禾".to_string(),
612                    author_url: None,
613                    avatar_url: String::new(),
614                    is_author: true,
615                    content_html: Some("<p>新功能组件图鉴上线,欢迎大家体验!</p>".to_string()),
616                    created_at: "昨天 10:00".to_string(),
617                    created_at_iso: "2026-09-22T10:00:00Z".to_string(),
618                },
619                PublicComment {
620                    id: 202,
621                    parent_id: Some(201),
622                    depth: 1,
623                    author_name: "阿木".to_string(),
624                    author_url: None,
625                    avatar_url: String::new(),
626                    is_author: false,
627                    content_html: Some("<p>交互非常流畅,尤其细节动画很精致。</p>".to_string()),
628                    created_at: "昨天 11:30".to_string(),
629                    created_at_iso: "2026-09-22T11:30:00Z".to_string(),
630                },
631                PublicComment {
632                    id: 203,
633                    parent_id: Some(202),
634                    depth: 2,
635                    author_name: "青禾".to_string(),
636                    author_url: None,
637                    avatar_url: String::new(),
638                    is_author: true,
639                    content_html: Some("<p>感谢支持,我们会继续完善体验。</p>".to_string()),
640                    created_at: "昨天 12:00".to_string(),
641                    created_at_iso: "2026-09-22T12:00:00Z".to_string(),
642                },
643                PublicComment {
644                    id: 204,
645                    parent_id: None,
646                    depth: 0,
647                    author_name: "林间微风".to_string(),
648                    author_url: None,
649                    avatar_url: String::new(),
650                    is_author: false,
651                    content_html: Some("<p>请问后台支持自定义主题色配置吗?</p>".to_string()),
652                    created_at: "昨天 14:00".to_string(),
653                    created_at_iso: "2026-09-22T14:00:00Z".to_string(),
654                },
655                PublicComment {
656                    id: 205,
657                    parent_id: Some(99999), // 孤儿评论:指向不存在的父评论
658                    depth: 1,
659                    author_name: "探索者".to_string(),
660                    author_url: None,
661                    avatar_url: String::new(),
662                    is_author: false,
663                    content_html: Some(
664                        "<p>这是指向已失效父评论的孤儿评论(parent_id = 99999)。CommentList 自动将其降为顶层展示,确保评论不会静默丢失。</p>"
665                            .to_string(),
666                    ),
667                    created_at: "昨天 15:00".to_string(),
668                    created_at_iso: "2026-09-22T15:00:00Z".to_string(),
669                },
670            ];
671            let tree_pending = vec![PendingComment {
672                id: -20,
673                parent_id: Some(204),
674                depth: 1,
675                author_name: "叶子访客".to_string(),
676                author_url: None,
677                avatar_url: String::new(),
678                content_md: "支持的,可以在系统设置中调整调色板。".to_string(),
679                created_at: "2026-09-22T14:30:00Z".to_string(),
680                stored_at: "2026-09-22T14:30:00Z".to_string(),
681            }];
682            (tree_approved, tree_pending)
683        }
684    };
685
686    rsx! {
687        div { class: "w-full min-w-0",
688            p { class: "mb-3 text-xs text-paper-tertiary", "本地演示,可切换不同形态体验 CommentList 的树形合并、排序与孤儿容错。" }
689            div { class: "flex flex-wrap items-center gap-2 mb-3",
690                for (mode_val, label) in [
691                    (CommentListMode::Merged, "评论与待审核"),
692                    (CommentListMode::ApprovedOnly, "仅已审核"),
693                    (CommentListMode::Empty, "空列表"),
694                    (CommentListMode::ComplexTree, "多分支与孤儿容错"),
695                ] {
696                    button {
697                        key: "{label}",
698                        r#type: "button",
699                        class: if mode() == mode_val {
700                            "px-3 py-1.5 rounded-full border border-[var(--color-paper-accent)] text-xs font-medium text-[var(--color-paper-accent)] bg-[var(--color-paper-accent)]/10 transition-colors cursor-pointer"
701                        } else {
702                            "px-3 py-1.5 rounded-full border border-[var(--color-paper-border)] text-xs text-paper-secondary hover:text-paper-primary hover:border-[var(--color-paper-border)]/80 transition-colors cursor-pointer"
703                        },
704                        aria_pressed: "{mode() == mode_val}",
705                        onclick: move |_| {
706                            mode.set(mode_val);
707                        },
708                        "{label}"
709                    }
710                }
711            }
712            p { class: "text-xs text-paper-secondary mb-4 leading-relaxed",
713                match mode() {
714                    CommentListMode::Merged => "评论与待审核:服务端已审核评论与本地待审核评论统一合并为树形结构;待审核评论展示为带呼吸指示灯的半透明卡片,按时间升序插入对应层级。",
715                    CommentListMode::ApprovedOnly => "仅已审核:纯已审核评论树,展现多级回复的深度缩进、左侧引导线与完整回复交互。",
716                    CommentListMode::Empty => "空列表:无任何评论时的缺省占位展示,引导访客成为首位留言者。",
717                    CommentListMode::ComplexTree => "多分支与孤儿容错:展示多个并行顶层主题、多级嵌套回复,以及父评论缺失时的孤儿评论容错恢复(自动升级为顶层展示)。",
718                }
719            }
720            div { class: "rounded-2xl border border-[var(--color-paper-border)]/70 bg-[var(--color-paper-entry)]/30 p-4 sm:p-5 shadow-xs",
721                if comments.is_empty() && pending.is_empty() {
722                    div { class: "text-center py-8 px-4 rounded-2xl border border-dashed border-[var(--color-paper-border)] text-paper-secondary",
723                        "暂无评论"
724                    }
725                } else {
726                    CommentList { comments, pending, post_id: 0 }
727                }
728            }
729        }
730    }
731}
732
733#[derive(Clone, Copy, PartialEq, Eq)]
734enum CardShellMode {
735    All,
736    TopLevel,
737    NestedReply,
738    DeepIndent,
739    Pending,
740}
741
742#[component]
743fn ShellReplyButton(
744    comment_id: i32,
745    mut active_id: Signal<Option<i32>>,
746    author_name: String,
747) -> Element {
748    let is_active = active_id() == Some(comment_id);
749    rsx! {
750        div { class: "mt-2",
751            button {
752                r#type: "button",
753                class: "inline-flex items-center gap-1 text-xs font-medium px-2 py-1 rounded-md transition-all cursor-pointer",
754                class: if is_active {
755                    "text-[var(--color-paper-accent)] bg-[var(--color-paper-accent)]/10"
756                } else {
757                    "text-paper-tertiary hover:text-paper-accent hover:bg-[var(--color-paper-entry)]"
758                },
759                aria_label: "回复 {author_name}",
760                onclick: move |_| {
761                    if is_active {
762                        active_id.set(None);
763                    } else {
764                        active_id.set(Some(comment_id));
765                    }
766                },
767                svg {
768                    class: "w-3.5 h-3.5",
769                    fill: "none",
770                    stroke: "currentColor",
771                    stroke_width: "2",
772                    view_box: "0 0 24 24",
773                    path {
774                        stroke_linecap: "round",
775                        stroke_linejoin: "round",
776                        d: "M3 10h10a5 5 0 015 5v2m-15-7l4-4m-4 4l4 4",
777                    }
778                }
779                if is_active { "取消回复" } else { "回复" }
780            }
781            if is_active {
782                div { class: "mt-2.5 flex items-center gap-2 p-2.5 rounded-xl border border-[var(--color-paper-border)] bg-[var(--color-paper-entry)]/60 text-xs text-paper-secondary animate-in fade-in duration-200",
783                    span { class: "size-1.5 rounded-full bg-[var(--color-paper-accent)] shrink-0" }
784                    span { "children 插槽生效中:可挂载回复表单或快捷操作栏。" }
785                }
786            }
787        }
788    }
789}
790
791#[component]
792fn CommentCardShellPreview(detail: bool) -> Element {
793    let mut mode = use_signal(|| CardShellMode::All);
794    let replying_to = use_signal(|| None::<i32>);
795
796    let render_comment = move |id: i32,
797                               depth: i32,
798                               muted: bool,
799                               author: &'static str,
800                               is_author: bool,
801                               time: &'static str,
802                               is_pending: bool,
803                               html: &'static str| {
804        let author_element = rsx! {
805            span { class: "font-medium text-paper-primary", "{author}" }
806        };
807        let author_badge = if is_author {
808            rsx! {
809                span {
810                    class: "px-1.5 py-0.5 rounded text-[10px] font-medium bg-[var(--color-paper-accent)]/10 text-[var(--color-paper-accent)]",
811                    "作者"
812                }
813            }
814        } else {
815            rsx! {}
816        };
817        let timestamp = rsx! {
818            span { class: "text-paper-tertiary", "{time}" }
819        };
820        let status_badge = if is_pending {
821            rsx! {
822                span {
823                    class: "inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[10px] font-medium bg-amber-500/10 text-amber-600 dark:text-amber-400 border border-amber-500/20",
824                    span { class: "w-1.5 h-1.5 rounded-full bg-amber-500 animate-pulse" }
825                    "审核中"
826                }
827            }
828        } else {
829            rsx! {}
830        };
831        if is_pending {
832            rsx! {
833                CommentCardShell {
834                    depth,
835                    muted,
836                    avatar_url: String::new(),
837                    author_name: author.to_string(),
838                    author_element,
839                    author_badge,
840                    timestamp,
841                    status_badge,
842                    content_html: html.to_string(),
843                }
844            }
845        } else {
846            rsx! {
847                CommentCardShell {
848                    depth,
849                    muted,
850                    avatar_url: String::new(),
851                    author_name: author.to_string(),
852                    author_element,
853                    author_badge,
854                    timestamp,
855                    status_badge,
856                    content_html: html.to_string(),
857                    content_extra_class: "md-content",
858
859                    ShellReplyButton {
860                        comment_id: id,
861                        active_id: replying_to,
862                        author_name: author.to_string(),
863                    }
864                }
865            }
866        }
867    };
868
869    let items = match mode() {
870        CardShellMode::All => rsx! {
871            {render_comment(1, 0, false, "青禾", true, "昨天 17:30", false, "<p>春天的第一片叶子,总会记得来时的风。</p>")}
872            {render_comment(2, 1, false, "阿木", false, "昨天 18:02", false, "<p>我也喜欢这句话,愿新的一年枝繁叶茂。</p>")}
873            {render_comment(3, 2, false, "青禾", true, "今天 09:15", false, "<p>谢谢,愿你也有好天气。</p>")}
874            {render_comment(4, 2, true, "叶子访客", false, "刚刚", true, "<p>这条样例回复正在等待审核,卡片整体呈半透明淡化展示。</p>")}
875        },
876        CardShellMode::TopLevel => rsx! {
877            {render_comment(1, 0, false, "青禾", true, "昨天 17:30", false, "<p>春天的第一片叶子,总会记得来时的风。</p>")}
878        },
879        CardShellMode::NestedReply => rsx! {
880            {render_comment(2, 1, false, "阿木", false, "昨天 18:02", false, "<p>我也喜欢这句话,愿新的一年枝繁叶茂。</p>")}
881        },
882        CardShellMode::DeepIndent => rsx! {
883            {render_comment(3, 2, false, "青禾", true, "今天 09:15", false, "<p>谢谢,愿你也有好天气。</p>")}
884        },
885        CardShellMode::Pending => rsx! {
886            {render_comment(4, 0, true, "叶子访客", false, "刚刚", true, "<p>这条样例评论正在等待审核,整卡呈现 75% 不透明度与审核中徽章。</p>")}
887        },
888    };
889
890    if !detail {
891        return rsx! {
892            div { class: "w-full min-w-0 divide-y divide-[var(--color-paper-border)]/40 px-1",
893                {render_comment(1, 0, false, "青禾", true, "昨天 17:30", false, "<p>春天的第一片叶子,总会记得来时的风。</p>")}
894                {render_comment(2, 1, false, "阿木", false, "昨天 18:02", false, "<p>我也喜欢这句话,愿枝繁叶茂。</p>")}
895            }
896        };
897    }
898
899    rsx! {
900        div { class: "w-full min-w-0",
901            p { class: "mb-3 text-xs text-paper-tertiary", "本地演示,切换下方的形态标签可预览卡片骨架的不同状态与缩进效果。" }
902            div { class: "flex flex-wrap items-center gap-2 mb-3",
903                for (mode_val, label) in [
904                    (CardShellMode::All, "全部形态"),
905                    (CardShellMode::TopLevel, "顶层评论 (depth=0)"),
906                    (CardShellMode::NestedReply, "嵌套回复 (depth=1)"),
907                    (CardShellMode::DeepIndent, "深层嵌套 (depth=2)"),
908                    (CardShellMode::Pending, "待审核 (muted)"),
909                ] {
910                    button {
911                        key: "{label}",
912                        r#type: "button",
913                        class: if mode() == mode_val {
914                            "px-3 py-1.5 rounded-full border border-[var(--color-paper-accent)] text-xs font-medium text-[var(--color-paper-accent)] bg-[var(--color-paper-accent)]/10 transition-colors cursor-pointer"
915                        } else {
916                            "px-3 py-1.5 rounded-full border border-[var(--color-paper-border)] text-xs text-paper-secondary hover:text-paper-primary hover:border-[var(--color-paper-border)]/80 transition-colors cursor-pointer"
917                        },
918                        aria_pressed: "{mode() == mode_val}",
919                        onclick: move |_| {
920                            mode.set(mode_val);
921                        },
922                        "{label}"
923                    }
924                }
925            }
926            p { class: "text-xs text-paper-secondary mb-4 leading-relaxed",
927                match mode() {
928                    CardShellMode::All => "完整树形展示:包含顶层评论、一级嵌套引导线、二级阶梯缩进与待审核半透明状态。",
929                    CardShellMode::TopLevel => "顶层评论(depth = 0):无左侧边框与缩进,包含头像、作者徽章、绝对时间与回复操作。",
930                    CardShellMode::NestedReply => "一级嵌套(depth = 1):启用左侧引导线 border-l-2,区分父子回复关系。",
931                    CardShellMode::DeepIndent => "深层嵌套(depth = 2):在左侧引导线基础上叠加 margin-left 阶梯缩进。",
932                    CardShellMode::Pending => "待审核状态(muted = true):整卡 opacity-75 淡化,配合'审核中'徽章与呼吸指示灯。",
933                }
934            }
935            div { class: "rounded-2xl border border-[var(--color-paper-border)]/70 bg-[var(--color-paper-entry)]/30 p-4 sm:p-5 divide-y divide-[var(--color-paper-border)]/40 shadow-xs",
936                {items}
937            }
938        }
939    }
940}
941
942#[cfg(test)]
943mod tests {
944    use super::*;
945
946    #[test]
947    fn comment_previews_dispatch_properly() {
948        assert!(preview("comment-card-shell", false).is_some());
949        assert!(preview("comment-card-shell", true).is_some());
950        assert!(preview("comment-item", false).is_some());
951        assert!(preview("comment-item", true).is_some());
952        assert!(preview("comment-list", false).is_some());
953        assert!(preview("comment-list", true).is_some());
954        assert!(preview("pending-comment-item", false).is_some());
955        assert!(preview("comment-form", false).is_some());
956        assert!(preview("comment-section", false).is_some());
957        assert!(preview("unknown-slug", false).is_none());
958    }
959}