1use 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
48fn 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
145fn 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
187fn 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
212fn 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
230fn 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 #[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
422fn 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 ..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#[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}