Skip to main content

yggdrasil/pages/components_showcase/
live_previews.rs

1//! 图鉴里的实时预览:直接渲染生产 UI 组件,状态只留在本页,不发任何请求。
2
3use chrono::{DateTime, Utc};
4use leptos::ev::{MouseEvent, SubmitEvent};
5use leptos::prelude::*;
6
7use super::AlertBoxDemo;
8use crate::components::admin_palette::{AdminPaletteView, Item, PostSearch};
9use crate::components::forms::{
10    FormInput, FormLabel, FormSelect, FormTextarea, NumberInput, TimePicker, ToggleSwitch,
11};
12use crate::components::palette::{contains_ci, use_search_palette_provider};
13use crate::components::ui::{
14    use_sheet_close, use_toast, BottomSheet, Breadcrumb, Button, ButtonSize, ButtonVariant,
15    Checkbox, Composer, ComposerInput, ComposerSend, ComposerSize, ConfirmDialog, Crumb, Icon,
16    IconButton, IconName, ImagePlaceholder, LoadError, Menu, MenuItem, ModalShell, Pagination,
17    Popover, Radio, SegmentedControl, SegmentedItem, SegmentedKind, SegmentedSize, SideSheet,
18    StatusBadge, StatusDot, TablePager, TagChip, ToastTone, Tooltip, UserAvatar,
19};
20use crate::models::post::{Post, PostListItem, PostStatus};
21use crate::routes::Route;
22
23fn segmented_mode_items() -> Vec<SegmentedItem<String>> {
24    vec![
25        SegmentedItem::new("light".to_string(), "浅色").icon(IconName::Sun),
26        SegmentedItem::new("dark".to_string(), "深色").icon(IconName::Moon),
27        SegmentedItem::new("system".to_string(), "跟随系统").icon(IconName::Monitor),
28    ]
29}
30
31#[component]
32pub(super) fn LiveComponentPreview(
33    name: String,
34    detail: bool,
35    #[prop(optional)] checked: Option<RwSignal<bool>>,
36    #[prop(optional)] danger: Option<RwSignal<bool>>,
37    #[prop(optional)] alert_variant: Option<RwSignal<String>>,
38    #[prop(optional)] alert_message: Option<RwSignal<String>>,
39) -> impl IntoView {
40    let checked = checked.unwrap_or_else(|| RwSignal::new(true));
41    let danger = danger.unwrap_or_else(|| RwSignal::new(false));
42    let alert_variant = alert_variant.unwrap_or_else(|| RwSignal::new("success".to_string()));
43    let alert_message =
44        alert_message.unwrap_or_else(|| RwSignal::new("已保存,新的想法正在生长。".to_string()));
45    let switch_on = RwSignal::new(true);
46    let selected_radio = RwSignal::new("public".to_string());
47    let select_value = RwSignal::new("leaf".to_string());
48    let time_value = RwSignal::new("08:30".to_string());
49    let input_value = RwSignal::new(String::new());
50    let textarea_value = RwSignal::new(String::new());
51    let tab_value = RwSignal::new("all".to_string());
52    let seg_mode = RwSignal::new("light".to_string());
53    let page = RwSignal::new(1i32);
54    let number_value = RwSignal::new(14i64);
55    let clicked = RwSignal::new(false);
56    let popover_open = RwSignal::new(false);
57    let popover_x = RwSignal::new(550i32);
58    let popover_y = RwSignal::new(330i32);
59    let modal_open = RwSignal::new(false);
60    let modal_closing = RwSignal::new(false);
61    let confirm_open = RwSignal::new(false);
62    let filter = RwSignal::new(false);
63
64    match name.as_str() {
65        "Checkbox" => view! {
66            <div class="showcase-preview-stack">
67                <label class=move || {
68                    if danger.get() { "showcase-check-row danger" } else { "showcase-check-row" }
69                }>
70                    <Checkbox
71                        checked=checked
72                        danger=danger
73                        onchange=move |value: bool| checked.set(value)
74                    />
75                    "记住这个小小的选择"
76                </label>
77                {(!detail)
78                    .then(|| {
79                        view! {
80                            <label class="showcase-check-row">
81                                <Checkbox
82                                    checked=filter
83                                    onchange=move |value: bool| filter.set(value)
84                                />
85                                "接收新的生长消息"
86                            </label>
87                            <label class="showcase-check-row danger">
88                                <Checkbox checked=true danger=true onchange=move |_: bool| {}/>
89                                "确认移除这条记录"
90                            </label>
91                        }
92                    })}
93            </div>
94        }
95        .into_any(),
96        "Radio" => view! {
97            <div class="showcase-preview-stack" role="radiogroup" aria-label="分享范围">
98                {[("public", "公开分享"), ("private", "仅自己可见")]
99                    .into_iter()
100                    .map(|(value, label)| {
101                        view! {
102                            <label class="showcase-check-row">
103                                <Radio
104                                    name="showcase-visibility"
105                                    value=value
106                                    checked=move || selected_radio.get() == value
107                                    onchange=move |value: String| selected_radio.set(value)
108                                />
109                                {label}
110                            </label>
111                        }
112                    })
113                    .collect_view()}
114                <label class="showcase-check-row">
115                    <Radio
116                        name="showcase-visibility"
117                        value="unavailable"
118                        checked=false
119                        disabled=true
120                        onchange=move |_: String| {}
121                    />
122                    "暂不可用"
123                </label>
124            </div>
125        }
126        .into_any(),
127        "ToggleSwitch" => view! {
128            <div class="showcase-preview-stack">
129                <div class="showcase-switch-row">
130                    <span>"接收更新"</span>
131                    <ToggleSwitch
132                        checked=switch_on
133                        ontoggle=move |()| switch_on.update(|on| *on = !*on)
134                    />
135                </div>
136                <div class="showcase-switch-row">
137                    <span>"自动备份"</span>
138                    <ToggleSwitch
139                        checked=filter
140                        ontoggle=move |()| filter.update(|on| *on = !*on)
141                    />
142                </div>
143            </div>
144        }
145        .into_any(),
146        "FormSelect" => view! {
147            <div class="showcase-preview-stack">
148                <div class="showcase-preview-field">
149                    <FormLabel label="素材排序" html_for="showcase-sort"/>
150                    <FormSelect
151                        id="showcase-sort"
152                        value=select_value
153                        options=vec![
154                            ("leaf".to_string(), "最新上传"),
155                            ("design".to_string(), "体积最大"),
156                            ("daily".to_string(), "引用最多"),
157                            ("name".to_string(), "按文件名"),
158                        ]
159                        disabled_options=vec![("name".to_string(), "需要先重建索引")]
160                        onchange=move |value: String| select_value.set(value)
161                    />
162                </div>
163                <div class="showcase-preview-field">
164                    <FormLabel label="主题标签(多于 8 项时可筛选)" html_for="showcase-tag"/>
165                    <FormSelect
166                        id="showcase-tag"
167                        value=tab_value
168                        options=vec![
169                            ("all".to_string(), "全部标签"),
170                            ("rust".to_string(), "Rust"),
171                            ("dioxus".to_string(), "Dioxus"),
172                            ("pg".to_string(), "PostgreSQL"),
173                            ("wasm".to_string(), "WebAssembly"),
174                            ("design".to_string(), "设计"),
175                            ("arch".to_string(), "架构"),
176                            ("note".to_string(), "读书笔记"),
177                            ("emu".to_string(), "模拟器"),
178                            ("mcp".to_string(), "MCP"),
179                        ]
180                        onchange=move |value: String| tab_value.set(value)
181                    />
182                </div>
183            </div>
184        }
185        .into_any(),
186        "TimePicker" => view! {
187            <TimePicker
188                value=time_value
189                onchange=move |value: String| time_value.set(value)
190            />
191        }
192        .into_any(),
193        "FormInput" => view! {
194            <FormInput
195                r#type="text"
196                placeholder="一个新的开始…"
197                value=input_value
198                oninput=move |value: String| input_value.set(value)
199            />
200        }
201        .into_any(),
202        "FormTextarea" => view! {
203            <FormTextarea
204                rows=4
205                placeholder="写下这篇文章想说的事…"
206                value=textarea_value
207                oninput=move |value: String| textarea_value.set(value)
208            />
209        }
210        .into_any(),
211        "FormLabel" => view! {
212            <div class="showcase-preview-field">
213                <FormLabel label="文章标题" html_for="showcase-title"/>
214                <FormInput
215                    id="showcase-title"
216                    r#type="text"
217                    placeholder="点击标签也能聚焦这里"
218                    value=input_value
219                    oninput=move |value: String| input_value.set(value)
220                />
221            </div>
222        }
223        .into_any(),
224        "Button" => view! {
225            <div class="flex flex-col gap-4">
226                <div class="showcase-preview-row flex-wrap">
227                    <Button onclick=move |_: MouseEvent| clicked.set(true)>
228                        {move || if clicked.get() { "已发布" } else { "发布文章" }}
229                    </Button>
230                    <Button variant=ButtonVariant::Secondary icon=IconName::Save>
231                        "保存草稿"
232                    </Button>
233                    <Button variant=ButtonVariant::Outline>"预览"</Button>
234                    <Button variant=ButtonVariant::Ghost>"取消"</Button>
235                    <Button variant=ButtonVariant::Danger>"永久删除"</Button>
236                    <Button variant=ButtonVariant::Secondary loading=true>
237                        "处理中"
238                    </Button>
239                </div>
240                <div class="showcase-preview-row flex-wrap items-center">
241                    <Button size=ButtonSize::Xs variant=ButtonVariant::Secondary>
242                        "XS"
243                    </Button>
244                    <Button size=ButtonSize::Sm variant=ButtonVariant::Secondary>
245                        "SM"
246                    </Button>
247                    <Button variant=ButtonVariant::Secondary>"MD"</Button>
248                    <Button size=ButtonSize::Lg variant=ButtonVariant::Secondary>
249                        "LG"
250                    </Button>
251                    <Button variant=ButtonVariant::DangerSoft icon=IconName::Trash>
252                        "清空回收站"
253                    </Button>
254                </div>
255            </div>
256        }
257        .into_any(),
258        "IconButton" => view! {
259            <div class="showcase-preview-row">
260                <IconButton icon=IconName::Copy label="复制链接"/>
261                <IconButton
262                    icon=IconName::Search
263                    label="搜索"
264                    shortcut="⌘K"
265                    variant=ButtonVariant::Secondary
266                />
267                <IconButton
268                    icon=IconName::PanelLeft
269                    label="收起侧栏"
270                    pressed=Signal::derive(move || Some(clicked.get()))
271                    onclick=move |_: MouseEvent| clicked.update(|on| *on = !*on)
272                />
273                <IconButton icon=IconName::Trash label="删除" disabled=true/>
274            </div>
275        }
276        .into_any(),
277        "NumberInput" => view! {
278            <div class="showcase-preview-field">
279                <FormLabel label="日志保留" html_for="showcase-retention"/>
280                <NumberInput
281                    id="showcase-retention"
282                    value=number_value
283                    min=1
284                    max=90
285                    unit="天"
286                    hint="超过保留期的日志由后台任务自动清理。".to_string()
287                    onchange=move |value: i64| number_value.set(value)
288                />
289            </div>
290        }
291        .into_any(),
292        "Menu" => view! {
293            <div class="flex flex-col items-center gap-3">
294                <Menu
295                    label="更多操作"
296                    items=vec![
297                        MenuItem::new("edit", "编辑").icon(IconName::Pencil),
298                        MenuItem::new("copy", "复制链接").icon(IconName::Link).shortcut("⌘C"),
299                        MenuItem::new("archive", "归档")
300                            .icon(IconName::Archive)
301                            .disabled("已发布的文章不能归档"),
302                        MenuItem::new("trash", "移入回收站")
303                            .icon(IconName::Trash)
304                            .danger()
305                            .separated(),
306                    ]
307                    on_select=move |id: &'static str| input_value.set(id.to_string())
308                />
309                {move || {
310                    (!input_value.get().is_empty())
311                        .then(|| {
312                            view! {
313                                <p class="text-xs text-fg-3 font-mono">
314                                    {move || format!("on_select: {}", input_value.get())}
315                                </p>
316                            }
317                        })
318                }}
319            </div>
320        }
321        .into_any(),
322        "ToastRegion" => view! { <ToastDemo/> }.into_any(),
323        "BottomSheet" => view! { <BottomSheetDemo/> }.into_any(),
324        "Breadcrumb" => view! {
325            <Breadcrumb items=vec![
326                Crumb::link("关于", Route::About),
327                Crumb::link("组件图鉴", Route::ComponentShowcase),
328                Crumb::current("复选框"),
329            ]/>
330        }
331        .into_any(),
332        "SearchPalette" => view! { <SearchPaletteDemo/> }.into_any(),
333        "SideSheet" => view! { <SideSheetDemo/> }.into_any(),
334        "Composer" => view! { <ComposerDemo/> }.into_any(),
335        "ComposerInput" => view! { <ComposerInputDemo/> }.into_any(),
336        "ComposerSend" => view! { <ComposerSendDemo/> }.into_any(),
337        "AdminPalette" => view! { <AdminPaletteDemo/> }.into_any(),
338        "SegmentedControl" => view! {
339            <div class="showcase-preview-stack w-full">
340                <SegmentedControl
341                    items=vec![
342                        SegmentedItem::new("all".to_string(), "全部"),
343                        SegmentedItem::new("published".to_string(), "已发布"),
344                        SegmentedItem::new("draft".to_string(), "草稿"),
345                    ]
346                    value=tab_value
347                    aria_label="文章状态示例"
348                    on_change=move |value: String| tab_value.set(value)
349                />
350                {detail
351                    .then(|| {
352                        view! {
353                            <div class="w-full">
354                                <SegmentedControl
355                                    items=segmented_mode_items()
356                                    value=seg_mode
357                                    kind=SegmentedKind::Radio
358                                    size=SegmentedSize::Sm
359                                    aria_label="外观示例(宽)"
360                                    on_change=move |value: String| seg_mode.set(value)
361                                />
362                            </div>
363                            <div style="width: 11rem">
364                                <SegmentedControl
365                                    items=segmented_mode_items()
366                                    value=seg_mode
367                                    kind=SegmentedKind::Radio
368                                    size=SegmentedSize::Sm
369                                    aria_label="外观示例(窄)"
370                                    on_change=move |value: String| seg_mode.set(value)
371                                />
372                            </div>
373                        }
374                    })}
375            </div>
376        }
377        .into_any(),
378        "Pagination" => view! {
379            <div class="w-full max-w-md" inert="true">
380                <Pagination
381                    current_page=2
382                    total=80_i64
383                    per_page=10
384                    prev_route=Route::Home.to_string()
385                    next_route=Route::HomePage { page: 3 }.to_string()
386                />
387            </div>
388        }
389        .into_any(),
390        "TablePager" => view! {
391            <div class="admin-panel w-full max-w-md">
392                <TablePager
393                    page=page
394                    total=57_i64
395                    per_page=20
396                    unit="篇"
397                    on_change=move |value: i32| page.set(value)
398                />
399            </div>
400        }
401        .into_any(),
402        "Tooltip" => view! {
403            <div class="showcase-preview-row">
404                <Tooltip tip="复制链接">
405                    <button class="btn btn-icon btn-secondary" aria-label="复制链接">
406                        <Icon name=IconName::Copy/>
407                    </button>
408                </Tooltip>
409                <Tooltip tip="搜索" shortcut="⌘K">
410                    <button class="btn btn-icon btn-secondary" aria-label="搜索">
411                        <Icon name=IconName::Search/>
412                    </button>
413                </Tooltip>
414                <Tooltip tip="向右旋转" shortcut="R" placement="bottom">
415                    <button class="btn btn-icon btn-secondary" aria-label="向右旋转">
416                        <Icon name=IconName::RotateCw/>
417                    </button>
418                </Tooltip>
419            </div>
420        }
421        .into_any(),
422        "Popover" => view! {
423            <div class="showcase-popover-example">
424                <button
425                    class="showcase-demo-button secondary"
426                    on:click=move |event: MouseEvent| {
427                        event.stop_propagation();
428                        popover_x.set(event.client_x());
429                        popover_y.set(event.client_y() - event.offset_y());
430                        popover_open.update(|open| *open = !*open);
431                    }
432                >
433                    "试试浮层 ↗"
434                </button>
435                <Popover
436                    open=popover_open
437                    anchor_x=popover_x
438                    anchor_y=popover_y
439                    placement="top"
440                    align="center"
441                    on_close=move |()| popover_open.set(false)
442                >
443                    <div class="showcase-popover-content">
444                        <p>"要保留这片小小的灵感吗?"</p>
445                        <div class="showcase-popover-actions">
446                            <button
447                                class="showcase-demo-button secondary"
448                                on:click=move |_| popover_open.set(false)
449                            >
450                                "取消"
451                            </button>
452                            <button
453                                class="showcase-demo-button"
454                                on:click=move |_| popover_open.set(false)
455                            >
456                                "保留"
457                            </button>
458                        </div>
459                    </div>
460                </Popover>
461            </div>
462        }
463        .into_any(),
464        "ModalShell" => view! {
465            <div>
466                <button
467                    class="showcase-demo-button secondary"
468                    on:click=move |_| modal_open.set(true)
469                >
470                    "打开一扇小窗 ↗"
471                </button>
472                <ModalShell visible=modal_open closing=modal_closing title="留住一片灵感">
473                    <div class="showcase-demo-modal-content">
474                        <h2>"留住一片灵感。"</h2>
475                        <p>"让内容拥有一点专注的空间。"</p>
476                        <button
477                            class="showcase-demo-button"
478                            on:click=move |_| {
479                                modal_closing.set(true);
480                                modal_open.set(false);
481                            }
482                        >
483                            "收好了"
484                        </button>
485                    </div>
486                </ModalShell>
487            </div>
488        }
489        .into_any(),
490        "ConfirmDialog" => view! {
491            <div>
492                <button
493                    class="showcase-demo-button secondary"
494                    on:click=move |_| confirm_open.set(true)
495                >
496                    "清空回收站"
497                </button>
498                <ConfirmDialog
499                    visible=confirm_open
500                    title="清空回收站?"
501                    description="3 篇文章会被彻底删除,无法恢复。被这些文章引用的素材会变为未引用。"
502                    confirm_label="彻底删除"
503                    on_confirm=move |()| {}
504                />
505            </div>
506        }
507        .into_any(),
508        "AlertBox" => view! {
509            <AlertBoxDemo detail=detail alert_variant=alert_variant alert_message=alert_message/>
510        }
511        .into_any(),
512        "StatusBadge" => view! {
513            <div class="showcase-preview-row">
514                <StatusBadge color_class="badge-success" label="已发布"/>
515                <StatusBadge color_class="" label="草稿"/>
516                <StatusBadge color_class="badge-warning" label="待审核"/>
517                <StatusBadge color_class="badge-danger" label="3 天后清除"/>
518                <StatusBadge color_class="badge-clay" label="新"/>
519            </div>
520        }
521        .into_any(),
522        "StatusDot" => view! {
523            <div class="showcase-preview-row">
524                <StatusDot label="已发布" tone="clay"/>
525                <StatusDot label="草稿"/>
526                <StatusDot label="Docker   沙箱" tone="success"/>
527                <StatusDot label="即将过期" tone="warning"/>
528                <StatusDot label="已吊销" tone="danger"/>
529            </div>
530        }
531        .into_any(),
532        "TagChip" => view! {
533            <div class="showcase-preview-row">
534                <TagChip
535                    label="设计"
536                    to=Route::TagDetail { tag: "设计".to_string() }.to_string()
537                    variant="outline"
538                    count=12
539                />
540                <TagChip
541                    label="灵感"
542                    to=Route::TagDetail { tag: "灵感".to_string() }.to_string()
543                    variant="solid"
544                />
545            </div>
546        }
547        .into_any(),
548        "UserAvatar" => {
549            let no_avatar: Option<String> = None;
550            view! {
551            <div class="showcase-preview-row">
552                <UserAvatar
553                    name="叶子"
554                    avatar_url=no_avatar
555                    class="w-14 h-14 rounded-full text-xl"
556                />
557                <UserAvatar
558                    name="Yggdrasil"
559                    avatar_url=Some("/missing-avatar-for-showcase.png".to_string())
560                    class="w-10 h-10 rounded-full text-sm"
561                />
562            </div>
563            }
564            .into_any()
565        }
566        "ImagePlaceholder" => view! { <ImagePlaceholder class="w-10 h-10 text-fg-3"/> }.into_any(),
567        "Icon" => view! {
568            <div class="grid grid-cols-8 gap-3 text-fg-2">
569                {IconName::ALL
570                    .into_iter()
571                    .map(|icon| {
572                        view! {
573                            <span class="grid place-items-center" title=icon.slug()>
574                                <Icon name=icon size=18/>
575                            </span>
576                        }
577                    })
578                    .collect_view()}
579            </div>
580        }
581        .into_any(),
582        "EmptyState" => view! {
583            <div class="showcase-empty-preview">
584                <crate::components::empty_state::EmptyState
585                    title="这里,等待第一颗种子。"
586                    description="写下一个新的开始。"
587                />
588            </div>
589        }
590        .into_any(),
591        "LoadError" => view! {
592            <div class="w-full max-w-xl">
593                <LoadError
594                    title="文章暂时没能加载"
595                    retrying=clicked
596                    on_retry=move |()| clicked.set(true)
597                />
598            </div>
599        }
600        .into_any(),
601        _ => panic!("基础组件缺少预览: {name}"),
602    }
603}
604
605/// 图鉴示例用的固定文章,保证预览不随真实时间变化。
606pub(super) fn sample_post() -> Post {
607    let created_at = DateTime::parse_from_rfc3339("2026-09-23T08:00:00Z")
608        .expect("固定的图鉴示例日期必须有效")
609        .with_timezone(&Utc);
610    Post {
611        id: 0,
612        author_id: 0,
613        title: "让想法生根".to_string(),
614        slug: "showcase-example".to_string(),
615        summary: Some("在时间的缝隙里,种下一些文字。".to_string()),
616        content_md: "一片叶子。".to_string(),
617        content_html: Some("<p>一片叶子,也记得来时的风。</p>".to_string()),
618        status: PostStatus::Published,
619        published_at: Some(created_at),
620        created_at,
621        updated_at: created_at,
622        deleted_at: None,
623        tags: vec!["设计".to_string()],
624        cover_image: None,
625        reading_time: 3,
626        word_count: 620,
627        toc_html: None,
628        prev_post: None,
629        next_post: None,
630    }
631}
632
633/// 不请求服务、不修改数据的业务组件使用固定样例资料展示真实结构。
634#[component]
635pub(super) fn SceneComponentPreview(name: String) -> impl IntoView {
636    let cover_preview = name == "PostCover";
637    Effect::new(move |_| {
638        #[cfg(target_arch = "wasm32")]
639        if cover_preview {
640            crate::bridges::globals::init_lightbox(&[".showcase-cover-window"]);
641        }
642    });
643    let inner = match name.as_str() {
644        "PostCover" => view! {
645            <crate::components::post::post_cover::PostCover src="/images/samples/clay-circle.webp"/>
646        }
647        .into_any(),
648        "SearchIconLink" => view! { <crate::components::header::SearchIconLink/> }.into_any(),
649        _ => panic!("场景组件缺少预览: {name}"),
650    };
651    view! {
652        <div
653            class=if cover_preview {
654                "showcase-scene-window showcase-cover-window"
655            } else {
656                "showcase-scene-window"
657            }
658            aria-label=format!("{name} 的静态样例")
659        >
660            <div class="showcase-skeleton-window-bar">
661                <span></span>
662                <span></span>
663                <span></span>
664            </div>
665            <div class="showcase-scene-canvas" inert="true">
666                {inner}
667            </div>
668        </div>
669    }
670}
671
672/// 搜索面板演示:自带句柄与面板实例,不注册全局快捷键,不影响页面上的正式面板。
673#[component]
674fn SearchPaletteDemo() -> impl IntoView {
675    let palette = use_search_palette_provider();
676    view! {
677        <div class="showcase-preview-row">
678            <Button
679                variant=ButtonVariant::Secondary
680                icon=IconName::Search
681                onclick=move |_: MouseEvent| palette.open()
682            >
683                "打开搜索面板"
684            </Button>
685        </div>
686        <crate::components::search_palette::SearchPalette shortcut=false/>
687    }
688}
689
690/// 提示演示:三种语气各发一条,走正式的全站提示区。
691#[component]
692fn ToastDemo() -> impl IntoView {
693    let toast = use_toast();
694    view! {
695        <div class="showcase-preview-row flex-wrap">
696            <Button
697                variant=ButtonVariant::Secondary
698                onclick=move |_: MouseEvent| toast.show(ToastTone::Info, "草稿已自动保存")
699            >
700                "说明"
701            </Button>
702            <Button
703                variant=ButtonVariant::Secondary
704                onclick=move |_: MouseEvent| toast.success("设置已保存")
705            >
706                "成功"
707            </Button>
708            <Button
709                variant=ButtonVariant::Secondary
710                onclick=move |_: MouseEvent| toast.error("保存失败:网络连接中断,请稍后重试")
711            >
712                "错误"
713            </Button>
714        </div>
715    }
716}
717
718/// 图鉴里的抽屉只放演示用的按钮,选中后关闭,不跳转。
719#[component]
720fn BottomSheetDemo() -> impl IntoView {
721    let open = RwSignal::new(false);
722    view! {
723        <button
724            id="showcase-sheet-trigger"
725            class="btn btn-secondary"
726            type="button"
727            aria-haspopup="dialog"
728            aria-expanded=move || open.get().to_string()
729            aria-controls="showcase-bottom-sheet"
730            on:click=move |_| open.set(true)
731        >
732            "打开抽屉"
733        </button>
734        <BottomSheet
735            open=open
736            id="showcase-bottom-sheet"
737            trigger_id="showcase-sheet-trigger"
738            label="演示抽屉"
739        >
740            <BottomSheetDemoBody/>
741        </BottomSheet>
742    }
743}
744
745/// 左侧抽屉演示:内容与底部抽屉演示相同,选中后关闭。
746#[component]
747fn SideSheetDemo() -> impl IntoView {
748    let open = RwSignal::new(false);
749    view! {
750        <button
751            id="showcase-side-sheet-trigger"
752            class="btn btn-secondary"
753            type="button"
754            aria-haspopup="dialog"
755            aria-expanded=move || open.get().to_string()
756            aria-controls="showcase-side-sheet"
757            on:click=move |_| open.set(true)
758        >
759            "打开左侧抽屉"
760        </button>
761        <SideSheet
762            open=open
763            id="showcase-side-sheet"
764            trigger_id="showcase-side-sheet-trigger"
765            label="演示抽屉"
766        >
767            <div class="p-3">
768                <BottomSheetDemoBody/>
769            </div>
770        </SideSheet>
771    }
772}
773
774/// 输入栏演示:单行搜索式,提交后只在下方回显,不请求、不跳转。
775#[component]
776fn ComposerDemo() -> impl IntoView {
777    let query = RwSignal::new(String::new());
778    let submitted = RwSignal::new(String::new());
779    view! {
780        <div class="showcase-preview-stack w-full max-w-md">
781            <form
782                role="search"
783                on:submit=move |event: SubmitEvent| {
784                    event.prevent_default();
785                    submitted.set(query.get_untracked().trim().to_string());
786                }
787            >
788                <Composer>
789                    <Icon name=IconName::Search size=18/>
790                    <ComposerInput
791                        search=true
792                        aria_label="输入栏示例"
793                        placeholder="搜索文章、标签…"
794                        value=query
795                        oninput=move |value: String| query.set(value)
796                    />
797                    <ComposerSend label="搜索"/>
798                </Composer>
799            </form>
800            {move || {
801                (!submitted.get().is_empty())
802                    .then(|| {
803                        view! {
804                            <p class="text-sm text-fg-2" role="status">
805                                {move || format!("已提交:{}", submitted.get())}
806                            </p>
807                        }
808                    })
809            }}
810        </div>
811    }
812}
813
814/// 输入栏输入框演示:多行输入,⌘/Ctrl+Enter 把内容追加到下方的本地列表。
815#[component]
816fn ComposerInputDemo() -> impl IntoView {
817    let text = RwSignal::new(String::new());
818    let entries = RwSignal::new(Vec::<String>::new());
819    view! {
820        <div class="showcase-preview-stack w-full max-w-md">
821            <Composer size=ComposerSize::Sm>
822                <ComposerInput
823                    multiline=true
824                    aria_label="多行输入示例"
825                    placeholder="写点什么…(⌘↵ 提交)"
826                    value=text
827                    oninput=move |value: String| text.set(value)
828                    on_submit=move |()| {
829                        let entry = text.get_untracked().trim().to_string();
830                        if !entry.is_empty() {
831                            entries.update(|list| list.push(entry));
832                            text.set(String::new());
833                        }
834                    }
835                />
836            </Composer>
837            <ul class="flex flex-col gap-1 text-sm text-fg-2" aria-label="已提交内容">
838                {move || {
839                    entries
840                        .get()
841                        .into_iter()
842                        .map(|entry| view! { <li>{entry}</li> })
843                        .collect_view()
844                }}
845            </ul>
846        </div>
847    }
848}
849
850/// 发送键演示:可用与不可用并排,点击只在本地计数。
851#[component]
852fn ComposerSendDemo() -> impl IntoView {
853    let sent = RwSignal::new(0u32);
854    view! {
855        <div class="showcase-preview-stack">
856            <div class="showcase-preview-row">
857                <ComposerSend
858                    label="发送"
859                    onclick=move |_: MouseEvent| sent.update(|count| *count += 1)
860                />
861                <ComposerSend label="发送(不可用)" disabled=true/>
862            </div>
863            {move || {
864                (sent.get() > 0)
865                    .then(|| {
866                        view! {
867                            <p class="text-sm text-fg-2" role="status">
868                                {move || format!("已点击 {} 次", sent.get())}
869                            </p>
870                        }
871                    })
872            }}
873        </div>
874    }
875}
876
877/// 后台命令面板演示:固定文章在浏览器内过滤,选中项只在下方回显,不请求、不跳转。
878#[component]
879fn AdminPaletteDemo() -> impl IntoView {
880    let palette = use_search_palette_provider();
881    let picked = RwSignal::new(String::new());
882    let created_at = sample_post().created_at;
883    let posts: Vec<PostListItem> = [
884        (1, "让想法生根", PostStatus::Published),
885        (2, "Dioxus 服务端渲染的水合细节", PostStatus::Draft),
886        (3, "用 Rust 写一个 6502 模拟器", PostStatus::Published),
887    ]
888    .into_iter()
889    .map(|(id, title, status)| PostListItem {
890        id,
891        author_id: 0,
892        title: title.to_string(),
893        slug: format!("showcase-{id}"),
894        summary: None,
895        published_at: (status == PostStatus::Published).then_some(created_at),
896        status,
897        created_at,
898        updated_at: created_at,
899        deleted_at: None,
900        tags: Vec::new(),
901        cover_image: None,
902        reading_time: 3,
903        word_count: 620,
904    })
905    .collect();
906    let search = move |query: String| -> PostSearch {
907        let hits = posts
908            .iter()
909            .filter(|post| contains_ci(&post.title, &query))
910            .cloned()
911            .collect();
912        Box::pin(std::future::ready(Some(hits)))
913    };
914    view! {
915        <div class="showcase-preview-row flex-wrap">
916            <Button
917                variant=ButtonVariant::Secondary
918                icon=IconName::Search
919                onclick=move |_: MouseEvent| palette.open()
920            >
921                "打开命令面板"
922            </Button>
923            {move || {
924                (!picked.get().is_empty())
925                    .then(|| {
926                        view! {
927                            <p class="text-xs text-fg-3 font-mono">
928                                {move || format!("on_activate: {}", picked.get())}
929                            </p>
930                        }
931                    })
932            }}
933        </div>
934        <AdminPaletteView
935            search=search
936            on_activate=move |item: Item| picked.set(item.title())
937        />
938    }
939}
940
941#[component]
942fn BottomSheetDemoBody() -> impl IntoView {
943    let sheet = use_sheet_close();
944    view! {
945        <ul class="flex flex-col gap-0.5">
946            {["设计系统", "Dioxus 实践", "Rust 异步"]
947                .into_iter()
948                .map(|label| {
949                    view! {
950                        <li>
951                            <button
952                                class="sheet-nav-item"
953                                type="button"
954                                on:click=move |_| sheet.close()
955                            >
956                                {label}
957                            </button>
958                        </li>
959                    }
960                })
961                .collect_view()}
962        </ul>
963    }
964}