Skip to main content

yggdrasil/pages/admin/settings/
mod.rs

1//! 后台「站点配置」页面(滚动联动分区布局)。
2//!
3//! 左侧分类导航 + 右侧滚动内容的布局(2026 admin UX 最佳实践),将全部站点级
4//! 配置集中到一处。分区按功能域组织:
5//!
6//! - **站点**:页脚 GitHub 链接等公开配置(前台可见)
7//! - **安全**:CSRF 可信源、cookie Secure、代理层数、并发会话上限(即时生效)
8//! - **缓存**:图片磁盘缓存容量与保留策略(即时生效)
9//! - **素材清理**:无引用图片自动清理开关与保留天数(即时生效)
10//! - **备份**:自动备份调度与保留份数(即时生效,备份操作仍在 /admin/system)
11//! - **回收站**:自动清理开关与保留天数(即时生效)
12//! - **上传**:素材上传弹窗并发数(即时生效)
13//! - **系统**:只读展示启动时配置(数据库、日志、Docker 等,需重启生效)
14//!
15//! 与 tab 切换不同,全部分区**常驻挂载、纵向堆叠**在同一个滚动容器里:
16//! - 点击左侧导航 → 容器 `scroll-smooth` + `scrollIntoView` 平滑滚动到对应分区;
17//! - 手动滚动 → scroll-spy(复用 changelog.rs 的 getBoundingClientRect 判定线
18//!   模式)实时同步左侧高亮;
19//! - 点击触发的平滑滚动期间用「世代锁」抑制 spy,避免途经分区依次闪过高亮。
20//!
21//! 各分区拆分为独立子模块,状态完全独立、互不共享,全部挂载后各自存活。
22//!
23//! 仅 WASM 前端交互(照 mcp.rs / friends.rs 的 `#[cfg(target_arch = "wasm32")]`
24//! 门控模式)。
25
26mod 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/// 设置页面的分区枚举。
53#[derive(Clone, Copy, PartialEq, Debug)]
54pub enum SettingsSection {
55    /// 站点公开配置(页脚 GitHub 链接等)。
56    Site,
57    /// 安全配置(CSRF / cookie / 代理 / 会话)。
58    Security,
59    /// 缓存配置(图片磁盘缓存)。
60    Cache,
61    /// 孤儿素材自动清理。
62    AssetPurge,
63    /// 限流配置(各接口速率限制)。
64    RateLimit,
65    /// 图片处理配置(WebP 编码 / 尺寸上限)。
66    Image,
67    /// 代码运行器配置(沙箱资源限制)。
68    Runner,
69    /// 自动备份调度。
70    Backup,
71    /// 回收站自动清理。
72    Trash,
73    /// 素材上传并发。
74    Upload,
75    /// 系统启动配置(只读)。
76    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    /// 分区显示名称。
97    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    /// 分区图标(Feather 线框 SVG path d 属性)。
114    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    /// 所有分区,按导航顺序。
131    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    /// 分区锚点元素的 DOM id(scroll-spy 判定与 scrollIntoView 定位共用)。
148    fn dom_id(&self) -> String {
149        format!("settings-sec-{}", self.as_str())
150    }
151}
152
153/// 计算滚动容器中当前命中的分区(仅 wasm;模式复用 changelog.rs 的判定线法:
154/// 容器内部分区数量有限,每次滚动事件十几次 rect 读取成本可忽略,无需
155/// IntersectionObserver 那套可见性集合管理)。
156#[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    // 贴底 → 强制末项:末分区内容短时永远到不了判定线。
163    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    // 判定线 = 容器顶部往下 40px(须大于分区的 scroll-mt-2=8px 落点余量)。
171    // 顶部已越过判定线的最后一个分区 = 当前阅读位置。
172    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(&section.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    // 首分区尚未越过判定线(页面顶附近)→ 回退首项。
185    current.or_else(|| all.first().copied())
186}
187
188/// 平滑滚动到指定分区(仅 wasm)。平滑由滚动容器的 `scroll-smooth`
189/// (scroll-behavior: smooth)提供,这里只需瞬时 scrollIntoView。
190#[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(&section.dom_id()) else {
196        return;
197    };
198    el.scroll_into_view();
199}
200
201/// 渲染单个分区组件。全部分区常驻挂载,各分区状态独立、互不共享。
202fn 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/// 管理后台站点配置页面(滚动联动分区布局)。
241///
242/// 左侧分类导航 + 右侧纵向堆叠的全部分区。点击导航平滑滚动到对应分区;
243/// 手动滚动时 scroll-spy 同步左侧高亮(`active` 信号驱动)。
244#[component]
245pub fn SiteSettingsPage() -> Element {
246    let mut active = use_signal(|| SettingsSection::Site);
247    // scroll-spy 锁定:点击导航后的平滑滚动期间抑制 spy 改写 active,避免途经
248    // 分区依次闪过高亮。u32 为单调世代号,防止上一次点击的超时回调误清本次的锁。
249    // 仅 wasm 使用(server 目标下 onscroll/onclick 体内的 DOM 逻辑整体 cfg 掉)。
250    #[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    // 展示信号:toast_state 驱动 is-open(展开/收起),display_* 保留最近一条消息,
257    // 使退出动画(收起+淡出)期间文本不闪。
258    let mut display_msg: Signal<String> = use_signal(String::new);
259    let mut display_err: Signal<bool> = use_signal(|| false);
260    // toast 出现时同步展示信号 + 启动 3 秒自动消失计时器。
261    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                // 仅当 toast 未被新消息覆盖时清除(避免误清后续 toast)
269                if toast_state().map(|(m, _)| m == key).unwrap_or(false) {
270                    toast_state.set(None);
271                }
272            });
273        }
274    });
275    rsx! {
276        // flex-1 min-h-0 作为 main 的 flex 子项获得有界高度(AdminLayout 对 settings 路由
277        // 已切到 internal-scroll 变体:卡片 overflow-hidden、main 无 padding 纯 flex 容器),
278        // 故内边距 px-6 py-12 由本页自带。纯 flex 约束,不用百分比——百分比会被 main 的
279        // min-height:auto 循环撑大失效。页头固定,下方 flex-1 区域仅占剩余高度。
280        div { class: "animate-page-enter w-full flex-1 min-h-0 flex flex-col px-6 py-8 sm:py-12",
281            // 页头(固定,不随右侧内容滚动)
282            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            // 操作提示条(各分区共享;grid-rows 高度过渡 + 淡入淡出,3 秒自动消失)
294            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            // 顶部横向分类导航栏(水平胶囊语言,消除双侧栏宽度不协调问题)
301            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, &section) 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            // 内容区:全部分区常驻挂载、纵向堆叠在同一个滚动容器里
352            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, &section) 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}