1use leptos::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::sql_result_table::SqlResultTable;
13use crate::components::ui::{
14 Button, ButtonSize, ButtonVariant, Icon, IconButton, IconName, ModalShell, SegmentedControl,
15 SegmentedItem, TablePager,
16};
17
18pub(super) fn preview(slug: &str, detail: bool) -> Option<AnyView> {
19 Some(match slug {
20 "asset-picker-modal" => view! { <PickerDemo detail=detail/> }.into_any(),
21 "asset-upload-modal" => view! { <UploadDemo detail=detail/> }.into_any(),
22 "code-runner" => view! { <RunnerDemo detail=detail/> }.into_any(),
23 "sql-result-table" => view! { <SqlTableDemo detail=detail/> }.into_any(),
24 _ => return None,
25 })
26}
27
28const IMAGES: [(&str, &str, &str); 5] = [
29 (
30 "/images/samples/clay-circle.webp",
31 "纸面与陶土.webp",
32 "暖白纸面上的一轮陶土色圆",
33 ),
34 (
35 "/images/samples/hairline-grid.webp",
36 "细线网格.webp",
37 "细线网格上连起的几个点",
38 ),
39 (
40 "/images/samples/ink-dusk.webp",
41 "夜色弧线.webp",
42 "深色背景上层层展开的陶土色弧线",
43 ),
44 (
45 "/images/samples/stacked.webp",
46 "叠放的卡片.webp",
47 "两张叠放的卡片",
48 ),
49 (
50 "/images/samples/contours.webp",
51 "等高线.webp",
52 "起伏的等高线",
53 ),
54];
55
56fn picker_page(query: &str, page: i32) -> (Vec<PickerAsset>, i64) {
57 let query = query.trim().to_lowercase();
58 let matches: Vec<PickerAsset> = IMAGES
59 .iter()
60 .filter(|(_, name, alt)| {
61 query.is_empty()
62 || name.to_lowercase().contains(&query)
63 || alt.to_lowercase().contains(&query)
64 })
65 .map(|(url, name, alt)| PickerAsset {
66 url: (*url).to_string(),
67 thumbnail: (*url).to_string(),
68 filename: (*name).to_string(),
69 alt: Some((*alt).to_string()),
70 fresh: false,
71 })
72 .collect();
73 let total = matches.len() as i64;
74 let start = ((page - 1).max(0) as usize) * 3;
75 (matches.into_iter().skip(start).take(3).collect(), total)
76}
77
78fn sample_selected() -> Vec<AssetSelection> {
79 IMAGES[..2]
80 .iter()
81 .map(|(url, _, alt)| AssetSelection {
82 url: (*url).to_string(),
83 alt: Some((*alt).to_string()),
84 })
85 .collect()
86}
87
88#[component]
90fn DemoModalHead(
91 title: &'static str,
92 #[prop(into)] on_reset: Callback<()>,
93 #[prop(into)] on_close: Callback<()>,
94) -> impl IntoView {
95 view! {
96 <div class="asset-modal-head">
97 <h2>{title}</h2>
98 <div class="flex items-center gap-1">
99 <Button
100 variant=ButtonVariant::Ghost
101 size=ButtonSize::Sm
102 icon=IconName::RotateCcw
103 onclick=move |_| on_reset.run(())
104 >
105 "恢复默认"
106 </Button>
107 <IconButton
108 icon=IconName::X
109 label="关闭"
110 size=ButtonSize::Sm
111 onclick=move |_| on_close.run(())
112 />
113 </div>
114 </div>
115 }
116}
117
118#[component]
119fn PickerDemo(detail: bool) -> impl IntoView {
120 let visible = RwSignal::new(false);
121 let closing = RwSignal::new(false);
122 let selected = RwSignal::new(sample_selected());
123 let multi = RwSignal::new(true);
124 let query = RwSignal::new(String::new());
125 let page = RwSignal::new(1_i32);
126 let confirmed: RwSignal<Vec<AssetSelection>> = RwSignal::new(Vec::new());
127 let result_label = move || {
128 confirmed.with(|items| {
129 items
130 .iter()
131 .map(|item| item.url.clone())
132 .collect::<Vec<_>>()
133 .join("、")
134 })
135 };
136 let root_class = if detail {
137 "showcase-picker-demo showcase-picker-detail"
138 } else {
139 "showcase-picker-demo"
140 };
141 view! {
142 <div class=root_class>
143 {detail
144 .then(|| {
145 view! {
146 <div class="showcase-demo-bar">
147 <Button
148 variant=ButtonVariant::Secondary
149 size=ButtonSize::Sm
150 onclick=move |_| {
151 selected.set(Vec::new());
152 page.set(1);
153 visible.set(true);
154 }
155 >
156 "打开素材选择弹窗"
157 </Button>
158 <span>"本地演示,选择不会写入文章。"</span>
159 </div>
160 }
161 })}
162 {move || {
163 (!visible.get())
164 .then(|| {
165 view! {
166 <div class="showcase-picker-panel">
167 <PickerDemoContent
168 detail=detail
169 selected=selected
170 multi=multi
171 query=query
172 page=page
173 confirmed=confirmed
174 visible=visible
175 />
176 </div>
177 }
178 })
179 }}
180 {detail
181 .then(|| {
182 view! {
183 <ModalShell
184 visible=visible
185 closing=closing
186 title="选择图片"
187 overlay_padding="p-4 sm:p-6"
188 panel_class="asset-modal asset-picker"
189 >
190 <DemoModalHead
191 title="选择图片"
192 on_reset=move |_| {
193 selected.set(sample_selected());
194 multi.set(true);
195 query.set(String::new());
196 page.set(1);
197 confirmed.set(Vec::new());
198 closing.set(true);
199 visible.set(false);
200 }
201 on_close=move |_| {
202 closing.set(true);
203 visible.set(false);
204 }
205 />
206 <PickerDemoContent
207 detail=true
208 selected=selected
209 multi=multi
210 query=query
211 page=page
212 confirmed=confirmed
213 visible=visible
214 />
215 </ModalShell>
216 }
217 })}
218 {move || {
219 (detail && !confirmed.with(Vec::is_empty))
220 .then(|| {
221 view! {
222 <p class="showcase-demo-result">
223 {move || format!("已确认:{}", result_label())}
224 </p>
225 }
226 })
227 }}
228 </div>
229 }
230}
231
232#[component]
233fn PickerDemoContent(
234 detail: bool,
235 selected: RwSignal<Vec<AssetSelection>>,
236 multi: RwSignal<bool>,
237 query: RwSignal<String>,
238 page: RwSignal<i32>,
239 confirmed: RwSignal<Vec<AssetSelection>>,
240 visible: RwSignal<bool>,
241) -> impl IntoView {
242 let items = Signal::derive(move || picker_page(&query.get(), page.get()).0);
243 let total = Signal::derive(move || picker_page(&query.get(), page.get()).1);
244 let mode = Signal::derive(move || if multi.get() { "multi" } else { "single" }.to_string());
245 let on_pick = Callback::new(move |picks: Vec<AssetSelection>| {
246 confirmed.set(picks);
247 visible.set(false);
248 });
249 view! {
251 <div class="asset-modal-bar">
252 <label class="field field-sm admin-filter-search asset-modal-search">
253 <Icon name=IconName::Search size=14/>
254 <span class="sr-only">"搜索素材"</span>
255 <input
256 type="search"
257 placeholder="文件名或替代文本"
258 prop:value=move || query.get()
259 on:input=move |e| {
260 query.set(event_target_value(&e));
261 page.set(1);
262 }
263 />
264 </label>
265 {detail
266 .then(|| {
267 view! {
268 <SegmentedControl
269 items=vec![
270 SegmentedItem::new("single".to_string(), "单选"),
271 SegmentedItem::new("multi".to_string(), "多选"),
272 ]
273 value=mode
274 on_change=move |value: String| {
275 multi.set(value == "multi");
276 selected.set(Vec::new());
277 }
278 aria_label="选择方式"
279 />
280 }
281 })}
282 </div>
283 {move || {
284 let multi = multi.get();
285 view! {
286 <PickerGallery
287 items=items
288 selected=selected
289 multi=multi
290 loading=false
291 uploading=false
292 error=Signal::stored(None::<String>)
293 uploading_preview=Signal::stored(None::<String>)
294 on_pick=on_pick
295 />
296 }
297 }}
298 {move || {
299 (total.get() > 3)
300 .then(|| {
301 view! {
302 <div class="asset-modal-pager">
303 <TablePager
304 page=page
305 total=total
306 per_page=3
307 unit="张"
308 on_change=move |next: i32| {
309 page.set(next.clamp(1, ((total.get_untracked() + 2) / 3) as i32))
310 }
311 />
312 </div>
313 }
314 })
315 }}
316 {move || {
317 multi.get().then(|| view! { <PickerSelectionFooter selected=selected on_pick=on_pick/> })
318 }}
319 }
320}
321
322#[component]
323fn UploadDemo(detail: bool) -> impl IntoView {
324 let visible = RwSignal::new(false);
325 let closing = RwSignal::new(false);
326 let items = RwSignal::new(sample_upload_items());
327 let generation = RwSignal::new(0_u64);
328 let root_class = if detail {
329 "showcase-upload-demo showcase-upload-detail"
330 } else {
331 "showcase-upload-demo"
332 };
333 view! {
334 <div class=root_class>
335 {detail
336 .then(|| {
337 view! {
338 <div class="showcase-demo-bar">
339 <Button
340 variant=ButtonVariant::Secondary
341 size=ButtonSize::Sm
342 onclick=move |_| visible.set(true)
343 >
344 "打开上传弹窗"
345 </Button>
346 <Button
347 variant=ButtonVariant::Ghost
348 size=ButtonSize::Sm
349 onclick=move |_| {
350 generation.update(|value| *value += 1);
351 items.set(Vec::new());
352 }
353 >
354 "空状态"
355 </Button>
356 <Button
357 variant=ButtonVariant::Ghost
358 size=ButtonSize::Sm
359 onclick=move |_| {
360 generation.update(|value| *value += 1);
361 items.set(sample_upload_items());
362 }
363 >
364 "代表状态"
365 </Button>
366 <span>"本地演示,示例文件不会上传。"</span>
367 </div>
368 }
369 })}
370 {move || {
371 (!visible.get()).then(|| view! { <UploadDemoPanel items=items generation=generation/> })
372 }}
373 {detail
374 .then(|| {
375 view! {
376 <ModalShell
377 visible=visible
378 closing=closing
379 title="上传素材"
380 panel_class="asset-modal asset-uploader"
381 >
382 <DemoModalHead
383 title="上传素材"
384 on_reset=move |_| {
385 generation.update(|value| *value += 1);
386 items.set(sample_upload_items());
387 closing.set(true);
388 visible.set(false);
389 }
390 on_close=move |_| {
391 closing.set(true);
392 visible.set(false);
393 }
394 />
395 <UploadDemoPanel items=items generation=generation/>
396 </ModalShell>
397 }
398 })}
399 </div>
400 }
401}
402
403fn sample_upload_items() -> Vec<UploadItem> {
404 [
405 ("封面.webp", "1.2 MB", UploadStatus::Done),
406 ("相册-02.png", "860 KB", UploadStatus::Uploading),
407 ("草稿插图.jpg", "420 KB", UploadStatus::Queued),
408 (
409 "超大原图.png",
410 "8.4 MB",
411 UploadStatus::Failed("大小超过 5MB 限制".to_string()),
412 ),
413 ]
414 .into_iter()
415 .enumerate()
416 .map(|(index, (name, size, status))| UploadItem {
417 id: index as u64 + 1,
418 name: name.to_string(),
419 size: size.to_string(),
420 status,
421 removing: false,
422 })
423 .collect()
424}
425
426#[component]
427fn UploadDemoPanel(items: RwSignal<Vec<UploadItem>>, generation: RwSignal<u64>) -> impl IntoView {
428 let on_retry = move |id: u64| {
429 let ticket = generation.get_untracked();
430 items.update(|list| {
431 if let Some(item) = list.iter_mut().find(|item| item.id == id) {
432 item.status = UploadStatus::Uploading;
433 }
434 });
435 leptos::task::spawn_local(async move {
436 crate::utils::time::sleep_ms(350).await;
437 if generation.try_get_untracked() != Some(ticket) {
438 return;
439 }
440 let _ = items.try_update(|list| {
441 if let Some(item) = list.iter_mut().find(|item| item.id == id) {
442 item.status = UploadStatus::Done;
443 }
444 });
445 });
446 };
447 let on_remove = move |id: u64| items.update(|list| list.retain(|item| item.id != id));
448 view! {
449 <UploadPanel items=items on_retry=on_retry on_remove=on_remove>
450 <button
451 type="button"
452 class="asset-drop w-full border-0"
453 on:click=move |_| {
454 items
455 .update(|list| {
456 let next = list.iter().map(|item| item.id).max().unwrap_or(0) + 1;
457 list.push(UploadItem {
458 id: next,
459 name: format!("示例文件-{next}.webp"),
460 size: "540 KB".to_string(),
461 status: UploadStatus::Queued,
462 removing: false,
463 });
464 })
465 }
466 >
467 <UploadDropZoneContent
468 title="加入示例文件"
469 hint="本地状态演示,不读取电脑文件或发起上传。"
470 />
471 </button>
472 </UploadPanel>
473 }
474}
475
476#[component]
477fn RunnerDemo(detail: bool) -> impl IntoView {
478 let failed = RwSignal::new(false);
479 let host_id = if detail {
480 "showcase-runner-detail"
481 } else {
482 "showcase-runner-card"
483 };
484 let near_viewport = crate::hooks::use_near_viewport(host_id.to_string(), "0px");
486 let visible = move || detail || near_viewport.get();
487 let root_class = if detail {
488 "showcase-runner-demo showcase-runner-detail"
489 } else {
490 "showcase-runner-demo"
491 };
492 view! {
493 <div class=root_class id=host_id>
494 {detail
495 .then(|| {
496 view! {
497 <div class="showcase-demo-bar">
498 <SegmentedControl
499 items=vec![
500 SegmentedItem::new("ok".to_string(), "正常输出"),
501 SegmentedItem::new("error".to_string(), "错误输出"),
502 ]
503 value=Signal::derive(move || {
504 if failed.get() { "error" } else { "ok" }.to_string()
505 })
506 on_change=move |value: String| failed.set(value == "error")
507 aria_label="输出示例"
508 />
509 <span>"固定输出示例,与编辑后的代码无关;不会执行程序。"</span>
510 </div>
511 }
512 })}
513 {move || {
514 if visible() {
515 let sample = if failed.get() {
516 RunnerExample {
517 stdout: "开始整理 3 个条目…\n".to_string(),
518 stderr: "示例错误:第 2 个条目缺少标题\n".to_string(),
519 status: "退出码: 1 · 输出示例".to_string(),
520 error: "运行错误示例".to_string(),
521 }
522 } else {
523 RunnerExample {
524 stdout: "开始整理 3 个条目…\n✓ 已整理 3 个条目\n".to_string(),
525 stderr: "".to_string(),
526 status: "退出码: 0 · 输出示例".to_string(),
527 error: String::new(),
528 }
529 };
530 view! {
532 <div>
533 <CodeRunner
534 source="print('让想法生根')\n".to_string()
535 language="python".to_string()
536 instance_id=if detail { 3101 } else { 3100 }
537 example_output=sample
538 />
539 </div>
540 }
541 .into_any()
542 } else {
543 view! {
544 <div class="showcase-browser-placeholder">
545 "运行器进入视野后加载编辑器和终端…"
546 </div>
547 }
548 .into_any()
549 }
550 }}
551 </div>
552 }
553}
554
555#[derive(Clone, Copy, PartialEq, Eq)]
556enum SqlPreset {
557 Posts,
558 Comments,
559 Metrics,
560}
561
562fn sample_sql_result(preset: SqlPreset, detail: bool) -> (SqlResult, &'static str, &'static str) {
563 match preset {
564 SqlPreset::Posts => {
565 let columns = if detail {
566 vec![
567 "id".to_string(),
568 "title".to_string(),
569 "slug".to_string(),
570 "published".to_string(),
571 "views".to_string(),
572 "deleted_at".to_string(),
573 ]
574 } else {
575 vec![
576 "id".to_string(),
577 "title".to_string(),
578 "published".to_string(),
579 "views".to_string(),
580 ]
581 };
582 let mut rows = vec![
583 if detail {
584 vec![
585 serde_json::json!(108),
586 serde_json::json!("让想法生根"),
587 serde_json::json!("roots-of-thought"),
588 serde_json::json!(true),
589 serde_json::json!(1280),
590 serde_json::Value::Null,
591 ]
592 } else {
593 vec![
594 serde_json::json!(108),
595 serde_json::json!("让想法生根"),
596 serde_json::json!(true),
597 serde_json::json!(1280),
598 ]
599 },
600 if detail {
601 vec![
602 serde_json::json!(107),
603 serde_json::json!("在时间的缝隙里种下文字"),
604 serde_json::json!("writing-in-time"),
605 serde_json::json!(true),
606 serde_json::json!(856),
607 serde_json::Value::Null,
608 ]
609 } else {
610 vec![
611 serde_json::json!(107),
612 serde_json::json!("在时间的缝隙里种下文字"),
613 serde_json::json!(true),
614 serde_json::json!(856),
615 ]
616 },
617 if detail {
618 vec![
619 serde_json::json!(106),
620 serde_json::json!("未命名草稿:微风与山岗"),
621 serde_json::json!("draft-whispering-wind"),
622 serde_json::json!(false),
623 serde_json::json!(42),
624 serde_json::Value::Null,
625 ]
626 } else {
627 vec![
628 serde_json::json!(106),
629 serde_json::json!("未命名草稿:微风与山岗"),
630 serde_json::json!(false),
631 serde_json::json!(42),
632 ]
633 },
634 ];
635 if detail {
636 rows.push(vec![
637 serde_json::json!(105),
638 serde_json::json!("已归档随笔:初春记事"),
639 serde_json::json!("archived-early-notes"),
640 serde_json::json!(false),
641 serde_json::json!(310),
642 serde_json::json!("2026-09-01 14:20"),
643 ]);
644 }
645 let query = if detail {
646 "SELECT id, title, slug, published, views, deleted_at FROM posts ORDER BY id DESC LIMIT 4;"
647 } else {
648 "SELECT id, title, published, views FROM posts LIMIT 3;"
649 };
650 let stats = if detail {
651 "4 行 · 12ms"
652 } else {
653 "3 行 · 8ms"
654 };
655 (
656 SqlResult {
657 columns,
658 rows,
659 statement_type: "SELECT".to_string(),
660 ..Default::default()
661 },
662 query,
663 stats,
664 )
665 }
666 SqlPreset::Comments => {
667 let columns = vec![
668 "id".to_string(),
669 "post_id".to_string(),
670 "author".to_string(),
671 "content".to_string(),
672 "is_approved".to_string(),
673 "created_at".to_string(),
674 ];
675 let rows = vec![
676 vec![
677 serde_json::json!(34),
678 serde_json::json!(108),
679 serde_json::json!("叶子"),
680 serde_json::json!("风吹过的时候,树叶也在点头。"),
681 serde_json::json!(true),
682 serde_json::json!("2026-09-28 09:12"),
683 ],
684 vec![
685 serde_json::json!(33),
686 serde_json::json!(108),
687 serde_json::json!("林中客"),
688 serde_json::json!("文字很有力量,期待下一篇更新!"),
689 serde_json::json!(true),
690 serde_json::json!("2026-09-28 09:25"),
691 ],
692 vec![
693 serde_json::json!(32),
694 serde_json::json!(107),
695 serde_json::json!("待审访客"),
696 serde_json::json!(
697 "这是一条待审核留言,用于验证长文本在表格中的截断展示与点击展开详情效果。"
698 ),
699 serde_json::json!(false),
700 serde_json::json!("2026-09-27 22:40"),
701 ],
702 ];
703 (
704 SqlResult {
705 columns,
706 rows,
707 statement_type: "SELECT".to_string(),
708 ..Default::default()
709 },
710 "SELECT id, post_id, author, content, is_approved, created_at FROM comments ORDER BY id DESC;",
711 "3 行 · 9ms",
712 )
713 }
714 SqlPreset::Metrics => {
715 let columns = vec![
716 "metric".to_string(),
717 "category".to_string(),
718 "value".to_string(),
719 "unit".to_string(),
720 "healthy".to_string(),
721 "updated_at".to_string(),
722 ];
723 let rows = vec![
724 vec![
725 serde_json::json!("db_pool_active"),
726 serde_json::json!("database"),
727 serde_json::json!(8),
728 serde_json::json!("conns"),
729 serde_json::json!(true),
730 serde_json::json!("2026-09-28 09:50"),
731 ],
732 vec![
733 serde_json::json!("cache_hit_rate"),
734 serde_json::json!("memory"),
735 serde_json::json!(94.6),
736 serde_json::json!("%"),
737 serde_json::json!(true),
738 serde_json::json!("2026-09-28 09:50"),
739 ],
740 vec![
741 serde_json::json!("p99_latency"),
742 serde_json::json!("query"),
743 serde_json::json!(18.2),
744 serde_json::json!("ms"),
745 serde_json::json!(true),
746 serde_json::json!("2026-09-28 09:50"),
747 ],
748 vec![
749 serde_json::json!("disk_usage_warn"),
750 serde_json::json!("storage"),
751 serde_json::json!(88.4),
752 serde_json::json!("%"),
753 serde_json::json!(false),
754 serde_json::json!("2026-09-28 09:50"),
755 ],
756 ];
757 (
758 SqlResult {
759 columns,
760 rows,
761 statement_type: "SELECT".to_string(),
762 ..Default::default()
763 },
764 "SELECT metric, category, value, unit, healthy, updated_at FROM system_metrics;",
765 "4 行 · 15ms",
766 )
767 }
768 }
769}
770#[component]
771fn SqlTableDemo(detail: bool) -> impl IntoView {
772 let preset = RwSignal::new(SqlPreset::Posts);
773 let active = Signal::derive(move || {
774 match preset.get() {
775 SqlPreset::Posts => "posts",
776 SqlPreset::Comments => "comments",
777 SqlPreset::Metrics => "metrics",
778 }
779 .to_string()
780 });
781 let root_class = if detail {
782 "showcase-sql-demo showcase-sql-detail"
783 } else {
784 "showcase-sql-demo"
785 };
786
787 view! {
788 <div class=root_class>
789 {detail
790 .then(|| {
791 view! {
792 <div class="showcase-demo-bar">
793 <SegmentedControl
794 items=vec![
795 SegmentedItem::new("posts".to_string(), "文章"),
796 SegmentedItem::new("comments".to_string(), "评论"),
797 SegmentedItem::new("metrics".to_string(), "系统指标"),
798 ]
799 value=active
800 on_change=move |value: String| {
801 preset
802 .set(
803 match value.as_str() {
804 "comments" => SqlPreset::Comments,
805 "metrics" => SqlPreset::Metrics,
806 _ => SqlPreset::Posts,
807 },
808 )
809 }
810 aria_label="示例查询"
811 />
812 <span>"点击任意行展开完整字段。"</span>
813 </div>
814 }
815 })}
816 {move || {
818 let preset = preset.get();
819 let (result, query_text, stats_text) = sample_sql_result(preset, detail);
820 let initial_expanded = (detail && preset == SqlPreset::Posts).then_some(0_usize);
821 view! {
822 <section class="admin-panel sys-section sys-result">
823 <div class="sys-section-head">
824 <code class="showcase-sql-query">{query_text}</code>
825 <span>{stats_text}</span>
826 </div>
827 <div>
828 <SqlResultTable result=result initial_expanded=initial_expanded/>
829 </div>
830 </section>
831 }
832 }}
833 </div>
834 }
835}