Skip to main content

yggdrasil/pages/admin/system/
export.rs

1//! 数据导出 tab。
2
3use dioxus::prelude::*;
4
5use crate::components::forms::{FormInput, FormSelect, FORM_SELECT_COMPACT_CLASS};
6use crate::components::ui::BTN_PRIMARY_SM;
7
8/// 数据导出 tab:按表/按查询导出 SQL/CSV,走 Axum 流式下载。
9#[allow(non_snake_case)]
10pub(super) fn ExportTab() -> Element {
11    use crate::components::ui::ADMIN_CARD_CLASS;
12    // 导出模式:"table" / "query"
13    let mut mode = use_signal(|| "table".to_string());
14    let mut table_name = use_signal(String::new);
15    let mut query = use_signal(String::new);
16    let mut format = use_signal(|| "csv".to_string());
17    let mut include_columns = use_signal(|| true);
18
19    // 触发下载:构造 GET /api/database/export?... URL 并打开
20    let do_export = move || {
21        #[cfg(target_arch = "wasm32")]
22        {
23            let source = if mode().as_str() == "table" {
24                format!("table:{}", table_name.read().trim())
25            } else {
26                format!("query:{}", query.read())
27            };
28            let url = format!(
29                "/api/database/export?source={}&format={}&include_columns={}",
30                urlencode(&source),
31                format(),
32                include_columns(),
33            );
34            if let Some(window) = web_sys::window() {
35                let _ = window.open_with_url(&url);
36            }
37        }
38    };
39
40    rsx! {
41        div { class: "space-y-4",
42            div { class: "{ADMIN_CARD_CLASS} p-4 space-y-4",
43                // 模式选择
44                div { class: "flex items-center gap-4",
45                    label { class: "flex items-center gap-2 text-sm text-paper-primary",
46                        input {
47                            r#type: "radio",
48                            name: "export-mode",
49                            checked: mode() == "table",
50                            onchange: move |_| mode.set("table".to_string()),
51                        }
52                        "按表导出"
53                    }
54                    label { class: "flex items-center gap-2 text-sm text-paper-primary",
55                        input {
56                            r#type: "radio",
57                            name: "export-mode",
58                            checked: mode() == "query",
59                            onchange: move |_| mode.set("query".to_string()),
60                        }
61                        "按查询导出"
62                    }
63                }
64
65                // 表名输入
66                if mode().as_str() == "table" {
67                    div {
68                        label { class: "block text-sm text-paper-secondary mb-1", "表名" }
69                        FormInput {
70                            r#type: "text",
71                            placeholder: "如 posts",
72                            value: table_name(),
73                            mono: true,
74                            oninput: move |v: String| table_name.set(v),
75                        }
76                        p { class: "text-xs text-paper-secondary mt-1",
77                            "仅支持 public schema 下的用户表,表名需为合法标识符"
78                        }
79                    }
80                } else {
81                    // 查询输入
82                    div {
83                        label { class: "block text-sm text-paper-secondary mb-1",
84                            "SELECT 查询(只读)"
85                        }
86                        textarea {
87                            class: "w-full px-3 py-2 text-sm border border-paper-border rounded bg-paper-theme text-paper-primary font-mono",
88                            rows: "4",
89                            placeholder: "SELECT id, title FROM posts WHERE published = true",
90                            value: "{query}",
91                            oninput: move |e| query.set(e.value()),
92                        }
93                    }
94                }
95
96                // 格式 + 选项
97                div { class: "flex flex-wrap items-center gap-4",
98                    div { class: "flex items-center gap-2",
99                        span { class: "text-sm text-paper-secondary", "格式" }
100                        FormSelect {
101                            trigger_class: Some(FORM_SELECT_COMPACT_CLASS),
102                            value: format(),
103                            options: vec![
104                                                                                                                                                ("csv".to_string(), "CSV"),
105                                                                                                                                                ("sql".to_string(), "SQL (INSERT)"),
106                                                                                                                                            ],
107                            onchange: move |v| format.set(v),
108                        }
109                    }
110                    label { class: "flex items-center gap-1 text-sm text-paper-secondary",
111                        input {
112                            r#type: "checkbox",
113                            class: "mr-1",
114                            checked: include_columns(),
115                            onchange: move |e| include_columns.set(e.checked()),
116                        }
117                        "包含列名(CSV 表头 / INSERT 列清单)"
118                    }
119                }
120
121                button { class: "{BTN_PRIMARY_SM}", onclick: move |_| do_export(), "导出并下载" }
122            }
123            p { class: "text-xs text-paper-secondary",
124                "导出走流式响应,大表不会占满内存。SQL 格式仅含 INSERT 语句(不含 DDL/schema)。"
125            }
126        }
127    }
128}
129/// 简易 URL 编码(避免引入新依赖;仅编码导出参数里的特殊字符)。
130/// 仅在 WASM 前端的导出按钮里用。
131#[cfg(target_arch = "wasm32")]
132fn urlencode(s: &str) -> String {
133    let mut out = String::with_capacity(s.len());
134    for b in s.bytes() {
135        match b {
136            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
137                out.push(b as char);
138            }
139            b' ' => out.push('+'),
140            _ => out.push_str(&format!("%{:02X}", b)),
141        }
142    }
143    out
144}