Skip to main content

yggdrasil/pages/admin/settings/
ratelimit_section.rs

1//! 限流配置分区(各接口速率限制)。
2//!
3//! Tier B 可编辑+重启生效:面板修改后需重启服务生效(限流桶 LazyLock 在
4//! 首次请求时从启动配置构建)。
5
6use dioxus::prelude::*;
7
8#[cfg(target_arch = "wasm32")]
9use crate::api::settings::{get_rate_limit_settings, update_rate_limit_settings};
10#[cfg(target_arch = "wasm32")]
11use crate::components::forms::{FormLabel, INPUT_CLASS};
12#[cfg(target_arch = "wasm32")]
13use crate::components::ui::{LoadingButton, ADMIN_CARD_CLASS};
14#[cfg(target_arch = "wasm32")]
15use crate::models::settings::RateLimitSettings;
16
17/// 限流配置分区组件。
18#[allow(non_snake_case)]
19#[component]
20pub fn RateLimitSection(toast: Callback<(String, bool)>) -> Element {
21    #[cfg(target_arch = "wasm32")]
22    {
23        let mut saved: Signal<RateLimitSettings> = use_signal(RateLimitSettings::default);
24        let mut draft: Signal<RateLimitSettings> = use_signal(RateLimitSettings::default);
25        let loading = use_signal(|| true);
26        let mut saving = use_signal(|| false);
27        let mut just_saved = use_signal(|| false);
28
29        use_effect(move || {
30            #[cfg(target_arch = "wasm32")]
31            {
32                let mut saved = saved;
33                let mut draft = draft;
34                let mut loading = loading;
35                spawn(async move {
36                    match get_rate_limit_settings().await {
37                        Ok(s) => {
38                            saved.set(s.clone());
39                            draft.set(s);
40                        }
41                        Err(e) => toast.call((format!("加载失败:{e}"), true)),
42                    }
43                    loading.set(false);
44                });
45            }
46        });
47
48        let dirty = use_memo(move || draft() != saved());
49
50        rsx! {
51            div { class: "space-y-6",
52                div { class: "{ADMIN_CARD_CLASS} p-6 md:p-8 flex flex-col gap-6",
53                    div { class: "flex items-center gap-3",
54                        span { class: "inline-flex items-center justify-center w-10 h-10 rounded-full bg-[var(--color-paper-theme)] text-[var(--color-paper-primary)] border border-[var(--color-paper-border)]",
55                            svg {
56                                class: "w-5 h-5",
57                                view_box: "0 0 24 24",
58                                fill: "none",
59                                stroke: "currentColor",
60                                stroke_width: "1.8",
61                                stroke_linecap: "round",
62                                stroke_linejoin: "round",
63                                path { d: "M12 2a10 10 0 1 0 10 10A10 10 0 0 0 12 2z M12 6v6l4 2" }
64                            }
65                        }
66                        div {
67                            h2 { class: "text-xl font-bold text-[var(--color-paper-primary)]",
68                                "接口限流"
69                            }
70                            p { class: "text-sm text-[var(--color-paper-secondary)] mt-0.5",
71                                span { class: "text-amber-600 dark:text-amber-400 font-medium",
72                                    "⚠ 修改后需重启服务生效。"
73                                }
74                            }
75                        }
76                    }
77
78                    // 各限流桶的 per_sec / burst 输入
79                    LimiterField {
80                        label: "严格限流(登录/注册)",
81                        per_sec: draft().strict_per_sec,
82                        burst: draft().strict_burst,
83                        on_per_sec: move |v| {
84                            let mut d = draft();
85                            d.strict_per_sec = v;
86                            draft.set(d);
87                            just_saved.set(false);
88                        },
89                        on_burst: move |v| {
90                            let mut d = draft();
91                            d.strict_burst = v;
92                            draft.set(d);
93                            just_saved.set(false);
94                        },
95                    }
96                    LimiterField {
97                        label: "上传限流(图片上传)",
98                        per_sec: draft().upload_per_sec,
99                        burst: draft().upload_burst,
100                        on_per_sec: move |v| {
101                            let mut d = draft();
102                            d.upload_per_sec = v;
103                            draft.set(d);
104                            just_saved.set(false);
105                        },
106                        on_burst: move |v| {
107                            let mut d = draft();
108                            d.upload_burst = v;
109                            draft.set(d);
110                            just_saved.set(false);
111                        },
112                    }
113                    LimiterField {
114                        label: "图片访问限流(GET /uploads/*)",
115                        per_sec: draft().image_per_sec,
116                        burst: draft().image_burst,
117                        on_per_sec: move |v| {
118                            let mut d = draft();
119                            d.image_per_sec = v;
120                            draft.set(d);
121                            just_saved.set(false);
122                        },
123                        on_burst: move |v| {
124                            let mut d = draft();
125                            d.image_burst = v;
126                            draft.set(d);
127                            just_saved.set(false);
128                        },
129                    }
130                    LimiterField {
131                        label: "评论限流(创建评论)",
132                        per_sec: draft().comment_per_sec,
133                        burst: draft().comment_burst,
134                        on_per_sec: move |v| {
135                            let mut d = draft();
136                            d.comment_per_sec = v;
137                            draft.set(d);
138                            just_saved.set(false);
139                        },
140                        on_burst: move |v| {
141                            let mut d = draft();
142                            d.comment_burst = v;
143                            draft.set(d);
144                            just_saved.set(false);
145                        },
146                    }
147                    LimiterField {
148                        label: "评论图片上传限流(含匿名)",
149                        per_sec: draft().comment_upload_per_sec,
150                        burst: draft().comment_upload_burst,
151                        on_per_sec: move |v| {
152                            let mut d = draft();
153                            d.comment_upload_per_sec = v;
154                            draft.set(d);
155                            just_saved.set(false);
156                        },
157                        on_burst: move |v| {
158                            let mut d = draft();
159                            d.comment_upload_burst = v;
160                            draft.set(d);
161                            just_saved.set(false);
162                        },
163                    }
164                    LimiterField {
165                        label: "代码执行限流",
166                        per_sec: draft().code_exec_per_sec,
167                        burst: draft().code_exec_burst,
168                        on_per_sec: move |v| {
169                            let mut d = draft();
170                            d.code_exec_per_sec = v;
171                            draft.set(d);
172                            just_saved.set(false);
173                        },
174                        on_burst: move |v| {
175                            let mut d = draft();
176                            d.code_exec_burst = v;
177                            draft.set(d);
178                            just_saved.set(false);
179                        },
180                    }
181                    LimiterField {
182                        label: "unknown 桶(无法识别 IP)",
183                        per_sec: draft().unknown_per_sec,
184                        burst: draft().unknown_burst,
185                        on_per_sec: move |v| {
186                            let mut d = draft();
187                            d.unknown_per_sec = v;
188                            draft.set(d);
189                            just_saved.set(false);
190                        },
191                        on_burst: move |v| {
192                            let mut d = draft();
193                            d.unknown_burst = v;
194                            draft.set(d);
195                            just_saved.set(false);
196                        },
197                    }
198
199                    // 评论图片上传日限额 + 代码执行日限额 + GC 间隔
200                    div { class: "flex flex-col gap-2 max-w-xl",
201                        FormLabel {
202                            label: "评论图片上传日限额(次/天,按 IP)",
203                            html_for: Some("rl-comment-upload-daily".to_string()),
204                        }
205                        input {
206                            id: "rl-comment-upload-daily",
207                            r#type: "number",
208                            min: "1",
209                            class: "{INPUT_CLASS}",
210                            value: "{draft().comment_upload_daily}",
211                            oninput: move |e: Event<FormData>| {
212                                let v = e.value();
213                                if let Ok(n) = v.parse::<u32>() {
214                                    let mut d = draft();
215                                    d.comment_upload_daily = n;
216                                    draft.set(d);
217                                    just_saved.set(false);
218                                }
219                            },
220                        }
221                        p { class: "text-xs text-[var(--color-paper-secondary)]",
222                            "评论区允许匿名传图,日限额是防磁盘耗尽的关键防线。"
223                        }
224                    }
225                    div { class: "flex flex-col gap-2 max-w-xl",
226                        FormLabel {
227                            label: "代码执行日限额(次/天)",
228                            html_for: Some("rl-code-daily".to_string()),
229                        }
230                        input {
231                            id: "rl-code-daily",
232                            r#type: "number",
233                            min: "1",
234                            class: "{INPUT_CLASS}",
235                            value: "{draft().code_exec_daily}",
236                            oninput: move |e: Event<FormData>| {
237                                let v = e.value();
238                                if let Ok(n) = v.parse::<u32>() {
239                                    let mut d = draft();
240                                    d.code_exec_daily = n;
241                                    draft.set(d);
242                                    just_saved.set(false);
243                                }
244                            },
245                        }
246                    }
247                    div { class: "flex flex-col gap-2 max-w-xl",
248                        FormLabel {
249                            label: "限流桶 GC 间隔(秒)",
250                            html_for: Some("rl-gc".to_string()),
251                        }
252                        input {
253                            id: "rl-gc",
254                            r#type: "number",
255                            min: "1",
256                            class: "{INPUT_CLASS}",
257                            value: "{draft().gc_interval_secs}",
258                            oninput: move |e: Event<FormData>| {
259                                let v = e.value();
260                                if let Ok(n) = v.parse::<u32>() {
261                                    let mut d = draft();
262                                    d.gc_interval_secs = n;
263                                    draft.set(d);
264                                    just_saved.set(false);
265                                }
266                            },
267                        }
268                        p { class: "text-xs text-[var(--color-paper-secondary)]",
269                            "周期性回收已冷却的 IP 键,防止键空间无限膨胀。"
270                        }
271                    }
272
273                    div { class: "flex items-center justify-between gap-4 pt-1",
274                        if just_saved() {
275                            span { class: "inline-flex items-center gap-1.5 text-xs text-[var(--color-paper-accent)]",
276                                svg {
277                                    class: "w-3.5 h-3.5",
278                                    view_box: "0 0 24 24",
279                                    fill: "none",
280                                    stroke: "currentColor",
281                                    stroke_width: "2.5",
282                                    path {
283                                        stroke_linecap: "round",
284                                        stroke_linejoin: "round",
285                                        d: "M5 13l4 4L19 7",
286                                    }
287                                }
288                                "已保存(重启后生效)"
289                            }
290                        } else if dirty() {
291                            span { class: "text-xs text-[var(--color-paper-secondary)]",
292                                "有未保存的更改"
293                            }
294                        } else {
295                            span { class: "text-xs text-transparent select-none", "·" }
296                        }
297                        LoadingButton {
298                            label: "保存设置".to_string(),
299                            loading: saving(),
300                            disabled: loading() || just_saved() || !dirty(),
301                            onclick: move |_| {
302                                let d = draft();
303                                saving.set(true);
304                                spawn(async move {
305                                    match update_rate_limit_settings(
306                                            d.strict_per_sec,
307                                            d.strict_burst,
308                                            d.upload_per_sec,
309                                            d.upload_burst,
310                                            d.image_per_sec,
311                                            d.image_burst,
312                                            d.comment_per_sec,
313                                            d.comment_burst,
314                                            d.comment_upload_per_sec,
315                                            d.comment_upload_burst,
316                                            d.comment_upload_daily,
317                                            d.code_exec_per_sec,
318                                            d.code_exec_burst,
319                                            d.code_exec_daily,
320                                            d.unknown_per_sec,
321                                            d.unknown_burst,
322                                            d.gc_interval_secs,
323                                        )
324                                        .await
325                                    {
326                                        Ok(s) => {
327                                            saved.set(s.clone());
328                                            draft.set(s);
329                                            just_saved.set(true);
330                                            toast.call(("保存成功".to_string(), false));
331                                        }
332                                        Err(e) => toast.call((format!("保存失败:{e}"), true)),
333                                    }
334                                    saving.set(false);
335                                });
336                            },
337                        }
338                    }
339                }
340            }
341        }
342    }
343    #[cfg(not(target_arch = "wasm32"))]
344    {
345        rsx! {
346            div { class: "p-8 text-[var(--color-paper-secondary)]", "限流配置(前端渲染)" }
347        }
348    }
349}
350#[cfg(target_arch = "wasm32")]
351/// 单个限流桶的 per_sec + burst 双输入行。
352#[allow(non_snake_case)]
353#[component]
354fn LimiterField(
355    label: String,
356    per_sec: u32,
357    burst: u32,
358    on_per_sec: Callback<u32>,
359    on_burst: Callback<u32>,
360) -> Element {
361    rsx! {
362        div { class: "flex flex-col gap-2 max-w-xl",
363            FormLabel { label: "{label}", html_for: None }
364            div { class: "flex gap-3",
365                div { class: "flex-1",
366                    input {
367                        r#type: "number",
368                        min: "1",
369                        class: "{INPUT_CLASS}",
370                        value: "{per_sec}",
371                        oninput: move |e: Event<FormData>| {
372                            let v = e.value();
373                            if let Ok(n) = v.parse::<u32>() {
374                                on_per_sec.call(n);
375                            }
376                        },
377                    }
378                    p { class: "text-xs text-[var(--color-paper-tertiary)] mt-1",
379                        "每秒请求数"
380                    }
381                }
382                div { class: "flex-1",
383                    input {
384                        r#type: "number",
385                        min: "1",
386                        class: "{INPUT_CLASS}",
387                        value: "{burst}",
388                        oninput: move |e: Event<FormData>| {
389                            let v = e.value();
390                            if let Ok(n) = v.parse::<u32>() {
391                                on_burst.call(n);
392                            }
393                        },
394                    }
395                    p { class: "text-xs text-[var(--color-paper-tertiary)] mt-1",
396                        "突发上限"
397                    }
398                }
399            }
400        }
401    }
402}