Skip to main content

yggdrasil/pages/admin/settings/
kit.rs

1//! 设置页各分区共用的保存协调与版式组件。
2//!
3//! 各分区只管自己的草稿与已存值:经 [`use_settings_slot`] 登记「是否有改动」以及
4//! 保存、放弃两个回调。页面底部的浮起保存条读取 [`SettingsBus`]:点「保存设置」只让
5//! 有改动的分区提交,分区提交完毕调用 [`SettingsBus::finish`] 报告结果;点「放弃」
6//! 让所有分区把草稿还原为已存值。
7
8use std::future::Future;
9
10use leptos::prelude::*;
11use leptos::task::spawn_local;
12
13use super::SettingsSection;
14use crate::api::error::ApiError;
15use crate::components::ui::use_toast;
16use crate::utils::error::failure_message;
17
18/// 保存条与各分区之间的共享状态。
19#[derive(Clone, Copy, PartialEq)]
20pub(super) struct SettingsBus {
21    /// 有改动的分区。
22    pub dirty: RwSignal<Vec<SettingsSection>>,
23    /// 正在提交的分区。
24    pub saving: RwSignal<Vec<SettingsSection>>,
25    /// 最近一次提交失败的分区(再次提交或放弃时清除)。
26    pub failed: RwSignal<Vec<SettingsSection>>,
27    /// 每点一次「保存设置」加一。
28    pub save_gen: RwSignal<u32>,
29    /// 每点一次「放弃」加一。
30    pub reset_gen: RwSignal<u32>,
31}
32
33impl SettingsBus {
34    pub fn new() -> Self {
35        Self {
36            dirty: RwSignal::new(Vec::new()),
37            saving: RwSignal::new(Vec::new()),
38            failed: RwSignal::new(Vec::new()),
39            save_gen: RwSignal::new(0),
40            reset_gen: RwSignal::new(0),
41        }
42    }
43
44    /// 分区提交结束:移出「提交中」,失败时记入失败列表。
45    pub fn finish(self, section: SettingsSection, ok: bool) {
46        self.saving.update(|list| list.retain(|s| *s != section));
47        if !ok {
48            self.failed.update(|list| {
49                if !list.contains(&section) {
50                    list.push(section);
51                }
52            });
53        }
54    }
55
56    /// 保存条:提交全部有改动的分区。
57    pub fn save_all(self) {
58        if !self.saving.with_untracked(|list| list.is_empty()) {
59            return;
60        }
61        self.failed.set(Vec::new());
62        self.save_gen.update(|g| *g += 1);
63    }
64
65    /// 保存条:放弃全部改动。
66    pub fn reset_all(self) {
67        self.failed.set(Vec::new());
68        self.reset_gen.update(|g| *g += 1);
69    }
70}
71
72fn set_member(list: RwSignal<Vec<SettingsSection>>, section: SettingsSection, on: bool) {
73    let present = list
74        .try_with_untracked(|l| l.contains(&section))
75        .unwrap_or(on);
76    if on && !present {
77        list.update(|l| l.push(section));
78    } else if !on && present {
79        list.update(|l| l.retain(|s| *s != section));
80    }
81}
82
83/// 登记一个分区。`save` 负责发起提交,结束时调用 `finish`;`reset` 把草稿还原。
84pub(super) fn use_settings_slot(
85    section: SettingsSection,
86    dirty: Memo<bool>,
87    save: Callback<()>,
88    reset: Callback<()>,
89) -> SettingsBus {
90    let bus = expect_context::<SettingsBus>();
91    Effect::new(move |_| set_member(bus.dirty, section, dirty.get()));
92    let seen_save = StoredValue::new(bus.save_gen.get_untracked());
93    Effect::new(move |_| {
94        let generation = bus.save_gen.get();
95        if generation == seen_save.get_value() {
96            return;
97        }
98        seen_save.set_value(generation);
99        if dirty.get_untracked() {
100            set_member(bus.saving, section, true);
101            save.run(());
102        }
103    });
104    let seen_reset = StoredValue::new(bus.reset_gen.get_untracked());
105    Effect::new(move |_| {
106        let generation = bus.reset_gen.get();
107        if generation == seen_reset.get_value() {
108            return;
109        }
110        seen_reset.set_value(generation);
111        reset.run(());
112    });
113    on_cleanup(move || {
114        set_member(bus.dirty, section, false);
115        set_member(bus.saving, section, false);
116    });
117    bus
118}
119
120/// 一个分区的草稿、已存值与读取状态。
121pub(super) struct SectionState<T: Send + Sync + 'static> {
122    pub draft: RwSignal<T>,
123    pub saved: RwSignal<Option<T>>,
124    pub load_failed: RwSignal<bool>,
125    pub bus: SettingsBus,
126    pub section: SettingsSection,
127}
128
129impl<T: Send + Sync + 'static> Clone for SectionState<T> {
130    fn clone(&self) -> Self {
131        *self
132    }
133}
134
135impl<T: Send + Sync + 'static> Copy for SectionState<T> {}
136
137impl<T: Clone + PartialEq + Send + Sync + 'static> SectionState<T> {
138    /// 已读到服务端的值,可以编辑。
139    pub fn ready(&self) -> Signal<bool> {
140        let saved = self.saved;
141        Signal::derive(move || saved.with(|s| s.is_some()))
142    }
143
144    /// 读取失败。
145    pub fn load_failed(&self) -> Signal<bool> {
146        self.load_failed.into()
147    }
148
149    /// 最近一次提交失败。
150    pub fn failed(&self) -> Signal<bool> {
151        let (bus, section) = (self.bus, self.section);
152        Signal::derive(move || bus.failed.with(|list| list.contains(&section)))
153    }
154
155    /// 草稿中某个字段的响应式读取。
156    pub fn field<U: Send + Sync + 'static>(
157        &self,
158        read: impl Fn(&T) -> U + Send + Sync + 'static,
159    ) -> Signal<U> {
160        let draft = self.draft;
161        Signal::derive(move || draft.with(&read))
162    }
163
164    /// 修改草稿。
165    pub fn edit(&self, change: impl FnOnce(&mut T)) {
166        self.draft.update(change);
167    }
168}
169
170/// 读取一次已存值并登记到保存条。`save` 提交草稿并返回服务端规范化后的值。
171pub(super) fn use_section<T, L, LF, S, SF>(
172    section: SettingsSection,
173    load: L,
174    save: S,
175) -> SectionState<T>
176where
177    T: Clone + PartialEq + Default + Send + Sync + 'static,
178    L: FnOnce() -> LF + 'static,
179    LF: Future<Output = Result<T, ApiError>> + 'static,
180    S: Fn(T) -> SF + Copy + Send + Sync + 'static,
181    SF: Future<Output = Result<T, ApiError>> + 'static,
182{
183    let toast = use_toast();
184    let bus = expect_context::<SettingsBus>();
185    let draft = RwSignal::new(T::default());
186    let saved = RwSignal::new(None::<T>);
187    let load_failed = RwSignal::new(false);
188    // Effect 只在浏览器里运行:服务端渲染不请求。
189    let mut load = Some(load);
190    Effect::new(move |_| {
191        let Some(load) = load.take() else {
192            return;
193        };
194        spawn_local(async move {
195            match load().await {
196                Ok(value) => {
197                    draft.set(value.clone());
198                    saved.set(Some(value));
199                }
200                Err(_) => load_failed.set(true),
201            }
202        });
203    });
204    let dirty =
205        Memo::new(move |_| saved.with(|s| s.as_ref().is_some_and(|s| draft.with(|d| s != d))));
206    let on_save = Callback::new(move |()| {
207        let value = draft.get_untracked();
208        spawn_local(async move {
209            match save(value).await {
210                Ok(next) => {
211                    draft.set(next.clone());
212                    saved.set(Some(next));
213                    bus.finish(section, true);
214                }
215                Err(e) => {
216                    toast.error(failure_message(
217                        &format!("「{}」保存失败", section.label()),
218                        &e,
219                    ));
220                    bus.finish(section, false);
221                }
222            }
223        });
224    });
225    let on_reset = Callback::new(move |()| {
226        if let Some(value) = saved.get_untracked() {
227            draft.set(value);
228        }
229    });
230    use_settings_slot(section, dirty, on_save, on_reset);
231    SectionState {
232        draft,
233        saved,
234        load_failed,
235        bus,
236        section,
237    }
238}
239
240/// 分区卡片:标题、说明与「需重启」标记,正文为若干设置行。
241#[component]
242pub(super) fn SettingsCard(
243    section: SettingsSection,
244    #[prop(into)] description: String,
245    #[prop(optional, into)] failed: Signal<bool>,
246    #[prop(into)] ready: Signal<bool>,
247    #[prop(into)] load_failed: Signal<bool>,
248    children: ChildrenFn,
249) -> impl IntoView {
250    let heading = format!("{}-h", section.dom_id());
251    view! {
252        <section
253            id=section.dom_id()
254            class="admin-panel settings-card"
255            data-failed=move || failed.get().then_some("true")
256            aria-labelledby=heading.clone()
257        >
258            <div class="settings-card-head">
259                <div>
260                    <h2 id=heading.clone()>{section.label()}</h2>
261                    <p>{description}</p>
262                </div>
263                {section.needs_restart().then(|| view! { <span class="settings-restart">"需重启"</span> })}
264            </div>
265            {move || {
266                if load_failed.get() {
267                    view! {
268                        <p class="settings-load-error" role="alert">"读取失败,请刷新页面重试。"</p>
269                    }
270                        .into_any()
271                } else if ready.get() {
272                    view! { <div class="settings-rows">{children()}</div> }.into_any()
273                } else {
274                    view! {
275                        <div class="settings-rows" aria-busy="true">
276                            {(0..2)
277                                .map(|_| {
278                                    view! {
279                                        <div class="settings-row">
280                                            <crate::components::skeletons::atoms::SkeletonBox class="h-3.5 w-20 rounded"/>
281                                            <crate::components::skeletons::atoms::SkeletonBox class="h-9 w-full rounded-lg"/>
282                                        </div>
283                                    }
284                                })
285                                .collect_view()}
286                        </div>
287                    }
288                        .into_any()
289                }
290            }}
291        </section>
292    }
293}
294
295/// 标签在左、控件与说明在右的一行。
296#[component]
297pub(super) fn SettingRow(
298    #[prop(into)] label: String,
299    #[prop(optional, into)] html_for: Option<String>,
300    #[prop(optional, into)] hint: Option<String>,
301    children: Children,
302) -> impl IntoView {
303    let label_view = match html_for {
304        Some(id) => view! { <label class="settings-label" for=id>{label}</label> }.into_any(),
305        None => view! { <span class="settings-label">{label}</span> }.into_any(),
306    };
307    view! {
308        <div class="settings-row">
309            {label_view} <div class="settings-control">
310                {children()} {hint.map(|hint| view! { <p class="settings-hint">{hint}</p> })}
311            </div>
312        </div>
313    }
314}
315
316/// 开关行:标签、说明与右侧开关。
317#[component]
318pub(super) fn SwitchRow(
319    #[prop(into)] label: String,
320    #[prop(into)] hint: String,
321    #[prop(into)] checked: Signal<bool>,
322    #[prop(into)] ontoggle: Callback<()>,
323) -> impl IntoView {
324    view! {
325        <div class="settings-row settings-switch-row">
326            <span class="settings-label">{label.clone()}</span>
327            <p class="settings-hint">{hint}</p>
328            <crate::components::forms::ToggleSwitch checked aria_label=label ontoggle/>
329        </div>
330    }
331}