Skip to main content

yggdrasil/pages/
components_showcase.rs

1//! 项目自写组件的公开图鉴与独立用法页。
2//!
3//! 目录、文案和属性说明来自本模块的静态数据;预览直接渲染项目里的组件。
4use std::sync::LazyLock;
5
6use chrono::{DateTime, Utc};
7#[cfg(target_arch = "wasm32")]
8use dioxus::html::{InteractionElementOffset, InteractionLocation};
9use dioxus::prelude::*;
10#[cfg(target_arch = "wasm32")]
11use dioxus::web::WebEventExt;
12use serde::Deserialize;
13#[cfg(target_arch = "wasm32")]
14use wasm_bindgen::JsCast;
15
16use crate::components::forms::FormInput;
17use crate::models::post::{Post, PostListItem, PostStatus};
18use crate::router::Route;
19
20mod browser_previews;
21mod business_previews;
22mod comment_previews;
23mod layout_article_previews;
24
25#[derive(Clone, Default, PartialEq)]
26pub struct ShowcaseFilters {
27    pub category: String,
28    pub search: String,
29    pub return_to: Option<String>,
30}
31
32#[derive(Deserialize)]
33struct ShowcaseData {
34    groups: Vec<[String; 2]>,
35    components: Vec<ComponentSpec>,
36}
37
38#[derive(Deserialize)]
39struct ComponentSpec {
40    name: String,
41    slug: String,
42    label: String,
43    group: String,
44    summary: String,
45    states: String,
46    description: String,
47    props: Vec<[String; 4]>,
48    keys: Vec<[String; 2]>,
49    source: String,
50    code: String,
51    preview_mode: PreviewMode,
52    usage_kind: UsageKind,
53}
54
55#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq)]
56#[serde(rename_all = "snake_case")]
57enum PreviewMode {
58    Interactive,
59    Static,
60}
61
62#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq)]
63#[serde(rename_all = "snake_case")]
64enum UsageKind {
65    Rust,
66    Api,
67    Router,
68    Browser,
69}
70
71static SHOWCASE: LazyLock<ShowcaseData> = LazyLock::new(|| {
72    serde_json::from_str(include_str!("components_showcase_data.json"))
73        .expect("组件图鉴数据必须是有效 JSON")
74});
75
76fn group_label(group: &str) -> &str {
77    SHOWCASE
78        .groups
79        .iter()
80        .find(|item| item[0] == group)
81        .map(|item| item[1].as_str())
82        .unwrap_or("全部组件")
83}
84
85struct CodeToken<'a> {
86    class: Option<&'static str>,
87    text: &'a str,
88}
89
90fn highlight_snippet(code: &str) -> Vec<CodeToken<'_>> {
91    const KEYWORDS: &str = concat!(
92        "as async await const crate else enum export fn for from if impl import in ",
93        "interface let match mod move mut new pub return self static struct super trait ",
94        "type use where while",
95    );
96    let bytes = code.as_bytes();
97    let mut tokens = Vec::new();
98    let mut position = 0;
99    let mut plain_start = 0;
100
101    while position < bytes.len() {
102        let start = position;
103        let class = if bytes[position..].starts_with(b"//") {
104            position = bytes[position..]
105                .iter()
106                .position(|byte| *byte == b'\n')
107                .map_or(bytes.len(), |offset| position + offset);
108            Some("comment")
109        } else if bytes[position..].starts_with(b"/*") {
110            position = bytes[position + 2..]
111                .windows(2)
112                .position(|pair| pair == b"*/")
113                .map_or(bytes.len(), |offset| position + offset + 4);
114            Some("comment")
115        } else if matches!(bytes[position], b'"' | b'`') {
116            let quote = bytes[position];
117            position += 1;
118            while position < bytes.len() {
119                if bytes[position] == b'\\' {
120                    position = (position + 2).min(bytes.len());
121                } else if bytes[position] == quote {
122                    position += 1;
123                    break;
124                } else {
125                    position += 1;
126                }
127            }
128            Some("string")
129        } else if bytes[position].is_ascii_digit() {
130            position += 1;
131            while position < bytes.len()
132                && (bytes[position].is_ascii_alphanumeric() || bytes[position] == b'_')
133            {
134                position += 1;
135            }
136            Some("constant numeric")
137        } else if bytes[position].is_ascii_alphabetic() || bytes[position] == b'_' {
138            let raw_identifier = bytes[position..].starts_with(b"r#");
139            position += if raw_identifier { 2 } else { 1 };
140            while position < bytes.len()
141                && (bytes[position].is_ascii_alphanumeric() || bytes[position] == b'_')
142            {
143                position += 1;
144            }
145            let word = &code[start..position];
146            let next = bytes.get(position).copied();
147            if matches!(word, "true" | "false") {
148                Some("constant language boolean")
149            } else if !raw_identifier && KEYWORDS.split_ascii_whitespace().any(|item| item == word)
150            {
151                Some("keyword")
152            } else if next == Some(b':') && bytes.get(position + 1) != Some(&b':') {
153                Some("meta property object")
154            } else if word.starts_with(|character: char| character.is_ascii_uppercase()) {
155                Some("entity name class")
156            } else if matches!(next, Some(b'(' | b'!')) {
157                Some("entity name function")
158            } else if next == Some(b'{') {
159                Some("keyword")
160            } else {
161                None
162            }
163        } else {
164            position += code[position..].chars().next().unwrap().len_utf8();
165            None
166        };
167
168        if let Some(class) = class {
169            if plain_start < start {
170                tokens.push(CodeToken {
171                    class: None,
172                    text: &code[plain_start..start],
173                });
174            }
175            tokens.push(CodeToken {
176                class: Some(class),
177                text: &code[start..position],
178            });
179            plain_start = position;
180        }
181    }
182
183    if plain_start < code.len() {
184        tokens.push(CodeToken {
185            class: None,
186            text: &code[plain_start..],
187        });
188    }
189    tokens
190}
191
192fn checkbox_snippet(checked: bool, danger: bool) -> String {
193    format!(
194        "let mut checked = use_signal(|| {checked});\n\nrsx! {{\n    label {{\n        Checkbox {{\n            checked: checked(),\n            onchange: move |value| checked.set(value),{}\n        }}\n        \"记住这个小小的选择\"\n    }}\n}}",
195        if danger { "\n            danger: true," } else { "" },
196    )
197}
198
199fn alert_box_preset_message(variant: &str) -> &'static str {
200    match variant {
201        "error" => "保存失败,网络连接似乎中断了。",
202        "default" => "草稿已自动保存至本地缓存。",
203        _ => "已保存,新的想法正在生长。",
204    }
205}
206
207fn alert_box_next_variant(variant: &str) -> &'static str {
208    match variant {
209        "success" => "error",
210        "error" => "default",
211        _ => "success",
212    }
213}
214
215fn alert_box_snippet(variant: &str, message: &str) -> String {
216    let escaped_message = message.replace('\\', "\\\\").replace('"', "\\\"");
217    format!(
218        "rsx! {{\n    AlertBox {{\n        message: \"{escaped_message}\",\n        variant: \"{variant}\",\n    }}\n}}"
219    )
220}
221
222#[component]
223fn AlertBoxDemo(
224    detail: bool,
225    mut alert_variant: Signal<String>,
226    mut alert_message: Signal<String>,
227) -> Element {
228    let current_variant = alert_variant();
229    let variant_str: &'static str = match current_variant.as_str() {
230        "error" => "error",
231        "default" => "default",
232        _ => "success",
233    };
234
235    rsx! {
236        div { class: if detail { "showcase-alert-demo showcase-alert-detail" } else { "showcase-alert-demo" },
237            div { class: "showcase-alert-switcher", role: "tablist", aria_label: "切换提示状态",
238                button {
239                    r#type: "button",
240                    class: if variant_str == "success" { "state-success" } else { "" },
241                    aria_pressed: "{variant_str == \"success\"}",
242                    onclick: move |_| {
243                        alert_variant.set("success".to_string());
244                        alert_message.set(alert_box_preset_message("success").to_string());
245                    },
246                    span { class: "dot dot-success" }
247                    "成功"
248                }
249                button {
250                    r#type: "button",
251                    class: if variant_str == "error" { "state-error" } else { "" },
252                    aria_pressed: "{variant_str == \"error\"}",
253                    onclick: move |_| {
254                        alert_variant.set("error".to_string());
255                        alert_message.set(alert_box_preset_message("error").to_string());
256                    },
257                    span { class: "dot dot-error" }
258                    "错误"
259                }
260                button {
261                    r#type: "button",
262                    class: if variant_str == "default" { "state-default" } else { "" },
263                    aria_pressed: "{variant_str == \"default\"}",
264                    onclick: move |_| {
265                        alert_variant.set("default".to_string());
266                        alert_message.set(alert_box_preset_message("default").to_string());
267                    },
268                    span { class: "dot dot-default" }
269                    "普通"
270                }
271            }
272            div {
273                class: "showcase-alert-box-target",
274                role: "button",
275                tabindex: 0,
276                title: "点击循环切换状态",
277                aria_label: "点击循环切换状态",
278                onclick: move |_| {
279                    let next_v = alert_box_next_variant(&alert_variant());
280                    alert_variant.set(next_v.to_string());
281                    alert_message.set(alert_box_preset_message(next_v).to_string());
282                },
283                onkeydown: move |evt: KeyboardEvent| {
284                    if evt.key() == Key::Enter {
285                        let next_v = alert_box_next_variant(&alert_variant());
286                        alert_variant.set(next_v.to_string());
287                        alert_message.set(alert_box_preset_message(next_v).to_string());
288                    }
289                },
290                crate::components::forms::AlertBox {
291                    message: alert_message(),
292                    variant: variant_str,
293                }
294            }
295            if detail {
296                p { class: "showcase-alert-hint", "点击上方状态切换不同视觉反馈;亦可直接点击提示框或在右侧调整文案" }
297            }
298        }
299    }
300}
301
302#[component]
303pub fn ComponentShowcase() -> Element {
304    let mut filters = use_context::<Signal<ShowcaseFilters>>();
305
306    #[cfg(target_arch = "wasm32")]
307    use_effect(move || {
308        if let Some(window) = web_sys::window() {
309            crate::utils::js::invoke_optional_global(&window, "__initShowcaseMasonry", &[]);
310        }
311    });
312    #[cfg(target_arch = "wasm32")]
313    use_drop(move || {
314        if let Some(window) = web_sys::window() {
315            crate::utils::js::invoke_optional_global(&window, "__disposeShowcaseMasonry", &[]);
316        }
317    });
318
319    let state = filters();
320    let active_group = if state.category.is_empty() {
321        "all"
322    } else {
323        state.category.as_str()
324    };
325    let query = state.search.trim().to_lowercase();
326    let shown: Vec<&ComponentSpec> = SHOWCASE
327        .components
328        .iter()
329        .filter(|spec| {
330            (active_group == "all" || spec.group == active_group)
331                && (query.is_empty()
332                    || spec.name.to_lowercase().contains(&query)
333                    || spec.label.to_lowercase().contains(&query))
334        })
335        .collect();
336    let count = shown.len();
337    let interactive_count = SHOWCASE
338        .components
339        .iter()
340        .filter(|spec| spec.preview_mode == PreviewMode::Interactive)
341        .count();
342    let static_count = SHOWCASE.components.len() - interactive_count;
343
344    use_effect(move || {
345        #[cfg(target_arch = "wasm32")]
346        let return_to = { filters.read().return_to.clone() };
347        #[cfg(target_arch = "wasm32")]
348        if let Some(slug) = return_to {
349            if let Some(target) = web_sys::window()
350                .and_then(|window| window.document())
351                .and_then(|document| document.get_element_by_id(&format!("showcase-{slug}")))
352            {
353                target.scroll_into_view();
354                filters.write().return_to = None;
355            }
356        }
357    });
358
359    rsx! {
360        document::Title { "组件图鉴 · Yggdrasil" }
361        article { class: "showcase showcase-gallery",
362            nav { class: "showcase-breadcrumb", aria_label: "面包屑",
363                Link { to: Route::About {}, "← 关于这里" }
364                span { aria_hidden: "true", "/" }
365                span { aria_current: "page", "组件图鉴" }
366            }
367            header { class: "showcase-hero",
368                div {
369                    p { class: "showcase-eyebrow", span { class: "showcase-dot" } "YGGDRASIL / INTERFACE ATLAS" }
370                    h1 { "组件图鉴" small { "↗ 01—{SHOWCASE.components.len()}" } }
371                    p { class: "showcase-lead", "一枚按钮,一次勾选,一点恰好的回应。" br {} "这里收藏着,让 Yggdrasil 长成自己的界面细节。" }
372                    div { class: "showcase-hero-meta",
373                        span { "{interactive_count} 枚交互预览" }
374                        span { "{static_count} 枚静态展示" }
375                        span { "可以触碰的设计" }
376                    }
377                }
378                ShowcaseStillLife {}
379            }
380            div { class: "showcase-intro-line",
381                p { "好的界面,藏在每一个小小的细节里。" }
382                a { href: "#showcase-collection", "开始翻阅 ↓" }
383            }
384            div { class: "showcase-catalog", id: "showcase-collection",
385                aside { class: "showcase-sidebar",
386                    p { class: "showcase-sidebar-label", "沿着目录,慢慢翻阅" }
387                    div { class: "showcase-search",
388                        FormInput {
389                            r#type: "search",
390                            aria_label: Some("搜索组件"),
391                            placeholder: "搜索组件…",
392                            value: state.search,
393                            oninput: move |event: String| filters.write().search = event,
394                        }
395                    }
396                    nav { class: "showcase-categories", aria_label: "组件分类",
397                        for group in SHOWCASE.groups.iter() {
398                            {
399                                let id = group[0].clone();
400                                let label = group[1].clone();
401                                let selected = active_group == id;
402                                let amount = if id == "all" { SHOWCASE.components.len() } else {
403                                    SHOWCASE.components.iter().filter(|spec| spec.group == id).count()
404                                };
405                                rsx! {
406                                    button {
407                                        key: "{id}",
408                                        r#type: "button",
409                                        class: if selected { "is-active" } else { "" },
410                                        aria_pressed: "{selected}",
411                                        onclick: move |_| filters.write().category = id.clone(),
412                                        "{label}" small { "{amount:02}" }
413                                    }
414                                }
415                            }
416                        }
417                    }
418                    p { class: "showcase-sidebar-note", "设计,发生在使用之中。" br {} "试着勾选、切换或展开。" br {} "每个细节,都有自己的回应。" }
419                }
420                div { class: "showcase-collection",
421                    div { class: "showcase-collection-heading",
422                        h2 { "{group_label(active_group)}" }
423                        small { "{count:02} SPECIMENS" }
424                    }
425                    div { class: "showcase-grid",
426                        for spec in shown.iter() {
427                            {
428                                let slug = spec.slug.clone();
429                                let card_slug = slug.clone();
430                                rsx! {
431                                    article {
432                                        class: "showcase-card",
433                                        id: "showcase-{slug}",
434                                        key: "{slug}",
435                                        onclick: move |event: MouseEvent| {
436                                            #[cfg(not(target_arch = "wasm32"))]
437                                            let _ = event;
438                                            #[cfg(target_arch = "wasm32")]
439                                            {
440                                                let interactive = event.try_as_web_event()
441                                                    .and_then(|event| event.target())
442                                                    .and_then(|target| target.dyn_into::<web_sys::Element>().ok())
443                                                    .and_then(|element| element.closest(
444                                                        "a, button, input, select, textarea, label, summary, [contenteditable], [role=button], [role=tab], .cm-editor, .xterm"
445                                                    ).ok().flatten())
446                                                    .is_some();
447                                                if interactive {
448                                                    return;
449                                                }
450                                            }
451                                            navigator().push(Route::ComponentDetail { component: card_slug.clone() });
452                                        },
453                                        div { class: "showcase-card-preview showcase-card-preview--{slug}",
454                                            span { class: "showcase-card-index", "{spec.name.to_uppercase()} / {group_label(&spec.group)}" }
455                                            ComponentPreview { slug: slug.clone(), detail: false }
456                                        }
457                                        div { class: "showcase-card-caption",
458                                            div { class: "showcase-card-title", h3 { "{spec.label}" } code { "{spec.name}" } }
459                                            p { "{spec.summary}" }
460                                            div { class: "showcase-card-foot",
461                                                span { "{spec.states}" }
462                                                Link { to: Route::ComponentDetail { component: slug.clone() }, "查看用法 ↗" }
463                                            }
464                                        }
465                                    }
466                                }
467                            }
468                        }
469                        if count == 0 {
470                            p { class: "showcase-no-results", "没有找到这个组件,换个名字试试。" }
471                        }
472                    }
473                    div { class: "showcase-composition",
474                        div { h3 { "当枝叶,组成风景。" } p { "素材、文章、评论与编辑器,组成更完整的界面。" } }
475                        span { "素材选择 · 文章卡片 · 评论区 · 页面骨架" }
476                    }
477                }
478            }
479            footer { class: "showcase-closing", em { "细节有回应,界面有温度。" } span { "YGGDRASIL · A COLLECTION OF SMALL DETAILS" } }
480        }
481    }
482}
483
484#[component]
485pub fn ComponentDetail(component: String) -> Element {
486    let mut filters = use_context::<Signal<ShowcaseFilters>>();
487    let mut checked = use_signal(|| true);
488    let mut danger = use_signal(|| false);
489    let mut alert_variant = use_signal(|| "success".to_string());
490    let mut alert_message = use_signal(|| "已保存,新的想法正在生长。".to_string());
491    let mut reset_generation = use_signal(|| 0_u32);
492    #[allow(unused_mut)]
493    let mut copied = use_signal(|| false);
494    let Some((index, spec)) = SHOWCASE
495        .components
496        .iter()
497        .enumerate()
498        .find(|(_, spec)| spec.slug == component)
499    else {
500        #[cfg(feature = "server")]
501        dioxus::fullstack::FullstackContext::commit_http_status(
502            http::StatusCode::NOT_FOUND,
503            Some("Component Not Found".to_string()),
504        );
505        return rsx! {
506            document::Title { "组件未找到 · Yggdrasil" }
507            div { class: "showcase showcase-missing",
508                h1 { "这枚组件尚未收录。" }
509                Link { to: Route::ComponentShowcase {}, "返回组件图鉴 →" }
510            }
511        };
512    };
513    let snippet = if spec.name == "Checkbox" {
514        checkbox_snippet(checked(), danger())
515    } else if spec.name == "AlertBox" {
516        alert_box_snippet(&alert_variant(), &alert_message())
517    } else {
518        spec.code.clone()
519    };
520    #[cfg(target_arch = "wasm32")]
521    let snippet_for_copy = snippet.clone();
522    let highlighted_snippet = highlight_snippet(&snippet);
523    let workarea_class = if spec.preview_mode == PreviewMode::Static {
524        "showcase-live-workarea showcase-live-workarea-static"
525    } else if is_wide_preview(&spec.slug) {
526        "showcase-live-workarea showcase-live-workarea-wide"
527    } else {
528        "showcase-live-workarea"
529    };
530    let previous = index.checked_sub(1).map(|i| &SHOWCASE.components[i]);
531    let next = SHOWCASE.components.get(index + 1);
532
533    rsx! {
534        document::Title { "{spec.label} {spec.name} · 组件图鉴 · Yggdrasil" }
535        article { class: "showcase showcase-detail",
536            nav { class: "showcase-breadcrumb", aria_label: "面包屑",
537                Link { to: Route::About {}, "关于这里" }
538                span { aria_hidden: "true", "/" }
539                Link { to: Route::ComponentShowcase {}, onclick: move |_| filters.write().return_to = Some(component.clone()), "组件图鉴" }
540                span { aria_hidden: "true", "/" }
541                span { aria_current: "page", "{spec.label}" }
542            }
543            div { class: "showcase-detail-layout",
544                aside { class: "showcase-detail-sidebar",
545                    Link { class: "showcase-back", to: Route::ComponentShowcase {}, onclick: move |_| filters.write().return_to = Some(spec.slug.clone()), "← 返回组件图鉴" }
546                    nav { aria_label: "组件目录",
547                        for group in SHOWCASE.groups.iter().skip(1) {
548                            section {
549                                h2 { "{group[1]}" }
550                                for item in SHOWCASE.components.iter().filter(|item| item.group == group[0]) {
551                                    Link {
552                                        key: "{item.slug}",
553                                        class: if item.slug == spec.slug { "is-current" } else { "" },
554                                        aria_current: if item.slug == spec.slug { "page" } else { "false" },
555                                        to: Route::ComponentDetail { component: item.slug.clone() },
556                                        "{item.name}"
557                                    }
558                                }
559                            }
560                        }
561                    }
562                }
563                div { class: "showcase-detail-content",
564                    label { class: "showcase-mobile-select",
565                        "组件目录"
566                        select {
567                            aria_label: "切换组件",
568                            value: "{spec.slug}",
569                            onchange: move |event| { navigator().push(Route::ComponentDetail { component: event.value() }); },
570                            for item in SHOWCASE.components.iter() {
571                                option { value: "{item.slug}", "{item.name} · {item.label}" }
572                            }
573                        }
574                    }
575                    header { class: "showcase-detail-heading",
576                        div {
577                            p { class: "showcase-eyebrow", span { class: "showcase-dot" } "{group_label(&spec.group)} / COMPONENT ATLAS" }
578                            h1 { "{spec.label}" code { "{spec.name}" } }
579                            p { "{spec.description}" }
580                        }
581                        span { class: "showcase-detail-number", aria_hidden: "true", "{index + 1:02}" }
582                    }
583                    nav { class: "showcase-detail-tabs", aria_label: "本页章节",
584                        a { href: "#preview", if spec.preview_mode == PreviewMode::Interactive { "交互预览" } else { "组件预览" } }
585                        a { href: "#usage", "用法" }
586                        a { href: "#properties", "属性" }
587                        if !spec.keys.is_empty() { a { href: "#keyboard", "操作方式" } }
588                    }
589                    section { class: "showcase-detail-section", id: "preview",
590                        div { class: "showcase-section-heading",
591                            h2 { if spec.preview_mode == PreviewMode::Interactive { "亲手试一试。" } else { "组件预览。" } }
592                            span { if spec.preview_mode == PreviewMode::Interactive { "01 / LIVE PREVIEW" } else { "01 / COMPONENT PREVIEW" } }
593                        }
594                        div { class: "showcase-live-panel",
595                            div { class: "showcase-live-toolbar", span { "● {spec.name}" }
596                                if spec.preview_mode == PreviewMode::Interactive {
597                                    button { r#type: "button", onclick: move |_| {
598                                        checked.set(true);
599                                        danger.set(false);
600                                        alert_variant.set("success".to_string());
601                                        alert_message.set(alert_box_preset_message("success").to_string());
602                                        reset_generation.set(reset_generation().wrapping_add(1));
603                                    }, "↻ 恢复默认" }
604                                }
605                            }
606                            div { class: "{workarea_class}",
607                                div { class: "showcase-live-preview",
608                                    for generation in std::iter::once(reset_generation()) {
609                                        div { key: "{spec.slug}-{generation}", class: "showcase-preview-instance",
610                                            ComponentPreview {
611                                                slug: spec.slug.clone(),
612                                                detail: true,
613                                                checked,
614                                                danger,
615                                                alert_variant,
616                                                alert_message,
617                                            }
618                                        }
619                                    }
620                                }
621                                if spec.preview_mode == PreviewMode::Interactive { div { class: "showcase-live-controls",
622                                    if spec.name == "Checkbox" {
623                                        p { "PROPERTIES / 调整属性" }
624                                        div { class: "showcase-property-row", span { "checked" }
625                                            crate::components::forms::ToggleSwitch { checked: checked(), aria_label: Some("设置 checked".to_string()), ontoggle: move |_| checked.toggle() }
626                                        }
627                                        div { class: "showcase-property-row", span { "danger" }
628                                            crate::components::forms::ToggleSwitch { checked: danger(), aria_label: Some("设置 danger".to_string()), ontoggle: move |_| danger.toggle() }
629                                        }
630                                        small { "调整属性,看看细节如何回应。" }
631                                    } else if spec.name == "AlertBox" {
632                                        p { "PROPERTIES / 调整属性" }
633                                        div { class: "showcase-property-col",
634                                            span { class: "showcase-property-name", "variant" }
635                                            div { class: "showcase-alert-switcher showcase-control-switcher", role: "tablist", aria_label: "切换提示状态",
636                                                button {
637                                                    r#type: "button",
638                                                    class: if alert_variant() == "success" { "state-success" } else { "" },
639                                                    aria_pressed: "{alert_variant() == \"success\"}",
640                                                    onclick: move |_| {
641                                                        alert_variant.set("success".to_string());
642                                                        alert_message.set(alert_box_preset_message("success").to_string());
643                                                    },
644                                                    span { class: "dot dot-success" }
645                                                    "成功"
646                                                }
647                                                button {
648                                                    r#type: "button",
649                                                    class: if alert_variant() == "error" { "state-error" } else { "" },
650                                                    aria_pressed: "{alert_variant() == \"error\"}",
651                                                    onclick: move |_| {
652                                                        alert_variant.set("error".to_string());
653                                                        alert_message.set(alert_box_preset_message("error").to_string());
654                                                    },
655                                                    span { class: "dot dot-error" }
656                                                    "错误"
657                                                }
658                                                button {
659                                                    r#type: "button",
660                                                    class: if alert_variant() == "default" { "state-default" } else { "" },
661                                                    aria_pressed: "{alert_variant() == \"default\"}",
662                                                    onclick: move |_| {
663                                                        alert_variant.set("default".to_string());
664                                                        alert_message.set(alert_box_preset_message("default").to_string());
665                                                    },
666                                                    span { class: "dot dot-default" }
667                                                    "普通"
668                                                }
669                                            }
670                                        }
671                                        div { class: "showcase-property-col",
672                                            span { class: "showcase-property-name", "message" }
673                                            crate::components::forms::FormInput {
674                                                class: Some("w-full px-3 py-1.5 text-xs border border-paper-border rounded-xl bg-paper-entry text-paper-primary placeholder:text-paper-tertiary focus:outline-none focus:border-paper-accent focus:ring-1 focus:ring-paper-accent/30 transition-colors duration-200"),
675                                                r#type: "text",
676                                                placeholder: "输入自定义提示文案…",
677                                                value: alert_message(),
678                                                oninput: move |val| alert_message.set(val),
679                                            }
680                                        }
681                                        small { "切换状态或修改文案,观察视觉语义与下方用法代码的联动。" }
682                                    } else {
683                                        p { "STATES / 示例状态" }
684                                        div { class: "showcase-state-chips",
685                                            for state in spec.states.split(" · ") { span { "{state}" } }
686                                        }
687                                        small { if is_local_demo(&spec.slug) { "本地演示,内容不会保存。" } else { "在左侧预览中体验这枚组件。" } }
688                                    }
689                                } }
690                            }
691                            div { class: "showcase-live-status", if spec.name == "Checkbox" { "checked: {checked()}  danger: {danger()}" } else if spec.name == "AlertBox" { "variant: \"{alert_variant()}\"  message: \"{alert_message()}\"" } else if spec.preview_mode == PreviewMode::Interactive { "INTERACTIVE SPECIMEN · 跟随全站主题" } else { "COMPONENT PREVIEW · 跟随全站主题" } }
692                        }
693                    }
694                    section { class: "showcase-detail-section", id: "usage",
695                        div { class: "showcase-section-heading", h2 { "用法" } span { "02 / USAGE" } }
696                        p { class: "showcase-description", if spec.usage_kind == UsageKind::Api { "下方列出组件接口;正式调用由所在页面提供数据与回调。" } else { "下方展示组件的调用方式。" } }
697                        div { class: "showcase-code-panel",
698                            div { class: "showcase-code-toolbar",
699                                span { match spec.usage_kind { UsageKind::Rust => "RUST · DIOXUS RSX", UsageKind::Api => "RUST · 组件接口", UsageKind::Router => "RUST · 路由配置", UsageKind::Browser => "TYPESCRIPT · 浏览器调用" } }
700                                button {
701                                    r#type: "button",
702                                    aria_label: "复制代码",
703                                    onclick: move |_| {
704                                        #[cfg(target_arch = "wasm32")]
705                                        let text = snippet_for_copy.clone();
706                                        #[cfg(target_arch = "wasm32")]
707                                        spawn(async move {
708                                            if let Some(window) = web_sys::window() {
709                                                let clipboard = window.navigator().clipboard();
710                                                if wasm_bindgen_futures::JsFuture::from(clipboard.write_text(&text)).await.is_ok() {
711                                                    copied.set(true);
712                                                }
713                                            }
714                                        });
715                                    },
716                                    if copied() { "✓ 已复制" } else { "复制代码 ↗" }
717                                }
718                            }
719                            pre { code {
720                                for (token_index, token) in highlighted_snippet.iter().enumerate() {
721                                    span { key: "{token_index}", class: token.class.unwrap_or(""), "{token.text}" }
722                                }
723                            } }
724                        }
725                    }
726                    section { class: "showcase-detail-section", id: "properties",
727                        div { class: "showcase-section-heading", h2 { "主要属性" } span { "03 / PROPERTIES" } }
728                        div { class: "showcase-props-wrap",
729                            table { thead { tr { th { "属性 / 类型" } th { "默认值" } th { "说明" } } }
730                                tbody {
731                                    for prop in spec.props.iter() {
732                                        tr { td { code { "{prop[0]}" } small { "{prop[1]}" } } td { "{prop[2]}" } td { "{prop[3]}" } }
733                                    }
734                                }
735                            }
736                        }
737                    }
738                    if !spec.keys.is_empty() {
739                        section { class: "showcase-detail-section", id: "keyboard",
740                            div { class: "showcase-section-heading", h2 { "操作方式" } span { "04 / INTERACTION" } }
741                            div { class: "showcase-key-guide",
742                                for item in spec.keys.iter() { span { kbd { "{item[0]}" } "{item[1]}" } }
743                            }
744                        }
745                    }
746                    nav { class: "showcase-neighbors", aria_label: "相邻组件",
747                        if let Some(item) = previous {
748                            Link { to: Route::ComponentDetail { component: item.slug.clone() }, span { "←" } div { small { "上一枚组件" } strong { "{item.label} · {item.name}" } } }
749                        } else {
750                            Link { to: Route::ComponentShowcase {}, span { "←" } div { small { "全部收藏" } strong { "返回组件图鉴" } } }
751                        }
752                        if let Some(item) = next {
753                            Link { to: Route::ComponentDetail { component: item.slug.clone() }, div { small { "下一枚组件" } strong { "{item.label} · {item.name}" } } span { "→" } }
754                        } else {
755                            Link { to: Route::ComponentShowcase {}, div { small { "继续翻阅" } strong { "返回组件图鉴" } } span { "→" } }
756                        }
757                    }
758                    footer { class: "showcase-detail-colophon", span { "YGGDRASIL · 每一个细节,都有回应。" } code { "{spec.source}" } }
759                }
760            }
761        }
762    }
763}
764
765#[component]
766fn ShowcaseStillLife() -> Element {
767    rsx! {
768        div { class: "showcase-still-life", aria_hidden: "true",
769            div { class: "showcase-orbit" }
770            div { class: "showcase-specimen",
771                span { "A SMALL INTERACTION" }
772                strong { "让细节,轻轻生长。" }
773                div { "一点恰好的回应" i { class: "showcase-specimen-switch" } }
774            }
775            span { class: "showcase-float-mark", "✓ 刚刚好" }
776            span { class: "showcase-float-code", "Checkbox {{ checked: true }}" }
777            span { class: "showcase-life-caption", "FORM · FEEDBACK · FEELING" }
778        }
779    }
780}
781
782#[component]
783fn ComponentPreview(
784    slug: String,
785    detail: bool,
786    #[props(default)] checked: Option<Signal<bool>>,
787    #[props(default)] danger: Option<Signal<bool>>,
788    #[props(default)] alert_variant: Option<Signal<String>>,
789    #[props(default)] alert_message: Option<Signal<String>>,
790) -> Element {
791    let spec = SHOWCASE
792        .components
793        .iter()
794        .find(|item| item.slug == slug)
795        .expect("预览必须对应图鉴条目");
796    match preview_route(&slug).expect("图鉴条目必须有专属预览分派") {
797        PreviewRoute::Live => {
798            rsx! {
799                LiveComponentPreview {
800                    name: spec.name.clone(),
801                    detail,
802                    checked,
803                    danger,
804                    alert_variant,
805                    alert_message,
806                }
807            }
808        }
809        PreviewRoute::Skeleton => rsx! { PageSkeletonPreview { name: spec.name.clone() } },
810        PreviewRoute::Scene => rsx! { SceneComponentPreview { name: spec.name.clone() } },
811        PreviewRoute::Business => {
812            business_previews::preview(&slug, detail).expect("业务预览必须实现")
813        }
814        PreviewRoute::LayoutArticle => {
815            layout_article_previews::preview(&slug, detail).expect("布局与文章预览必须实现")
816        }
817        PreviewRoute::Comment => {
818            comment_previews::preview(&slug, detail).expect("评论预览必须实现")
819        }
820        PreviewRoute::Browser => {
821            browser_previews::preview(&slug, detail).expect("浏览器预览必须实现")
822        }
823    }
824}
825
826#[derive(Clone, Copy, Debug, PartialEq, Eq)]
827enum PreviewRoute {
828    Live,
829    Skeleton,
830    Scene,
831    Business,
832    LayoutArticle,
833    Comment,
834    Browser,
835}
836
837fn preview_route(slug: &str) -> Option<PreviewRoute> {
838    Some(match slug {
839        "checkbox"
840        | "toggle-switch"
841        | "form-select"
842        | "radio"
843        | "loading-button"
844        | "form-input"
845        | "time-picker"
846        | "form-label"
847        | "filter-tabs"
848        | "pagination"
849        | "tooltip"
850        | "popover"
851        | "modal-shell"
852        | "alert-box"
853        | "collapsible-settings-card"
854        | "status-badge"
855        | "tag-chip"
856        | "user-avatar"
857        | "sprout-placeholder"
858        | "empty-state"
859        | "skeleton-box"
860        | "skeleton-card"
861        | "skeleton-table"
862        | "delayed-skeleton" => PreviewRoute::Live,
863        "admin-comments-table-skeleton"
864        | "admin-comments-skeleton"
865        | "archive-skeleton"
866        | "assets-skeleton"
867        | "changelog-skeleton"
868        | "comment-list-skeleton"
869        | "admin-dashboard-skeleton"
870        | "friends-admin-list-skeleton"
871        | "friends-admin-skeleton"
872        | "friends-skeleton"
873        | "home-skeleton"
874        | "home-posts-skeleton"
875        | "logs-skeleton"
876        | "mcp-skeleton"
877        | "post-card-skeleton"
878        | "post-detail-body"
879        | "post-detail-skeleton"
880        | "post-preview-skeleton"
881        | "posts-table-skeleton"
882        | "posts-skeleton"
883        | "posts-trash-table-skeleton"
884        | "posts-trash-skeleton"
885        | "profile-skeleton"
886        | "runner-skeleton"
887        | "search-skeleton"
888        | "settings-admin-skeleton"
889        | "system-skeleton"
890        | "tags-skeleton"
891        | "tag-detail-skeleton"
892        | "tag-posts-loading"
893        | "write-skeleton" => PreviewRoute::Skeleton,
894        "post-cover" | "post-card" => PreviewRoute::Scene,
895        "asset-picker-modal" | "asset-upload-modal" | "code-runner" | "sql-result-table" => {
896            PreviewRoute::Business
897        }
898        "breadcrumbs" | "search-icon-link" | "admin-layout" | "footer" | "frontend-layout"
899        | "header" | "post-header" | "post-meta" | "post-content" | "post-footer"
900        | "post-nav-links" | "post-toc" => PreviewRoute::LayoutArticle,
901        "comment-card-shell"
902        | "comment-form"
903        | "comment-item"
904        | "comment-list"
905        | "pending-comment-item"
906        | "comment-section" => PreviewRoute::Comment,
907        "tiptap-editor" | "code-mirror-editor" | "xterm-terminal" | "lightbox" => {
908            PreviewRoute::Browser
909        }
910        _ => return None,
911    })
912}
913
914fn is_local_demo(slug: &str) -> bool {
915    slug == "admin-layout"
916        || slug == "header"
917        || slug == "search-icon-link"
918        || slug == "frontend-layout"
919        || slug == "footer"
920        || matches!(
921            preview_route(slug),
922            Some(PreviewRoute::Business | PreviewRoute::Comment | PreviewRoute::Browser)
923        )
924}
925
926fn is_wide_preview(slug: &str) -> bool {
927    matches!(
928        preview_route(slug),
929        Some(
930            PreviewRoute::Business
931                | PreviewRoute::LayoutArticle
932                | PreviewRoute::Comment
933                | PreviewRoute::Browser
934        )
935    )
936}
937
938#[component]
939fn LiveComponentPreview(
940    name: String,
941    detail: bool,
942    #[props(default)] checked: Option<Signal<bool>>,
943    #[props(default)] danger: Option<Signal<bool>>,
944    #[props(default)] alert_variant: Option<Signal<String>>,
945    #[props(default)] alert_message: Option<Signal<String>>,
946) -> Element {
947    let local_checked = use_signal(|| true);
948    let local_danger = use_signal(|| false);
949    let mut checked = checked.unwrap_or(local_checked);
950    let danger = danger.unwrap_or(local_danger);
951    let local_alert_variant = use_signal(|| "success".to_string());
952    let local_alert_message = use_signal(|| "已保存,新的想法正在生长。".to_string());
953    let alert_variant = alert_variant.unwrap_or(local_alert_variant);
954    let alert_message = alert_message.unwrap_or(local_alert_message);
955    let mut switch_on = use_signal(|| true);
956    let mut selected_radio = use_signal(|| "public".to_string());
957    let mut select_value = use_signal(|| "leaf".to_string());
958    let mut time_value = use_signal(|| "08:30".to_string());
959    let mut input_value = use_signal(String::new);
960    let mut tab_value = use_signal(|| "all".to_string());
961    let mut page = use_signal(|| 1i32);
962    let mut clicked = use_signal(|| false);
963    let mut popover_open = use_signal(|| false);
964    #[cfg_attr(not(target_arch = "wasm32"), allow(unused_mut))]
965    let mut popover_x = use_signal(|| 550i32);
966    #[cfg_attr(not(target_arch = "wasm32"), allow(unused_mut))]
967    let mut popover_y = use_signal(|| 330i32);
968    let mut modal_open = use_signal(|| false);
969    let mut modal_closing = use_signal(|| false);
970    let mut filter = use_signal(|| false);
971
972    match name.as_str() {
973        "Checkbox" => rsx! {
974            div { class: "showcase-preview-stack",
975                label { class: if danger() { "showcase-check-row danger" } else { "showcase-check-row" },
976                    crate::components::ui::Checkbox { checked: checked(), danger: danger(), onchange: move |value| checked.set(value) }
977                    if detail { "记住这个小小的选择" } else { "记住这个小小的选择" }
978                }
979                if !detail {
980                    label { class: "showcase-check-row",
981                        crate::components::ui::Checkbox { checked: filter(), onchange: move |value| filter.set(value) }
982                        "接收新的生长消息"
983                    }
984                    label { class: "showcase-check-row danger",
985                        crate::components::ui::Checkbox { checked: true, danger: true, onchange: move |_| {} }
986                        "确认移除这条记录"
987                    }
988                }
989            }
990        },
991        "Radio" => rsx! {
992            div { class: "showcase-preview-stack", role: "radiogroup", aria_label: "分享范围",
993                for (value, label) in [("public", "公开分享"), ("private", "仅自己可见")] {
994                    label { class: "showcase-check-row",
995                        crate::components::ui::Radio { name: "showcase-visibility".to_string(), value: value.to_string(), checked: selected_radio() == value, onchange: move |value| selected_radio.set(value) }
996                        "{label}"
997                    }
998                }
999                label { class: "showcase-check-row",
1000                    crate::components::ui::Radio { name: "showcase-visibility".to_string(), value: "unavailable".to_string(), checked: false, disabled: true, onchange: move |_| {} }
1001                    "暂不可用"
1002                }
1003            }
1004        },
1005        "ToggleSwitch" => rsx! {
1006            div { class: "showcase-preview-stack",
1007                div { class: "showcase-switch-row", span { "接收更新" }
1008                    crate::components::forms::ToggleSwitch { checked: switch_on(), ontoggle: move |_| switch_on.toggle() }
1009                }
1010                div { class: "showcase-switch-row", span { "自动备份" }
1011                    crate::components::forms::ToggleSwitch { checked: filter(), ontoggle: move |_| filter.toggle() }
1012                }
1013            }
1014        },
1015        "FormSelect" => rsx! {
1016            crate::components::forms::FormSelect {
1017                value: select_value(),
1018                options: vec![("leaf".to_string(), "每一片叶子"), ("design".to_string(), "关于设计"), ("daily".to_string(), "日常与灵感")],
1019                onchange: move |value| select_value.set(value),
1020            }
1021        },
1022        "TimePicker" => rsx! {
1023            crate::components::forms::TimePicker { value: time_value(), onchange: move |value| time_value.set(value) }
1024        },
1025        "FormInput" => rsx! {
1026            crate::components::forms::FormInput {
1027                r#type: "text",
1028                placeholder: "一个新的开始…",
1029                value: input_value(),
1030                oninput: move |value| input_value.set(value),
1031            }
1032        },
1033        "FormLabel" => rsx! {
1034            div { class: "showcase-preview-field",
1035                crate::components::forms::FormLabel { label: "文章标题", html_for: Some("showcase-title".to_string()) }
1036                crate::components::forms::FormInput {
1037                    id: Some("showcase-title".to_string()), r#type: "text", placeholder: "点击标签也能聚焦这里",
1038                    value: input_value(), oninput: move |value| input_value.set(value),
1039                }
1040            }
1041        },
1042        "LoadingButton" => rsx! {
1043            div { class: "showcase-preview-row",
1044                crate::components::ui::LoadingButton { label: if clicked() { "已保存" } else { "保存设置" }, loading: false, onclick: move |_| clicked.set(true) }
1045                crate::components::ui::LoadingButton { label: "处理中", loading: true, onclick: move |_| {} }
1046            }
1047        },
1048        "FilterTabs" => rsx! {
1049            crate::components::ui::FilterTabs {
1050                items: vec![("all", "全部"), ("published", "已发布"), ("draft", "草稿")],
1051                active_value: tab_value(), on_change: move |value| tab_value.set(value),
1052            }
1053        },
1054        "Pagination" => rsx! {
1055            crate::components::ui::Pagination::<Route> {
1056                variant: "admin", current_page: page(), total: 80, per_page: 10, unit: "篇",
1057                on_prev: move |_| page.set((page() - 1).max(1)),
1058                on_next: move |_| page.set((page() + 1).min(8)),
1059                compact: true,
1060            }
1061        },
1062        "Tooltip" => rsx! {
1063            crate::components::ui::Tooltip { tip: "一条恰到好处的说明", button { class: "showcase-demo-button secondary", "停留片刻" } }
1064        },
1065        "Popover" => rsx! {
1066            div { class: "showcase-popover-example",
1067                button { class: "showcase-demo-button secondary", onclick: move |event: MouseEvent| {
1068                    event.stop_propagation();
1069                    #[cfg(target_arch = "wasm32")]
1070                    {
1071                        let coords = event.client_coordinates();
1072                        let offset = event.element_coordinates();
1073                        popover_x.set(coords.x as i32);
1074                        popover_y.set((coords.y - offset.y) as i32);
1075                    }
1076                    #[cfg(not(target_arch = "wasm32"))]
1077                    let _ = event;
1078                    popover_open.toggle();
1079                }, "试试浮层 ↗" }
1080                crate::components::ui::Popover {
1081                    open: popover_open(), anchor_x: popover_x(), anchor_y: popover_y(), placement: "top", align: "center",
1082                    on_close: move |_| popover_open.set(false),
1083                    div { class: "showcase-popover-content",
1084                        p { "要保留这片小小的灵感吗?" }
1085                        div { class: "showcase-popover-actions",
1086                            button { class: "showcase-demo-button secondary", onclick: move |_| popover_open.set(false), "取消" }
1087                            button { class: "showcase-demo-button", onclick: move |_| popover_open.set(false), "保留" }
1088                        }
1089                    }
1090                }
1091            }
1092        },
1093        "ModalShell" => rsx! {
1094            div {
1095                button { class: "showcase-demo-button secondary", onclick: move |_| modal_open.set(true), "打开一扇小窗 ↗" }
1096                crate::components::ui::ModalShell {
1097                    visible: modal_open, closing: modal_closing, title: "留住一片灵感",
1098                    div { class: "showcase-demo-modal-content",
1099                        h2 { "留住一片灵感。" }
1100                        p { "让内容拥有一点专注的空间。" }
1101                        button { class: "showcase-demo-button", onclick: move |_| { modal_closing.set(true); modal_open.set(false); }, "收好了" }
1102                    }
1103                }
1104            }
1105        },
1106        "AlertBox" => rsx! {
1107            AlertBoxDemo {
1108                detail,
1109                alert_variant,
1110                alert_message,
1111            }
1112        },
1113        "CollapsibleSettingsCard" => rsx! {
1114            crate::components::ui::CollapsibleSettingsCard {
1115                title: "自动备份", summary: "每天 08:30 留下一份记录", enabled: true,
1116                div { class: "showcase-collapse-content", "这里放置详细设置。" }
1117            }
1118        },
1119        "StatusBadge" => rsx! {
1120            div { class: "showcase-preview-row",
1121                crate::components::ui::StatusBadge { color_class: "bg-green-500/10 text-green-600 dark:text-green-400", label: "已发布" }
1122                crate::components::ui::StatusBadge { color_class: "bg-paper-tertiary/20 text-paper-secondary", label: "草稿" }
1123            }
1124        },
1125        "TagChip" => rsx! {
1126            div { class: "showcase-preview-row",
1127                crate::components::ui::TagChip { label: "设计", to: Route::TagDetail { tag: "设计".to_string() }, variant: "outline", count: 12 }
1128                crate::components::ui::TagChip { label: "灵感", to: Route::TagDetail { tag: "灵感".to_string() }, variant: "solid" }
1129            }
1130        },
1131        "UserAvatar" => rsx! {
1132            div { class: "showcase-preview-row",
1133                crate::components::ui::UserAvatar { name: "叶子", avatar_url: None, class: "w-14 h-14 rounded-full text-xl" }
1134                crate::components::ui::UserAvatar { name: "Yggdrasil", avatar_url: Some("/missing-avatar-for-showcase.png".to_string()), class: "w-10 h-10 rounded-full text-sm" }
1135            }
1136        },
1137        "SproutPlaceholder" => rsx! {
1138            crate::components::ui::SproutPlaceholder { class: "w-10 h-10 text-paper-accent" }
1139        },
1140        "EmptyState" => rsx! {
1141            div { class: "showcase-empty-preview",
1142                crate::components::empty_state::EmptyState { title: "这里,等待第一颗种子。", description: "写下一个新的开始。" }
1143            }
1144        },
1145        "SkeletonBox" => rsx! {
1146            div { class: "showcase-preview-stack showcase-skeleton-lines",
1147                crate::components::skeletons::atoms::SkeletonBox { class: "h-3 w-52 rounded" }
1148                crate::components::skeletons::atoms::SkeletonBox { class: "h-3 w-36 rounded" }
1149                crate::components::skeletons::atoms::SkeletonBox { class: "h-3 w-44 rounded" }
1150            }
1151        },
1152        "SkeletonCard" => rsx! {
1153            crate::components::skeletons::atoms::SkeletonCard { class: Some("p-5 space-y-3 w-56"),
1154                crate::components::skeletons::atoms::SkeletonBox { class: "w-9 h-9 rounded-full" }
1155                crate::components::skeletons::atoms::SkeletonBox { class: "w-full h-3 rounded" }
1156                crate::components::skeletons::atoms::SkeletonBox { class: "w-2/3 h-3 rounded" }
1157            }
1158        },
1159        "SkeletonTable" => rsx! {
1160            div { class: "showcase-table-preview",
1161                crate::components::skeletons::atoms::SkeletonTable {
1162                    columns: vec![
1163                        crate::components::skeletons::atoms::SkeletonTableColumn { header_class: "px-3 py-2", header_box_class: "w-10 h-3 rounded", cell_class: "px-3 py-2", cell_shape: crate::components::skeletons::atoms::SkeletonCellShape::Single("w-12 h-3 rounded") },
1164                        crate::components::skeletons::atoms::SkeletonTableColumn { header_class: "px-3 py-2", header_box_class: "w-20 h-3 rounded", cell_class: "px-3 py-2", cell_shape: crate::components::skeletons::atoms::SkeletonCellShape::Single("w-24 h-3 rounded") },
1165                    ], rows: 2,
1166                }
1167            }
1168        },
1169        "DelayedSkeleton" => rsx! {
1170            div { class: "showcase-preview-stack showcase-skeleton-lines",
1171                crate::components::skeletons::delayed_skeleton::DelayedSkeleton {
1172                    crate::components::skeletons::atoms::SkeletonBox { class: "h-3 w-48 rounded" }
1173                }
1174            }
1175        },
1176        _ => panic!("基础组件缺少预览: {name}"),
1177    }
1178}
1179
1180/// 页面骨架可以脱离数据源安全渲染,图鉴因此直接展示原组件。
1181#[component]
1182fn PageSkeletonPreview(name: String) -> Element {
1183    let skeleton = match name.as_str() {
1184        "AdminCommentsTableSkeleton" => {
1185            rsx! { crate::components::skeletons::admin_comments_skeleton::AdminCommentsTableSkeleton {} }
1186        }
1187        "AdminCommentsSkeleton" => {
1188            rsx! { crate::components::skeletons::admin_comments_skeleton::AdminCommentsSkeleton {} }
1189        }
1190        "ArchiveSkeleton" => {
1191            rsx! { crate::components::skeletons::archive_skeleton::ArchiveSkeleton {} }
1192        }
1193        "AssetsSkeleton" => {
1194            rsx! { crate::components::skeletons::assets_skeleton::AssetsSkeleton {} }
1195        }
1196        "ChangelogSkeleton" => {
1197            rsx! { crate::components::skeletons::changelog_skeleton::ChangelogSkeleton {} }
1198        }
1199        "CommentListSkeleton" => {
1200            rsx! { crate::components::skeletons::comment_skeleton::CommentListSkeleton {} }
1201        }
1202        "AdminDashboardSkeleton" => {
1203            rsx! { crate::components::skeletons::dashboard_skeleton::AdminDashboardSkeleton {} }
1204        }
1205        "FriendsAdminListSkeleton" => {
1206            rsx! { crate::components::skeletons::friends_admin_skeleton::FriendsAdminListSkeleton {} }
1207        }
1208        "FriendsAdminSkeleton" => {
1209            rsx! { crate::components::skeletons::friends_admin_skeleton::FriendsAdminSkeleton {} }
1210        }
1211        "FriendsSkeleton" => {
1212            rsx! { crate::components::skeletons::friends_skeleton::FriendsSkeleton {} }
1213        }
1214        "HomeSkeleton" => rsx! { crate::components::skeletons::home_skeleton::HomeSkeleton {} },
1215        "HomePostsSkeleton" => {
1216            rsx! { crate::components::skeletons::home_skeleton::HomePostsSkeleton { current_page: 1 } }
1217        }
1218        "LogsSkeleton" => rsx! { crate::components::skeletons::logs_skeleton::LogsSkeleton {} },
1219        "McpSkeleton" => rsx! { crate::components::skeletons::mcp_skeleton::McpSkeleton {} },
1220        "PostCardSkeleton" => {
1221            rsx! { crate::components::skeletons::post_card_skeleton::PostCardSkeleton { has_cover: true } }
1222        }
1223        "PostDetailBody" => {
1224            rsx! { crate::components::skeletons::post_detail_skeleton::PostDetailBody {} }
1225        }
1226        "PostDetailSkeleton" => {
1227            rsx! { crate::components::skeletons::post_detail_skeleton::PostDetailSkeleton {} }
1228        }
1229        "PostPreviewSkeleton" => {
1230            rsx! { crate::components::skeletons::post_preview_skeleton::PostPreviewSkeleton {} }
1231        }
1232        "PostsTrashTableSkeleton" => {
1233            rsx! { crate::components::skeletons::posts_trash_skeleton::PostsTrashTableSkeleton {} }
1234        }
1235        "PostsTrashSkeleton" => {
1236            rsx! { crate::components::skeletons::posts_trash_skeleton::PostsTrashSkeleton {} }
1237        }
1238        "PostsTableSkeleton" => {
1239            rsx! { crate::components::skeletons::posts_skeleton::PostsTableSkeleton {} }
1240        }
1241        "PostsSkeleton" => rsx! { crate::components::skeletons::posts_skeleton::PostsSkeleton {} },
1242        "ProfileSkeleton" => {
1243            rsx! { crate::components::skeletons::profile_skeleton::ProfileSkeleton {} }
1244        }
1245        "RunnerSkeleton" => {
1246            rsx! { crate::components::skeletons::runner_skeleton::RunnerSkeleton {} }
1247        }
1248        "SearchSkeleton" => {
1249            rsx! { crate::components::skeletons::search_skeleton::SearchSkeleton {} }
1250        }
1251        "SettingsAdminSkeleton" => {
1252            rsx! { crate::components::skeletons::settings_admin_skeleton::SettingsAdminSkeleton {} }
1253        }
1254        "SystemSkeleton" => {
1255            rsx! { crate::components::skeletons::system_skeleton::SystemSkeleton {} }
1256        }
1257        "TagDetailSkeleton" => {
1258            rsx! { crate::components::skeletons::tags_skeleton::TagDetailSkeleton { tag: "设计".to_string() } }
1259        }
1260        "TagPostsLoading" => {
1261            rsx! { div { class: "tag-page", crate::components::skeletons::tags_skeleton::TagPostsLoading {} } }
1262        }
1263        "TagsSkeleton" => rsx! { crate::components::skeletons::tags_skeleton::TagsSkeleton {} },
1264        "WriteSkeleton" => rsx! { crate::components::skeletons::write_skeleton::WriteSkeleton {} },
1265        _ => panic!("页面骨架缺少预览: {name}"),
1266    };
1267    rsx! { div { class: "showcase-skeleton-window",
1268        div { class: "showcase-skeleton-window-bar", span {} span {} span {} }
1269        div { class: "showcase-skeleton-scale", {skeleton} }
1270    } }
1271}
1272
1273fn sample_post() -> Post {
1274    let created_at = DateTime::parse_from_rfc3339("2026-09-23T08:00:00Z")
1275        .expect("固定的图鉴示例日期必须有效")
1276        .with_timezone(&Utc);
1277    Post {
1278        id: 0,
1279        author_id: 0,
1280        title: "让想法生根".to_string(),
1281        slug: "showcase-example".to_string(),
1282        summary: Some("在时间的缝隙里,种下一些文字。".to_string()),
1283        content_md: "一片叶子。".to_string(),
1284        content_html: Some("<p>一片叶子,也记得来时的风。</p>".to_string()),
1285        status: PostStatus::Published,
1286        published_at: Some(created_at),
1287        created_at,
1288        updated_at: created_at,
1289        deleted_at: None,
1290        tags: vec!["设计".to_string()],
1291        cover_image: None,
1292        reading_time: 3,
1293        word_count: 620,
1294        toc_html: None,
1295        prev_post: None,
1296        next_post: None,
1297    }
1298}
1299
1300/// 不请求服务、不修改数据的业务组件使用固定样例资料展示真实结构。
1301#[component]
1302fn SceneComponentPreview(name: String) -> Element {
1303    let cover_preview = name == "PostCover";
1304    #[cfg(target_arch = "wasm32")]
1305    use_effect(move || {
1306        if !cover_preview {
1307            return;
1308        }
1309        if let Some(window) = web_sys::window() {
1310            let selectors = js_sys::Array::new();
1311            selectors.push(&".showcase-cover-window".into());
1312            let selectors = js_sys::Object::from(selectors).into();
1313            crate::utils::js::invoke_optional_global(&window, "__initLightbox", &[selectors]);
1314        }
1315    });
1316    let post = sample_post();
1317    let inner = match name.as_str() {
1318        "PostCard" => rsx! {
1319            crate::components::post_card::PostCard {
1320                post: PostListItem {
1321                    id: post.id,
1322                    author_id: post.author_id,
1323                    title: post.title.clone(),
1324                    slug: post.slug.clone(),
1325                    summary: post.summary.clone(),
1326                    status: post.status.clone(),
1327                    published_at: post.published_at,
1328                    created_at: post.created_at,
1329                    updated_at: post.updated_at,
1330                    deleted_at: None,
1331                    tags: post.tags.clone(),
1332                    cover_image: None,
1333                    reading_time: post.reading_time,
1334                    word_count: post.word_count,
1335                },
1336            }
1337        },
1338        "PostCover" => {
1339            rsx! { crate::components::post::post_cover::PostCover { src: "/images/empty-state/dog-camera.webp".to_string() } }
1340        }
1341        "SearchIconLink" => rsx! { crate::components::header::SearchIconLink {} },
1342        _ => panic!("场景组件缺少预览: {name}"),
1343    };
1344    rsx! {
1345        div { class: if cover_preview { "showcase-scene-window showcase-cover-window" } else { "showcase-scene-window" }, aria_label: "{name} 的静态样例",
1346            div { class: "showcase-skeleton-window-bar", span {} span {} span {} }
1347            div { class: "showcase-scene-canvas", inert: "true", {inner} }
1348        }
1349    }
1350}
1351
1352#[cfg(test)]
1353mod tests {
1354    use super::*;
1355    use std::collections::HashSet;
1356    use std::path::Path;
1357
1358    fn public_component_names(dir: &Path, names: &mut HashSet<String>) {
1359        for entry in std::fs::read_dir(dir).expect("读取组件源码目录") {
1360            let path = entry.expect("组件目录项").path();
1361            if path.is_dir() {
1362                public_component_names(&path, names);
1363            } else if path.extension().is_some_and(|extension| extension == "rs") {
1364                let source = std::fs::read_to_string(&path).expect("读取组件源码");
1365                for line in source.lines() {
1366                    if let Some(rest) = line.trim_start().strip_prefix("pub fn ") {
1367                        let name: String = rest
1368                            .chars()
1369                            .take_while(|ch| ch.is_ascii_alphanumeric() || *ch == '_')
1370                            .collect();
1371                        if name.starts_with(char::is_uppercase) {
1372                            names.insert(name);
1373                        }
1374                    }
1375                }
1376            }
1377        }
1378    }
1379
1380    #[test]
1381    fn catalog_covers_public_components_and_has_unique_routes() {
1382        let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("src/components");
1383        let mut exported = HashSet::new();
1384        public_component_names(&root, &mut exported);
1385        let catalog: HashSet<&str> = SHOWCASE
1386            .components
1387            .iter()
1388            .map(|component| component.name.as_str())
1389            .collect();
1390        let missing: Vec<_> = exported
1391            .iter()
1392            .filter(|name| !catalog.contains(name.as_str()))
1393            .collect();
1394        assert!(missing.is_empty(), "图鉴缺少公开组件: {missing:?}");
1395
1396        let mut slugs = HashSet::new();
1397        let groups: HashSet<&str> = SHOWCASE
1398            .groups
1399            .iter()
1400            .map(|group| group[0].as_str())
1401            .collect();
1402        for component in &SHOWCASE.components {
1403            assert!(
1404                slugs.insert(component.slug.as_str()),
1405                "重复路径: {}",
1406                component.slug
1407            );
1408            assert!(
1409                groups.contains(component.group.as_str()),
1410                "未知分类: {}",
1411                component.group
1412            );
1413            assert!(
1414                !component.code.trim().is_empty(),
1415                "缺少用法: {}",
1416                component.name
1417            );
1418            assert!(
1419                preview_route(&component.slug).is_some(),
1420                "缺少真实预览分派: {}",
1421                component.slug
1422            );
1423        }
1424    }
1425
1426    #[test]
1427    fn highlighted_usage_preserves_every_snippet() {
1428        let snippets = SHOWCASE
1429            .components
1430            .iter()
1431            .map(|component| component.code.clone())
1432            .chain([true, false].into_iter().flat_map(|checked| {
1433                [true, false].map(move |danger| checkbox_snippet(checked, danger))
1434            }))
1435            .chain(
1436                ["success", "error", "default"]
1437                    .into_iter()
1438                    .map(|variant| alert_box_snippet(variant, alert_box_preset_message(variant))),
1439            );
1440
1441        for snippet in snippets {
1442            let tokens = highlight_snippet(&snippet);
1443            assert_eq!(
1444                tokens.iter().map(|token| token.text).collect::<String>(),
1445                snippet
1446            );
1447            assert!(tokens.iter().any(|token| token.class.is_some()));
1448        }
1449    }
1450
1451    #[test]
1452    fn highlighted_usage_keeps_strings_and_comments_intact() {
1453        let code = "let text = \"中文 \\\"// still a string\\\"\";\n// 注释\n";
1454        let tokens = highlight_snippet(code);
1455        assert!(tokens
1456            .iter()
1457            .any(|token| token.class == Some("string") && token.text.contains("中文")));
1458        assert!(tokens
1459            .iter()
1460            .any(|token| token.class == Some("comment") && token.text == "// 注释"));
1461        assert_eq!(
1462            tokens.iter().map(|token| token.text).collect::<String>(),
1463            code
1464        );
1465    }
1466}