yggdrasil/pages/admin/settings/
mod.rs1mod backup_section;
15mod cache_section;
16mod image_section;
17mod kit;
18mod page_cache_section;
19mod purge_section;
20mod ratelimit_section;
21mod runner_section;
22mod security_section;
23mod site_section;
24mod system_section;
25mod trash_section;
26mod upload_section;
27
28use crate::components::admin_layout::AdminPageHead;
29use leptos::prelude::*;
30use leptos_router::{lazy_route, LazyRoute};
31
32use backup_section::BackupSection;
33use cache_section::CacheSection;
34use image_section::ImageSection;
35use kit::SettingsBus;
36use page_cache_section::PageCacheSection;
37use purge_section::AssetPurgeSection;
38use ratelimit_section::RateLimitSection;
39use runner_section::RunnerSection;
40use security_section::SecuritySection;
41use site_section::SiteSection;
42use system_section::SystemSection;
43use trash_section::TrashSection;
44use upload_section::UploadSection;
45
46use crate::components::ui::{button_class, use_toast, ButtonSize, ButtonVariant};
47
48#[derive(Clone, Copy, PartialEq, Eq, Debug)]
50pub enum SettingsSection {
51 Site,
53 Security,
55 Upload,
57 Cache,
59 PageCache,
61 Image,
63 AssetPurge,
65 Trash,
67 Backup,
69 Runner,
71 RateLimit,
73 System,
75}
76
77impl SettingsSection {
78 fn as_str(&self) -> &'static str {
79 match self {
80 SettingsSection::Site => "site",
81 SettingsSection::Security => "security",
82 SettingsSection::Upload => "upload",
83 SettingsSection::Cache => "cache",
84 SettingsSection::PageCache => "pagecache",
85 SettingsSection::Image => "image",
86 SettingsSection::AssetPurge => "assetpurge",
87 SettingsSection::Trash => "trash",
88 SettingsSection::Backup => "backup",
89 SettingsSection::Runner => "runner",
90 SettingsSection::RateLimit => "ratelimit",
91 SettingsSection::System => "system",
92 }
93 }
94
95 fn label(&self) -> &'static str {
97 match self {
98 SettingsSection::Site => "站点",
99 SettingsSection::Security => "安全",
100 SettingsSection::Upload => "上传",
101 SettingsSection::Cache => "图片缓存",
102 SettingsSection::PageCache => "页面缓存",
103 SettingsSection::Image => "图片处理",
104 SettingsSection::AssetPurge => "素材清理",
105 SettingsSection::Trash => "回收站",
106 SettingsSection::Backup => "自动备份",
107 SettingsSection::Runner => "代码运行器",
108 SettingsSection::RateLimit => "接口限流",
109 SettingsSection::System => "启动配置",
110 }
111 }
112
113 fn needs_restart(&self) -> bool {
115 matches!(
116 self,
117 SettingsSection::PageCache
118 | SettingsSection::Image
119 | SettingsSection::Runner
120 | SettingsSection::RateLimit
121 | SettingsSection::System
122 )
123 }
124
125 fn all() -> [SettingsSection; 12] {
127 [
128 SettingsSection::Site,
129 SettingsSection::Security,
130 SettingsSection::Upload,
131 SettingsSection::Cache,
132 SettingsSection::PageCache,
133 SettingsSection::Image,
134 SettingsSection::AssetPurge,
135 SettingsSection::Trash,
136 SettingsSection::Backup,
137 SettingsSection::Runner,
138 SettingsSection::RateLimit,
139 SettingsSection::System,
140 ]
141 }
142
143 fn dom_id(&self) -> String {
145 format!("settings-sec-{}", self.as_str())
146 }
147}
148
149#[cfg(target_arch = "wasm32")]
153fn compute_visible_section() -> Option<SettingsSection> {
154 let document = web_sys::window()?.document()?;
155 let container = document.get_element_by_id("settings-scroll")?;
156 let container_top = container.get_bounding_client_rect().top();
157
158 let at_bottom = f64::from(container.scroll_top()) + f64::from(container.client_height())
160 >= f64::from(container.scroll_height()) - 4.0;
161 let all = SettingsSection::all();
162 if at_bottom {
163 return all.last().copied();
164 }
165
166 const THRESHOLD_PX: f64 = 64.0;
169 let mut current: Option<SettingsSection> = None;
170 for section in all {
171 let Some(el) = document.get_element_by_id(§ion.dom_id()) else {
172 break;
173 };
174 if el.get_bounding_client_rect().top() - container_top <= THRESHOLD_PX {
175 current = Some(section);
176 } else {
177 break;
178 }
179 }
180 current.or_else(|| all.first().copied())
182}
183
184#[cfg(target_arch = "wasm32")]
187fn scroll_to_section(section: SettingsSection) {
188 let Some(document) = web_sys::window().and_then(|w| w.document()) else {
189 return;
190 };
191 let Some(el) = document.get_element_by_id(§ion.dom_id()) else {
192 return;
193 };
194 el.scroll_into_view();
195}
196
197fn render_section(section: SettingsSection) -> AnyView {
199 match section {
200 SettingsSection::Site => view! { <SiteSection/> }.into_any(),
201 SettingsSection::Security => view! { <SecuritySection/> }.into_any(),
202 SettingsSection::Upload => view! { <UploadSection/> }.into_any(),
203 SettingsSection::Cache => view! { <CacheSection/> }.into_any(),
204 SettingsSection::PageCache => view! { <PageCacheSection/> }.into_any(),
205 SettingsSection::Image => view! { <ImageSection/> }.into_any(),
206 SettingsSection::AssetPurge => view! { <AssetPurgeSection/> }.into_any(),
207 SettingsSection::Trash => view! { <TrashSection/> }.into_any(),
208 SettingsSection::Backup => view! { <BackupSection/> }.into_any(),
209 SettingsSection::Runner => view! { <RunnerSection/> }.into_any(),
210 SettingsSection::RateLimit => view! { <RateLimitSection/> }.into_any(),
211 SettingsSection::System => view! { <SystemSection/> }.into_any(),
212 }
213}
214
215pub struct SettingsRoute;
217
218#[lazy_route]
219impl LazyRoute for SettingsRoute {
220 fn data() -> Self {
221 Self
222 }
223
224 fn view(_this: Self) -> AnyView {
225 view! { <SiteSettingsPage/> }.into_any()
226 }
227}
228
229#[component]
234pub fn SiteSettingsPage() -> impl IntoView {
235 let bus = SettingsBus::new();
236 provide_context(bus);
237 let toast = use_toast();
238 let active = RwSignal::new(SettingsSection::Site);
239 #[cfg(target_arch = "wasm32")]
242 let spy_lock: RwSignal<Option<(SettingsSection, u32)>> = RwSignal::new(None);
243 #[cfg(target_arch = "wasm32")]
244 let lock_gen: RwSignal<u32> = RwSignal::new(0);
245 let submitted = RwSignal::new(None::<Vec<SettingsSection>>);
247
248 Effect::new(move |_| {
249 let saving = bus.saving.get();
250 if !saving.is_empty() {
251 return;
252 }
253 let Some(done) = submitted.get() else {
254 return;
255 };
256 submitted.set(None);
257 if bus.failed.get_untracked().is_empty() {
258 let restart = done.iter().any(SettingsSection::needs_restart);
259 toast.success(if restart {
260 "设置已保存,带「需重启」的部分在重启服务后生效"
261 } else {
262 "设置已保存"
263 });
264 }
265 });
266
267 let nav_links = SettingsSection::all()
268 .into_iter()
269 .map(|section| {
270 view! {
271 <a
272 href=format!("#{}", section.dom_id())
273 aria-current=move || (active.get() == section).then_some("true")
274 data-dirty=move || {
275 bus.dirty.with(|list| list.contains(§ion)).then_some("true")
276 }
277 on:click=move |e| {
278 e.prevent_default();
279 active.set(section);
280 #[cfg(target_arch = "wasm32")]
281 {
282 let g = lock_gen.get_untracked().wrapping_add(1);
283 lock_gen.set(g);
284 spy_lock.set(Some((section, g)));
285 scroll_to_section(section);
286 leptos::task::spawn_local(async move {
287 crate::utils::time::sleep_ms(1200).await;
288 spy_lock.maybe_update(|lock| {
291 let expired = matches!(lock, Some((_, gg)) if *gg == g);
292 if expired {
293 *lock = None;
294 }
295 expired
296 });
297 });
298 }
299 }
300 >
301 <span>{section.label()}</span>
302 {section
303 .needs_restart()
304 .then(|| view! { <span class="settings-restart-dot" title="修改后需重启"></span> })}
305 </a>
306 }
307 })
308 .collect_view();
309
310 let slots = SettingsSection::all()
311 .into_iter()
312 .map(|section| view! { <div class="settings-slot">{render_section(section)}</div> })
313 .collect_view();
314
315 let savebar = move || {
316 let dirty = bus.dirty.get();
317 let saving = !bus.saving.with(|list| list.is_empty());
318 let failed = bus.failed.get();
319 (!dirty.is_empty() || saving).then(|| {
320 let text = if saving {
321 "正在保存…".to_string()
322 } else if !failed.is_empty() {
323 format!("{} 个分区没有保存成功", failed.len())
324 } else {
325 format!("有未保存的更改 · {} 个分区", dirty.len())
326 };
327 let retry = !failed.is_empty();
328 view! {
329 <div class="settings-savebar" role="status">
330 <span class="settings-savebar-text">
331 <span class="settings-savebar-dot" aria-hidden="true"></span>
332 {text}
333 </span>
334 <button
335 class=button_class(ButtonVariant::Ghost, ButtonSize::Sm)
336 type="button"
337 disabled=saving
338 on:click=move |_| bus.reset_all()
339 >
340 "放弃"
341 </button>
342 <button
343 class=button_class(ButtonVariant::Primary, ButtonSize::Sm)
344 type="button"
345 disabled=saving
346 on:click=move |_| {
347 submitted.set(Some(bus.dirty.get_untracked()));
348 bus.save_all();
349 }
350 >
351 {if retry { "重试" } else { "保存设置" }}
352 </button>
353 </div>
354 }
355 })
356 };
357
358 view! {
359 <div
360 id="settings-scroll"
361 class="settings-page"
362 data-vt-scroll="admin-settings"
363 on:scroll=move |_| {
364 #[cfg(target_arch = "wasm32")]
365 {
366 let Some(current) = compute_visible_section() else {
367 return;
368 };
369 if let Some((target, _)) = spy_lock.get_untracked() {
370 if current == target {
371 spy_lock.set(None);
372 }
373 return;
374 }
375 if active.get_untracked() != current {
376 active.set(current);
377 }
378 }
379 }
380 >
381 <div class="settings-layout">
382 <nav class="settings-nav" aria-label="设置分区" data-app-anchor="true">
384 {nav_links}
385 </nav>
386 <div class="settings-main">
387 <AdminPageHead title="设置">
388 "大部分设置保存后立即生效;带 "
389 <span class="settings-restart-dot" aria-label="需重启标记"></span>
390 " 的分区需要重启服务。"
391 </AdminPageHead> {slots} {savebar}
392 </div>
393 </div>
394 </div>
395 }
396}