Skip to main content

yggdrasil/pages/admin/settings/
mod.rs

1//! 后台「设置」页面(`/admin/settings`)。
2//!
3//! 左侧分区目录 + 右侧纵向堆叠的全部分区,按「站点、安全、上传、图片缓存、页面缓存、图片处理、
4//! 素材清理、回收站、自动备份、代码运行器、接口限流、启动配置」排列。全部分区常驻
5//! 挂载在同一个滚动容器里:
6//! - 点目录 → `scrollIntoView` 平滑滚动到分区,期间用「世代锁」抑制 scroll-spy,
7//!   避免途经分区依次闪过高亮;
8//! - 手动滚动 → scroll-spy(判定线法)同步目录高亮。
9//!
10//! 各分区各自读取与提交(见 `kit.rs`),改动由底部浮起的保存条统一保存:只提交有
11//! 改动的分区,「放弃」把全部草稿还原。需要重启才生效的分区在目录与卡片上标出。
12//! 样式见 styles/pages/admin-settings.css。
13
14mod 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/// 设置页面的分区枚举,顺序即页面与导航顺序。
49#[derive(Clone, Copy, PartialEq, Eq, Debug)]
50pub enum SettingsSection {
51    /// 站点公开配置(页脚链接、首页标语、站点简介)。
52    Site,
53    /// 安全配置(CSRF / cookie / 代理 / 会话)。
54    Security,
55    /// 素材上传并发。
56    Upload,
57    /// 图片磁盘缓存。
58    Cache,
59    /// 匿名公开页 HTML 缓存,重启生效。
60    PageCache,
61    /// 图片处理(WebP 编码 / 尺寸上限),重启生效。
62    Image,
63    /// 无引用素材自动清理。
64    AssetPurge,
65    /// 回收站自动清理。
66    Trash,
67    /// 自动备份调度。
68    Backup,
69    /// 代码运行器(沙箱资源限制),重启生效。
70    Runner,
71    /// 接口限流,重启生效。
72    RateLimit,
73    /// 启动配置(只读)。
74    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    /// 分区名称(导航与卡片标题)。
96    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    /// 修改后需要重启服务才生效。
114    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    /// 所有分区,按导航顺序。
126    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    /// 分区锚点元素的 DOM id(scroll-spy 判定与 scrollIntoView 定位共用)。
144    fn dom_id(&self) -> String {
145        format!("settings-sec-{}", self.as_str())
146    }
147}
148
149/// 计算滚动容器中当前命中的分区(仅 wasm;模式复用 changelog.rs 的判定线法:
150/// 容器内部分区数量有限,每次滚动事件十几次 rect 读取成本可忽略,无需
151/// IntersectionObserver 那套可见性集合管理)。
152#[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    // 贴底 → 强制末项:末分区内容短时永远到不了判定线。
159    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    // 判定线 = 容器顶部往下 64px(须大于 .settings-card 的 scroll-margin-top:桌面 1rem、窄屏 3.5rem)。
167    // 顶部已越过判定线的最后一个分区 = 当前阅读位置。
168    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(&section.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    // 首分区尚未越过判定线(页面顶附近)→ 回退首项。
181    current.or_else(|| all.first().copied())
182}
183
184/// 平滑滚动到指定分区(仅 wasm)。平滑由滚动容器的 `scroll-smooth`
185/// (scroll-behavior: smooth)提供,这里只需瞬时 scrollIntoView。
186#[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(&section.dom_id()) else {
192        return;
193    };
194    el.scroll_into_view();
195}
196
197/// 渲染单个分区组件。全部分区常驻挂载,各自读取与提交,经保存条统一保存。
198fn 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
215/// `/admin/settings` 的懒加载路由。
216pub 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/// 管理后台设置页面:左侧分区目录,右侧纵向堆叠全部分区,底部浮起保存条。
230///
231/// 点目录平滑滚动到分区,手动滚动时 scroll-spy 同步目录高亮。各分区的改动由保存条
232/// 统一提交,只提交有改动的分区;需要重启的分区在目录与卡片上标出。
233#[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    // scroll-spy 锁定:点击目录后的平滑滚动期间不让 spy 改写高亮,避免途经分区依次闪过。
240    // u32 为单调世代号,防止上一次点击的超时回调误清本次的锁。
241    #[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    // 一次保存涉及的分区:全部提交结束后据此报告结果。
246    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(&section)).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                                // 计时期间页面可能已卸载(信号已释放):maybe_update 此时静默跳过,
289                                // 只有世代号仍匹配才清锁,未改动时不通知订阅者。
290                                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                // data-app-anchor:让全局 initAnchorClick 放行,下方 a 的 click 才能执行。
383                <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}