yggdrasil/pages/
register.rs1use 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
17fn 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
28fn 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
62pub 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#[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 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}