1use 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#[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#[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}