yggdrasil/pages/admin/system/
export.rs1use dioxus::prelude::*;
4
5use crate::components::forms::{FormInput, FormSelect, FORM_SELECT_COMPACT_CLASS};
6use crate::components::ui::BTN_PRIMARY_SM;
7
8#[allow(non_snake_case)]
10pub(super) fn ExportTab() -> Element {
11 use crate::components::ui::ADMIN_CARD_CLASS;
12 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 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 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 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 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 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#[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}