1use 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), 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}