Skip to main content

yggdrasil/pages/
register.rs

1//! 注册页面
2//!
3//! 首个注册成功的用户将自动成为管理员,后续注册请求会被服务端拒绝。
4//! 字段离开焦点后即时校验(规则与服务端一致),提交时再校验一次全部字段。
5
6use leptos::prelude::*;
7use leptos::task::spawn_local;
8use leptos_meta::Title;
9use leptos_router::{lazy_route, LazyRoute};
10
11use crate::api::auth::{register, AuthResponse};
12use crate::components::ui::{Button, ButtonSize, ButtonVariant};
13use crate::pages::login::{AuthAlert, AuthField, AuthShell, PasswordField};
14use crate::routes::Route;
15use crate::utils::error::failure_message;
16
17/// 用户名:3–50 字节,只含字母、数字与下划线(与服务端 `validate_username` 一致)。
18fn username_error(value: &str) -> Option<String> {
19    if value.len() < 3 || value.len() > 50 {
20        Some("用户名长度需在 3–50 个字符之间".into())
21    } else if !value.chars().all(|c| c.is_alphanumeric() || c == '_') {
22        Some("只能包含字母、数字和下划线".into())
23    } else {
24        None
25    }
26}
27
28/// 邮箱:本地部分、`@`、带点的域名与至少两位字母的后缀。
29fn email_error(value: &str) -> Option<String> {
30    let valid = value.split_once('@').is_some_and(|(local, domain)| {
31        let ok_local = !local.is_empty()
32            && local
33                .chars()
34                .all(|c| c.is_ascii_alphanumeric() || "._%+-".contains(c));
35        let ok_domain = domain.rsplit_once('.').is_some_and(|(host, tld)| {
36            !host.is_empty()
37                && host
38                    .chars()
39                    .all(|c| c.is_ascii_alphanumeric() || ".-".contains(c))
40                && tld.len() >= 2
41                && tld.chars().all(|c| c.is_ascii_alphabetic())
42        });
43        ok_local && ok_domain
44    });
45    (!valid).then(|| "邮箱格式不正确".into())
46}
47
48fn password_error(value: &str) -> Option<String> {
49    if value.len() < 8 {
50        Some("密码至少 8 位".into())
51    } else if value.len() > 256 {
52        Some("密码不能超过 256 位".into())
53    } else {
54        None
55    }
56}
57
58fn confirm_error(password: &str, confirm: &str) -> Option<String> {
59    (password != confirm).then(|| "两次输入的密码不一致".into())
60}
61
62/// 注册页面路由(无 SSR 数据)。
63pub struct RegisterRoute;
64
65#[lazy_route]
66impl LazyRoute for RegisterRoute {
67    fn data() -> Self {
68        Self
69    }
70
71    fn view(_this: Self) -> AnyView {
72        view! { <Register/> }.into_any()
73    }
74}
75
76/// 注册页面组件
77#[component]
78fn Register() -> impl IntoView {
79    let username = RwSignal::new(String::new());
80    let email = RwSignal::new(String::new());
81    let password = RwSignal::new(String::new());
82    let confirm = RwSignal::new(String::new());
83    // 填过的字段离开焦点后才显示校验结果(空字段失焦不报错,免得点按钮时布局跳动);
84    // 提交时全部显示。
85    let touched = RwSignal::new([false; 4]);
86    let error = RwSignal::new(None::<String>);
87    let success = RwSignal::new(false);
88    let loading = RwSignal::new(false);
89
90    let errors = Memo::new(move |_| {
91        [
92            username.with(|v| username_error(v)),
93            email.with(|v| email_error(v)),
94            password.with(|v| password_error(v)),
95            password.with(|p| confirm.with(|c| confirm_error(p, c))),
96        ]
97    });
98    let shown = move |i: usize| {
99        Signal::derive(move || {
100            if touched.with(|t| t[i]) {
101                errors.with(|e| e[i].clone())
102            } else {
103                None
104            }
105        })
106    };
107    let touch = move |i: usize| touched.update(|t| t[i] = true);
108
109    let on_submit = move |event: leptos::ev::SubmitEvent| {
110        event.prevent_default();
111        if loading.get_untracked() {
112            return;
113        }
114        touched.set([true; 4]);
115        error.set(None);
116        if errors.with_untracked(|e| e.iter().any(Option::is_some)) {
117            return;
118        }
119        loading.set(true);
120        let (u, e, p) = (
121            username.get_untracked(),
122            email.get_untracked(),
123            password.get_untracked(),
124        );
125        spawn_local(async move {
126            match register(u, e, p).await {
127                Ok(AuthResponse { success: true, .. }) => {
128                    let _ = success.try_set(true);
129                }
130                Ok(AuthResponse { message, .. }) => {
131                    let _ = error.try_set(Some(message));
132                }
133                Err(e) => {
134                    let _ = error.try_set(Some(failure_message("注册失败", &e)));
135                }
136            }
137            let _ = loading.try_set(false);
138        });
139    };
140
141    view! {
142        <Title text="注册 · Yggdrasil"/>
143        <AuthShell
144            title="注册"
145            footer=|| {
146                view! {
147                    "已有账号?"
148                    <a href=Route::Login {}.to_string()>"去登录"</a>
149                }
150            }
151        >
152            <p class="auth-note">
153                <span class="auth-note-dot" aria-hidden="true"></span>
154                "首个注册的账号会自动成为管理员。"
155            </p>
156            {move || {
157                if success.get() {
158                    view! {
159                        <div class="alert auth-alert" role="status" data-tone="success">
160                            <span>
161                                "注册成功,"
162                                <a href=Route::Login {}.to_string()>"现在去登录"</a>
163                                "。"
164                            </span>
165                        </div>
166                    }
167                        .into_any()
168                } else {
169                    view! {
170                        <form class="auth-form" on:submit=on_submit novalidate>
171                            <AuthField
172                                id="register-username"
173                                label="用户名"
174                                hint="3–50 个字符"
175                                autocomplete="username"
176                                value=username
177                                error=shown(0)
178                                disabled=loading
179                                oninput=move |v| username.set(v)
180                                onblur=move |_| {
181                                    if !username.with_untracked(String::is_empty) {
182                                        touch(0)
183                                    }
184                                }
185                            />
186                            <AuthField
187                                id="register-email"
188                                label="邮箱"
189                                r#type="email"
190                                autocomplete="email"
191                                value=email
192                                error=shown(1)
193                                disabled=loading
194                                oninput=move |v| email.set(v)
195                                onblur=move |_| {
196                                    if !email.with_untracked(String::is_empty) {
197                                        touch(1)
198                                    }
199                                }
200                            />
201                            <PasswordField
202                                id="register-password"
203                                label="密码"
204                                hint="至少 8 位"
205                                autocomplete="new-password"
206                                value=password
207                                error=shown(2)
208                                disabled=loading
209                                oninput=move |v| password.set(v)
210                                onblur=move |_| {
211                                    if !password.with_untracked(String::is_empty) {
212                                        touch(2)
213                                    }
214                                }
215                            />
216                            <PasswordField
217                                id="register-confirm"
218                                label="确认密码"
219                                autocomplete="new-password"
220                                value=confirm
221                                error=shown(3)
222                                disabled=loading
223                                oninput=move |v| confirm.set(v)
224                                onblur=move |_| {
225                                    if !confirm.with_untracked(String::is_empty) {
226                                        touch(3)
227                                    }
228                                }
229                            />
230                            {move || error.get().map(|message| view! { <AuthAlert message=message/> })}
231                            <Button
232                                r#type="submit"
233                                variant=ButtonVariant::Primary
234                                size=ButtonSize::Lg
235                                loading=loading
236                                class="auth-submit"
237                            >
238                                "创建账号"
239                            </Button>
240                        </form>
241                    }
242                        .into_any()
243                }
244            }}
245        </AuthShell>
246    }
247}
248
249#[cfg(test)]
250mod tests {
251    use super::*;
252
253    #[test]
254    fn validation_matches_server_rules() {
255        assert!(username_error("ab").is_some());
256        assert!(username_error("xfy_01").is_none());
257        assert!(username_error("bad name").is_some());
258        assert!(email_error("a@b.co").is_none());
259        assert!(email_error("a@b").is_some());
260        assert!(email_error("a b@c.com").is_some());
261        assert!(password_error("1234567").is_some());
262        assert!(password_error("12345678").is_none());
263        assert!(confirm_error("a", "b").is_some());
264        assert!(confirm_error("a", "a").is_none());
265    }
266}