yggdrasil/pages/admin/settings/
mod.rs1mod backup_section;
27mod cache_section;
28mod image_section;
29mod purge_section;
30mod ratelimit_section;
31mod runner_section;
32mod security_section;
33mod site_section;
34mod system_section;
35mod trash_section;
36mod upload_section;
37
38use dioxus::prelude::*;
39
40use backup_section::BackupSection;
41use cache_section::CacheSection;
42use image_section::ImageSection;
43use purge_section::AssetPurgeSection;
44use ratelimit_section::RateLimitSection;
45use runner_section::RunnerSection;
46use security_section::SecuritySection;
47use site_section::SiteSection;
48use system_section::SystemSection;
49use trash_section::TrashSection;
50use upload_section::UploadSection;
51
52#[derive(Clone, Copy, PartialEq, Debug)]
54pub enum SettingsSection {
55 Site,
57 Security,
59 Cache,
61 AssetPurge,
63 RateLimit,
65 Image,
67 Runner,
69 Backup,
71 Trash,
73 Upload,
75 System,
77}
78
79impl SettingsSection {
80 fn as_str(&self) -> &'static str {
81 match self {
82 SettingsSection::Site => "site",
83 SettingsSection::Security => "security",
84 SettingsSection::Cache => "cache",
85 SettingsSection::AssetPurge => "assetpurge",
86 SettingsSection::RateLimit => "ratelimit",
87 SettingsSection::Image => "image",
88 SettingsSection::Runner => "runner",
89 SettingsSection::Backup => "backup",
90 SettingsSection::Trash => "trash",
91 SettingsSection::Upload => "upload",
92 SettingsSection::System => "system",
93 }
94 }
95
96 fn label(&self) -> &'static str {
98 match self {
99 SettingsSection::Site => "站点",
100 SettingsSection::Security => "安全",
101 SettingsSection::Cache => "缓存",
102 SettingsSection::AssetPurge => "素材清理",
103 SettingsSection::RateLimit => "限流",
104 SettingsSection::Image => "图片",
105 SettingsSection::Runner => "运行器",
106 SettingsSection::Backup => "备份",
107 SettingsSection::Trash => "回收站",
108 SettingsSection::Upload => "上传",
109 SettingsSection::System => "系统",
110 }
111 }
112
113 fn icon_path(&self) -> &'static str {
115 match self {
116 SettingsSection::Site => "M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z M9 22V12h6v10",
117 SettingsSection::Security => "M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z",
118 SettingsSection::Cache => "M21 8v13H3V8 M1 3h22v5H1z M10 12h4",
119 SettingsSection::AssetPurge => "M21 15l-5-5L5 21 M18 21H3V3h18v9z M8.5 8.5a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0z M3 6h18",
120 SettingsSection::RateLimit => "M12 2a10 10 0 1 0 10 10A10 10 0 0 0 12 2z M12 6v6l4 2",
121 SettingsSection::Image => "M21 15l-5-5L5 21 M18 21H3V3h18v9z M8.5 8.5a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0z",
122 SettingsSection::Runner => "M16 18l6-6-6-6 M8 6l-6 6 6 6",
123 SettingsSection::Backup => "M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4 M7 10l5 5 5-5 M12 15V3",
124 SettingsSection::Trash => "M3 6h18 M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2 M10 11v6 M14 11v6",
125 SettingsSection::Upload => "M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4 M17 8l-5-5-5 5 M12 3v12",
126 SettingsSection::System => "M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z M22 6l-10 7L2 6",
127 }
128 }
129
130 fn all() -> [SettingsSection; 11] {
132 [
133 SettingsSection::Site,
134 SettingsSection::Security,
135 SettingsSection::Cache,
136 SettingsSection::AssetPurge,
137 SettingsSection::RateLimit,
138 SettingsSection::Image,
139 SettingsSection::Runner,
140 SettingsSection::Backup,
141 SettingsSection::Trash,
142 SettingsSection::Upload,
143 SettingsSection::System,
144 ]
145 }
146
147 fn dom_id(&self) -> String {
149 format!("settings-sec-{}", self.as_str())
150 }
151}
152
153#[cfg(target_arch = "wasm32")]
157fn compute_visible_section() -> Option<SettingsSection> {
158 let document = web_sys::window()?.document()?;
159 let container = document.get_element_by_id("settings-scroll")?;
160 let container_top = container.get_bounding_client_rect().top();
161
162 let at_bottom = f64::from(container.scroll_top()) + f64::from(container.client_height())
164 >= f64::from(container.scroll_height()) - 4.0;
165 let all = SettingsSection::all();
166 if at_bottom {
167 return all.last().copied();
168 }
169
170 const THRESHOLD_PX: f64 = 40.0;
173 let mut current: Option<SettingsSection> = None;
174 for section in all {
175 let Some(el) = document.get_element_by_id(§ion.dom_id()) else {
176 break;
177 };
178 if el.get_bounding_client_rect().top() - container_top <= THRESHOLD_PX {
179 current = Some(section);
180 } else {
181 break;
182 }
183 }
184 current.or_else(|| all.first().copied())
186}
187
188#[cfg(target_arch = "wasm32")]
191fn scroll_to_section(section: SettingsSection) {
192 let Some(document) = web_sys::window().and_then(|w| w.document()) else {
193 return;
194 };
195 let Some(el) = document.get_element_by_id(§ion.dom_id()) else {
196 return;
197 };
198 el.scroll_into_view();
199}
200
201fn render_section(section: SettingsSection, toast: Callback<(String, bool)>) -> Element {
203 match section {
204 SettingsSection::Security => rsx! {
205 SecuritySection { toast }
206 },
207 SettingsSection::RateLimit => rsx! {
208 RateLimitSection { toast }
209 },
210 SettingsSection::Site => rsx! {
211 SiteSection { toast }
212 },
213 SettingsSection::Cache => rsx! {
214 CacheSection { toast }
215 },
216 SettingsSection::AssetPurge => rsx! {
217 AssetPurgeSection { toast }
218 },
219 SettingsSection::Backup => rsx! {
220 BackupSection { toast }
221 },
222 SettingsSection::Image => rsx! {
223 ImageSection { toast }
224 },
225 SettingsSection::Runner => rsx! {
226 RunnerSection { toast }
227 },
228 SettingsSection::Trash => rsx! {
229 TrashSection { toast }
230 },
231 SettingsSection::Upload => rsx! {
232 UploadSection { toast }
233 },
234 SettingsSection::System => rsx! {
235 SystemSection {}
236 },
237 }
238}
239
240#[component]
245pub fn SiteSettingsPage() -> Element {
246 let mut active = use_signal(|| SettingsSection::Site);
247 #[cfg(target_arch = "wasm32")]
251 let mut spy_lock: Signal<Option<(SettingsSection, u32)>> = use_signal(|| None);
252 #[cfg(target_arch = "wasm32")]
253 let mut lock_gen: Signal<u32> = use_signal(|| 0);
254 let mut toast_state: Signal<Option<(String, bool)>> = use_signal(|| None);
255 let toast: Callback<(String, bool)> = Callback::new(move |m| toast_state.set(Some(m)));
256 let mut display_msg: Signal<String> = use_signal(String::new);
259 let mut display_err: Signal<bool> = use_signal(|| false);
260 use_effect(move || {
262 if let Some((msg, is_err)) = toast_state() {
263 display_msg.set(msg.clone());
264 display_err.set(is_err);
265 let key = msg.clone();
266 spawn(async move {
267 crate::utils::time::sleep_ms(3000).await;
268 if toast_state().map(|(m, _)| m == key).unwrap_or(false) {
270 toast_state.set(None);
271 }
272 });
273 }
274 });
275 rsx! {
276 div { class: "animate-page-enter w-full flex-1 min-h-0 flex flex-col px-6 py-8 sm:py-12",
281 div { class: "flex-shrink-0 flex flex-col sm:flex-row sm:items-center justify-between gap-4 pb-6 border-b border-[var(--color-paper-border)]/70 mb-6",
283 div {
284 h1 { class: "text-3xl sm:text-4xl font-extrabold tracking-tight text-[var(--color-paper-primary)]",
285 "站点配置"
286 }
287 p { class: "text-sm text-[var(--color-paper-secondary)] mt-1.5",
288 "管理站点公开配置、安全策略与后台行为参数"
289 }
290 }
291 }
292
293 div { class: if toast_state().is_some() { "ygg-toast is-open" } else { "ygg-toast" },
295 div { class: if display_err() { "ygg-toast-inner text-sm rounded-lg px-3 py-2 bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300" } else { "ygg-toast-inner text-sm rounded-lg px-3 py-2 bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300" },
296 "{display_msg()}"
297 }
298 }
299
300 div { class: "flex-shrink-0 flex items-center gap-2 overflow-x-auto pb-3 mb-6 border-b border-[var(--color-paper-border)]/60 scrollbar-none select-none",
302 for (idx, §ion) in SettingsSection::all().iter().enumerate() {
303 {
304 let is_active = active() == section;
305 let label = section.label();
306 let icon_path = section.icon_path();
307 let base = "inline-flex items-center gap-2 px-3.5 py-1.5 rounded-full text-xs font-medium transition-all whitespace-nowrap cursor-pointer shrink-0 border";
308 let color = if is_active {
309 "bg-[var(--color-paper-accent)] text-[var(--color-paper-theme)] shadow-2xs border-transparent font-semibold"
310 } else {
311 "text-[var(--color-paper-secondary)] bg-[var(--color-paper-entry)]/80 hover:bg-[var(--color-paper-theme)] hover:text-[var(--color-paper-primary)] border-[var(--color-paper-border)]/70"
312 };
313 rsx! {
314 button {
315 key: "{section.as_str()}",
316 class: "animate-row-enter {base} {color}",
317 style: "animation-delay: {idx * 25}ms",
318 onclick: move |_| {
319 active.set(section);
320 #[cfg(target_arch = "wasm32")]
321 {
322 let g = lock_gen().wrapping_add(1);
323 lock_gen.set(g);
324 spy_lock.set(Some((section, g)));
325 scroll_to_section(section);
326 spawn(async move {
327 crate::utils::time::sleep_ms(1200).await;
328 if matches!(spy_lock(), Some((_, gg)) if gg == g) {
329 spy_lock.set(None);
330 }
331 });
332 }
333 },
334 svg {
335 class: "w-3.5 h-3.5 flex-shrink-0",
336 view_box: "0 0 24 24",
337 fill: "none",
338 stroke: "currentColor",
339 stroke_width: "2",
340 stroke_linecap: "round",
341 stroke_linejoin: "round",
342 path { d: "{icon_path}" }
343 }
344 "{label}"
345 }
346 }
347 }
348 }
349 }
350
351 div {
353 id: "settings-scroll",
354 class: "flex-1 min-w-0 min-h-0 overflow-y-auto pb-8 rounded-2xl scroll-smooth",
355 onscroll: move |_| {
356 #[cfg(target_arch = "wasm32")]
357 {
358 let Some(current) = compute_visible_section() else {
359 return;
360 };
361 if let Some((target, _)) = spy_lock() {
362 if current == target {
363 spy_lock.set(None);
364 }
365 return;
366 }
367 if *active.peek() != current {
368 active.set(current);
369 }
370 }
371 },
372 div { class: "flex flex-col gap-8 max-w-5xl mx-auto w-full",
373 for (idx, §ion) in SettingsSection::all().iter().enumerate() {
374 section {
375 key: "{section.as_str()}",
376 id: "{section.dom_id()}",
377 class: "scroll-mt-2 animate-section-enter",
378 style: "animation-delay: {idx * 40}ms",
379 {render_section(section, toast)}
380 }
381 }
382 }
383 }
384 }
385 }
386}