Skip to main content

yggdrasil/pages/components_showcase/
business_previews.rs

1//! Local examples for business components. The production controllers stay in components/.
2use dioxus::prelude::*;
3
4use crate::api::database::sql_console::SqlResult;
5use crate::components::assets::asset_picker::{PickerAsset, PickerGallery, PickerSelectionFooter};
6use crate::components::assets::asset_upload::{
7    UploadDropZoneContent, UploadItem, UploadPanel, UploadStatus,
8};
9use crate::components::assets::AssetSelection;
10use crate::components::code_runner::runner::RunnerExample;
11use crate::components::code_runner::CodeRunner;
12use crate::components::forms::{FormInput, INPUT_INLINE_CLASS};
13use crate::components::sql_result_table::SqlResultTable;
14use crate::components::ui::{ModalShell, Pagination};
15use crate::router::Route;
16
17pub(super) fn preview(slug: &str, detail: bool) -> Option<Element> {
18    Some(match slug {
19        "asset-picker-modal" => rsx! { PickerDemo { detail } },
20        "asset-upload-modal" => rsx! { UploadDemo { detail } },
21        "code-runner" => rsx! { RunnerDemo { detail } },
22        "sql-result-table" => rsx! { SqlTableDemo { detail } },
23        _ => return None,
24    })
25}
26
27const IMAGES: [(&str, &str, &str); 5] = [
28    (
29        "/images/empty-state/dog-camera.webp",
30        "相机里的小狗.webp",
31        "相机里的两只小狗",
32    ),
33    (
34        "/images/xiantiaoxiaogou_02.webp",
35        "光里的院子.webp",
36        "院子里的光",
37    ),
38    (
39        "/images/xiantiaoxiaogou_03.webp",
40        "午后散步.webp",
41        "午后的散步",
42    ),
43    (
44        "/images/xiantiaoxiaogou_input.webp",
45        "手绘小狗.webp",
46        "小狗插画",
47    ),
48    (
49        "/images/xiantiaoxiaogou_input_bg.webp",
50        "草地背景.webp",
51        "绿色草地",
52    ),
53];
54
55fn picker_page(query: &str, page: i32) -> (Vec<PickerAsset>, i64) {
56    let query = query.trim().to_lowercase();
57    let matches: Vec<PickerAsset> = IMAGES
58        .iter()
59        .filter(|(_, name, alt)| {
60            query.is_empty()
61                || name.to_lowercase().contains(&query)
62                || alt.to_lowercase().contains(&query)
63        })
64        .map(|(url, name, alt)| PickerAsset {
65            url: (*url).to_string(),
66            thumbnail: (*url).to_string(),
67            filename: (*name).to_string(),
68            alt: Some((*alt).to_string()),
69            fresh: false,
70        })
71        .collect();
72    let total = matches.len() as i64;
73    let start = ((page - 1).max(0) as usize) * 3;
74    (matches.into_iter().skip(start).take(3).collect(), total)
75}
76
77fn sample_selected() -> Vec<AssetSelection> {
78    IMAGES[..2]
79        .iter()
80        .map(|(url, _, alt)| AssetSelection {
81            url: (*url).to_string(),
82            alt: Some((*alt).to_string()),
83        })
84        .collect()
85}
86
87#[component]
88fn PickerDemo(detail: bool) -> Element {
89    let mut visible = use_signal(|| false);
90    let mut closing = use_signal(|| false);
91    let mut selected = use_signal(sample_selected);
92    let mut multi = use_signal(|| true);
93    let mut query = use_signal(String::new);
94    let mut page = use_signal(|| 1_i32);
95    let mut confirmed: Signal<Vec<AssetSelection>> = use_signal(Vec::new);
96    let result_label = confirmed
97        .read()
98        .iter()
99        .map(|item| item.url.clone())
100        .collect::<Vec<_>>()
101        .join("、");
102    rsx! {
103        div { class: if detail { "showcase-picker-demo showcase-picker-detail" } else { "showcase-picker-demo" },
104            if detail {
105                div { class: "showcase-demo-actions",
106                    button { r#type: "button", onclick: move |_| { selected.set(Vec::new()); page.set(1); visible.set(true); }, "打开素材选择弹窗" }
107                    span { "本地演示,选择不会写入文章。" }
108                }
109            }
110            if !visible() {
111                PickerDemoContent { detail, selected, multi, query, page, confirmed, visible }
112            }
113            if detail {
114                ModalShell { visible, closing, title: "素材选择演示", panel_class: "w-full max-w-3xl min-h-0",
115                    div { class: "showcase-demo-actions", span { "素材选择 · 本地演示" }
116                        button { r#type: "button", onclick: move |_| {
117                            selected.set(sample_selected());
118                            multi.set(true);
119                            query.set(String::new());
120                            page.set(1);
121                            confirmed.set(Vec::new());
122                            closing.set(true);
123                            visible.set(false);
124                        }, "↻ 恢复默认" }
125                        button { r#type: "button", aria_label: "关闭", onclick: move |_| { closing.set(true); visible.set(false); }, "×" }
126                    }
127                    PickerDemoContent { detail: true, selected, multi, query, page, confirmed, visible }
128                }
129            }
130            if detail && !confirmed.read().is_empty() {
131                p { class: "showcase-demo-result", "已确认:{result_label}" }
132            }
133        }
134    }
135}
136
137#[component]
138fn PickerDemoContent(
139    detail: bool,
140    mut selected: Signal<Vec<AssetSelection>>,
141    mut multi: Signal<bool>,
142    mut query: Signal<String>,
143    mut page: Signal<i32>,
144    mut confirmed: Signal<Vec<AssetSelection>>,
145    mut visible: Signal<bool>,
146) -> Element {
147    let (items, total) = picker_page(&query(), page());
148    let mut page_prev = page;
149    let mut page_next = page;
150    let mut page_jump = page;
151    rsx! {
152        div { class: "showcase-picker-panel",
153            div { class: "showcase-picker-toolbar",
154                FormInput { r#type: "search", placeholder: "搜索文件名 / alt", value: query(), class: INPUT_INLINE_CLASS,
155                    oninput: move |value: String| { query.set(value); page.set(1); }
156                }
157                if detail {
158                    div { class: "showcase-picker-mode-switch",
159                        button { r#type: "button", aria_pressed: "{!multi()}", onclick: move |_| { multi.set(false); selected.set(Vec::new()); }, "单选" }
160                        button { r#type: "button", aria_pressed: "{multi()}", onclick: move |_| { multi.set(true); selected.set(Vec::new()); }, "多选" }
161                    }
162                }
163            }
164            PickerGallery { items, selected, multi: multi(), loading: false, uploading: false, error: None,
165                uploading_preview: None,
166                on_pick: move |picks: Vec<AssetSelection>| { confirmed.set(picks); visible.set(false); }
167            }
168            if total > 3 {
169                div { class: "showcase-picker-pagination shrink-0 shadow-[inset_0_1px_0_var(--color-paper-border)]",
170                    Pagination::<Route> { variant: "admin", compact: true, current_page: page(), total,
171                        per_page: 3, unit: "张",
172                        on_prev: move |_| page_prev.set((page_prev() - 1).max(1)),
173                        on_next: move |_| page_next.set((page_next() + 1).min(((total + 2) / 3) as i32)),
174                        on_jump: move |next: i32| page_jump.set(next.clamp(1, ((total + 2) / 3) as i32)),
175                    }
176                }
177            }
178            if multi() {
179                PickerSelectionFooter { selected, on_pick: move |picks: Vec<AssetSelection>| { confirmed.set(picks); visible.set(false); } }
180            }
181        }
182    }
183}
184
185#[component]
186fn UploadDemo(detail: bool) -> Element {
187    let mut visible = use_signal(|| false);
188    let mut closing = use_signal(|| false);
189    let mut items = use_signal(sample_upload_items);
190    let mut generation = use_signal(|| 0_u64);
191    rsx! {
192        div { class: if detail { "showcase-upload-demo showcase-upload-detail" } else { "showcase-upload-demo" },
193            if detail {
194                div { class: "showcase-demo-actions",
195                    button { r#type: "button", onclick: move |_| visible.set(true), "打开上传弹窗" }
196                    button { r#type: "button", onclick: move |_| { generation += 1; items.set(Vec::new()); }, "空状态" }
197                    button { r#type: "button", onclick: move |_| { generation += 1; items.set(sample_upload_items()); }, "代表状态" }
198                    span { "本地演示,示例文件不会上传。" }
199                }
200            }
201            if !visible() { UploadDemoPanel { items, generation } }
202            if detail {
203                ModalShell { visible, closing, title: "上传素材演示", panel_class: "w-full max-w-3xl min-h-0",
204                    div { class: "showcase-demo-actions", span { "素材上传 · 本地演示" }
205                        button { r#type: "button", onclick: move |_| {
206                            generation += 1;
207                            items.set(sample_upload_items());
208                            closing.set(true);
209                            visible.set(false);
210                        }, "↻ 恢复默认" }
211                        button { r#type: "button", aria_label: "关闭", onclick: move |_| { closing.set(true); visible.set(false); }, "×" }
212                    }
213                    UploadDemoPanel { items, generation }
214                }
215            }
216        }
217    }
218}
219
220fn sample_upload_items() -> Vec<UploadItem> {
221    [
222        ("封面.webp", "1.2 MB", UploadStatus::Done),
223        ("相册-02.png", "860 KB", UploadStatus::Uploading),
224        ("草稿插图.jpg", "420 KB", UploadStatus::Queued),
225        (
226            "超大原图.png",
227            "8.4 MB",
228            UploadStatus::Failed("大小超过 5MB 限制".to_string()),
229        ),
230    ]
231    .into_iter()
232    .enumerate()
233    .map(|(index, (name, size, status))| UploadItem {
234        id: index as u64 + 1,
235        name: name.to_string(),
236        size: size.to_string(),
237        status,
238        removing: false,
239    })
240    .collect()
241}
242
243#[component]
244fn UploadDemoPanel(mut items: Signal<Vec<UploadItem>>, generation: Signal<u64>) -> Element {
245    rsx! {
246        UploadPanel { items: items(),
247            on_retry: move |id: u64| {
248                let ticket = *generation.peek();
249                if let Some(item) = items.write().iter_mut().find(|item| item.id == id) {
250                    item.status = UploadStatus::Uploading;
251                }
252                spawn(async move {
253                    crate::utils::time::sleep_ms(350).await;
254                    if *generation.peek() != ticket { return; }
255                    if let Some(item) = items.write().iter_mut().find(|item| item.id == id) {
256                        item.status = UploadStatus::Done;
257                    }
258                });
259            },
260            on_remove: move |id: u64| items.write().retain(|item| item.id != id),
261            button { r#type: "button",
262                class: "flex w-full flex-col items-center justify-center gap-2 rounded-2xl border border-dashed border-[var(--color-paper-border)] bg-[var(--color-paper-theme)] px-6 py-8 cursor-pointer hover:border-[var(--color-paper-primary)]",
263                onclick: move |_| {
264                    let next = items.read().iter().map(|item| item.id).max().unwrap_or(0) + 1;
265                    items.write().push(UploadItem { id: next, name: format!("示例文件-{next}.webp"), size: "540 KB".to_string(), status: UploadStatus::Queued, removing: false });
266                },
267                UploadDropZoneContent { title: "加入示例文件", hint: "本地状态演示,不读取电脑文件或发起上传。" }
268            }
269        }
270    }
271}
272
273#[component]
274fn RunnerDemo(detail: bool) -> Element {
275    #[cfg_attr(not(target_arch = "wasm32"), allow(unused_mut))]
276    let mut visible = use_signal(move || detail);
277    let mut failed = use_signal(|| false);
278    let host_id = if detail {
279        "showcase-runner-detail"
280    } else {
281        "showcase-runner-card"
282    };
283
284    #[cfg(target_arch = "wasm32")]
285    {
286        use std::{cell::RefCell, rc::Rc};
287        use wasm_bindgen::{closure::Closure, JsCast};
288        type Watch = (
289            web_sys::IntersectionObserver,
290            Closure<dyn FnMut(js_sys::Array, web_sys::IntersectionObserver)>,
291        );
292        let watcher = use_hook(|| Rc::new(RefCell::new(None::<Watch>)));
293        let target_watcher = watcher.clone();
294        use_effect(move || {
295            if visible() {
296                return;
297            }
298            let Some(target) = web_sys::window()
299                .and_then(|window| window.document())
300                .and_then(|document| document.get_element_by_id(host_id))
301            else {
302                return;
303            };
304            let callback = Closure::new(
305                move |entries: js_sys::Array, observer: web_sys::IntersectionObserver| {
306                    if entries.iter().any(|value| {
307                        value
308                            .unchecked_into::<web_sys::IntersectionObserverEntry>()
309                            .is_intersecting()
310                    }) {
311                        observer.disconnect();
312                        visible.set(true);
313                    }
314                },
315            );
316            if let Ok(observer) =
317                web_sys::IntersectionObserver::new(callback.as_ref().unchecked_ref())
318            {
319                observer.observe(&target);
320                target_watcher.borrow_mut().replace((observer, callback));
321            } else {
322                visible.set(true);
323            }
324        });
325        use_drop(move || {
326            if let Some((observer, _callback)) = watcher.borrow_mut().take() {
327                observer.disconnect();
328            }
329        });
330    }
331
332    let sample = if failed() {
333        RunnerExample {
334            stdout: "开始整理 3 个条目…\n".to_string(),
335            stderr: "示例错误:第 2 个条目缺少标题\n".to_string(),
336            status: "退出码: 1 · 输出示例".to_string(),
337            error: "运行错误示例".to_string(),
338        }
339    } else {
340        RunnerExample {
341            stdout: "开始整理 3 个条目…\n✓ 已整理 3 个条目\n".to_string(),
342            stderr: "".to_string(),
343            status: "退出码: 0 · 输出示例".to_string(),
344            error: String::new(),
345        }
346    };
347    rsx! {
348        div { class: if detail { "showcase-runner-demo showcase-runner-detail" } else { "showcase-runner-demo" }, id: host_id,
349            if detail {
350                div { class: "showcase-demo-actions",
351                    button { r#type: "button", aria_pressed: "{!failed()}", onclick: move |_| failed.set(false), "正常输出" }
352                    button { r#type: "button", aria_pressed: "{failed()}", onclick: move |_| failed.set(true), "错误输出" }
353                    span { "固定输出示例,与编辑后的代码无关;不会执行程序。" }
354                }
355            }
356            if visible() {
357                div { key: "runner-{failed()}",
358                    CodeRunner { source: "print('让想法生根')\n".to_string(), language: "python".to_string(), overrides: None,
359                        instance_id: if detail { 3101 } else { 3100 }, example_output: Some(sample) }
360                }
361            } else {
362                div { class: "showcase-browser-placeholder", "运行器进入视野后加载编辑器和终端…" }
363            }
364        }
365    }
366}
367
368#[derive(Clone, Copy, PartialEq, Eq)]
369enum SqlPreset {
370    Posts,
371    Comments,
372    Metrics,
373}
374
375fn sample_sql_result(preset: SqlPreset, detail: bool) -> (SqlResult, &'static str, &'static str) {
376    match preset {
377        SqlPreset::Posts => {
378            let columns = if detail {
379                vec![
380                    "id".to_string(),
381                    "title".to_string(),
382                    "slug".to_string(),
383                    "published".to_string(),
384                    "views".to_string(),
385                    "deleted_at".to_string(),
386                ]
387            } else {
388                vec![
389                    "id".to_string(),
390                    "title".to_string(),
391                    "published".to_string(),
392                    "views".to_string(),
393                ]
394            };
395            let mut rows = vec![
396                if detail {
397                    vec![
398                        serde_json::json!(108),
399                        serde_json::json!("让想法生根"),
400                        serde_json::json!("roots-of-thought"),
401                        serde_json::json!(true),
402                        serde_json::json!(1280),
403                        serde_json::Value::Null,
404                    ]
405                } else {
406                    vec![
407                        serde_json::json!(108),
408                        serde_json::json!("让想法生根"),
409                        serde_json::json!(true),
410                        serde_json::json!(1280),
411                    ]
412                },
413                if detail {
414                    vec![
415                        serde_json::json!(107),
416                        serde_json::json!("在时间的缝隙里种下文字"),
417                        serde_json::json!("writing-in-time"),
418                        serde_json::json!(true),
419                        serde_json::json!(856),
420                        serde_json::Value::Null,
421                    ]
422                } else {
423                    vec![
424                        serde_json::json!(107),
425                        serde_json::json!("在时间的缝隙里种下文字"),
426                        serde_json::json!(true),
427                        serde_json::json!(856),
428                    ]
429                },
430                if detail {
431                    vec![
432                        serde_json::json!(106),
433                        serde_json::json!("未命名草稿:微风与山岗"),
434                        serde_json::json!("draft-whispering-wind"),
435                        serde_json::json!(false),
436                        serde_json::json!(42),
437                        serde_json::Value::Null,
438                    ]
439                } else {
440                    vec![
441                        serde_json::json!(106),
442                        serde_json::json!("未命名草稿:微风与山岗"),
443                        serde_json::json!(false),
444                        serde_json::json!(42),
445                    ]
446                },
447            ];
448            if detail {
449                rows.push(vec![
450                    serde_json::json!(105),
451                    serde_json::json!("已归档随笔:初春记事"),
452                    serde_json::json!("archived-early-notes"),
453                    serde_json::json!(false),
454                    serde_json::json!(310),
455                    serde_json::json!("2026-09-01 14:20"),
456                ]);
457            }
458            let query = if detail {
459                "SELECT id, title, slug, published, views, deleted_at FROM posts ORDER BY id DESC LIMIT 4;"
460            } else {
461                "SELECT id, title, published, views FROM posts LIMIT 3;"
462            };
463            let stats = if detail {
464                "4 行 · 12ms"
465            } else {
466                "3 行 · 8ms"
467            };
468            (
469                SqlResult {
470                    columns,
471                    rows,
472                    statement_type: "SELECT".to_string(),
473                    ..Default::default()
474                },
475                query,
476                stats,
477            )
478        }
479        SqlPreset::Comments => {
480            let columns = vec![
481                "id".to_string(),
482                "post_id".to_string(),
483                "author".to_string(),
484                "content".to_string(),
485                "is_approved".to_string(),
486                "created_at".to_string(),
487            ];
488            let rows = vec![
489                vec![
490                    serde_json::json!(34),
491                    serde_json::json!(108),
492                    serde_json::json!("叶子"),
493                    serde_json::json!("风吹过的时候,树叶也在点头。"),
494                    serde_json::json!(true),
495                    serde_json::json!("2026-09-28 09:12"),
496                ],
497                vec![
498                    serde_json::json!(33),
499                    serde_json::json!(108),
500                    serde_json::json!("林中客"),
501                    serde_json::json!("文字很有力量,期待下一篇更新!"),
502                    serde_json::json!(true),
503                    serde_json::json!("2026-09-28 09:25"),
504                ],
505                vec![
506                    serde_json::json!(32),
507                    serde_json::json!(107),
508                    serde_json::json!("待审访客"),
509                    serde_json::json!(
510                        "这是一条待审核留言,用于验证长文本在表格中的截断展示与点击展开详情效果。"
511                    ),
512                    serde_json::json!(false),
513                    serde_json::json!("2026-09-27 22:40"),
514                ],
515            ];
516            (
517                SqlResult {
518                    columns,
519                    rows,
520                    statement_type: "SELECT".to_string(),
521                    ..Default::default()
522                },
523                "SELECT id, post_id, author, content, is_approved, created_at FROM comments ORDER BY id DESC;",
524                "3 行 · 9ms",
525            )
526        }
527        SqlPreset::Metrics => {
528            let columns = vec![
529                "metric".to_string(),
530                "category".to_string(),
531                "value".to_string(),
532                "unit".to_string(),
533                "healthy".to_string(),
534                "updated_at".to_string(),
535            ];
536            let rows = vec![
537                vec![
538                    serde_json::json!("db_pool_active"),
539                    serde_json::json!("database"),
540                    serde_json::json!(8),
541                    serde_json::json!("conns"),
542                    serde_json::json!(true),
543                    serde_json::json!("2026-09-28 09:50"),
544                ],
545                vec![
546                    serde_json::json!("cache_hit_rate"),
547                    serde_json::json!("memory"),
548                    serde_json::json!(94.6),
549                    serde_json::json!("%"),
550                    serde_json::json!(true),
551                    serde_json::json!("2026-09-28 09:50"),
552                ],
553                vec![
554                    serde_json::json!("p99_latency"),
555                    serde_json::json!("query"),
556                    serde_json::json!(18.2),
557                    serde_json::json!("ms"),
558                    serde_json::json!(true),
559                    serde_json::json!("2026-09-28 09:50"),
560                ],
561                vec![
562                    serde_json::json!("disk_usage_warn"),
563                    serde_json::json!("storage"),
564                    serde_json::json!(88.4),
565                    serde_json::json!("%"),
566                    serde_json::json!(false),
567                    serde_json::json!("2026-09-28 09:50"),
568                ],
569            ];
570            (
571                SqlResult {
572                    columns,
573                    rows,
574                    statement_type: "SELECT".to_string(),
575                    ..Default::default()
576                },
577                "SELECT metric, category, value, unit, healthy, updated_at FROM system_metrics;",
578                "4 行 · 15ms",
579            )
580        }
581    }
582}
583
584#[component]
585fn SqlTableDemo(detail: bool) -> Element {
586    let mut preset = use_signal(|| SqlPreset::Posts);
587    let (result, query_text, stats_text) = sample_sql_result(preset(), detail);
588
589    rsx! {
590        div { class: if detail { "showcase-sql-demo showcase-sql-detail" } else { "showcase-sql-demo" },
591            if detail {
592                div { class: "showcase-demo-actions",
593                    button {
594                        r#type: "button",
595                        aria_pressed: "{preset() == SqlPreset::Posts}",
596                        onclick: move |_| preset.set(SqlPreset::Posts),
597                        "文章数据 (posts)"
598                    }
599                    button {
600                        r#type: "button",
601                        aria_pressed: "{preset() == SqlPreset::Comments}",
602                        onclick: move |_| preset.set(SqlPreset::Comments),
603                        "评论数据 (comments)"
604                    }
605                    button {
606                        r#type: "button",
607                        aria_pressed: "{preset() == SqlPreset::Metrics}",
608                        onclick: move |_| preset.set(SqlPreset::Metrics),
609                        "系统指标 (metrics)"
610                    }
611                    span { "点击任意行展开跨列完整详情;布尔、数字、文本与 NULL 呈现差异化渲染。" }
612                }
613            }
614            div { class: "showcase-sql-window",
615                div { class: "showcase-sql-window-bar",
616                    div { class: "showcase-sql-window-dots",
617                        span { class: "dot-close" }
618                        span { class: "dot-min" }
619                        span { class: "dot-max" }
620                    }
621                    div { class: "showcase-sql-query-tag",
622                        span { class: "opacity-50 mr-1 select-none", "yggdrasil=#" }
623                        span { class: "truncate", "{query_text}" }
624                    }
625                    div { class: "showcase-sql-meta-badge",
626                        span { class: "badge-type", "SELECT" }
627                        span { class: "badge-stats", "{stats_text}" }
628                    }
629                }
630                div { class: "showcase-sql-content",
631                    div { key: "{preset() as usize}-{detail}",
632                        SqlResultTable {
633                            result,
634                            initial_expanded: if detail && preset() == SqlPreset::Posts { Some(0) } else { None },
635                        }
636                    }
637                }
638                if detail {
639                    div { class: "showcase-sql-window-foot",
640                        span { "PostgreSQL 16.2 · UTF-8" }
641                        span { "提示:点击左侧箭头或行内容展开 / 收起完整字段" }
642                    }
643                }
644            }
645        }
646    }
647}