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