1use leptos::prelude::*;
7use leptos::task::spawn_local;
8use leptos_meta::Title;
9use leptos_router::{lazy_route, LazyRoute};
10
11use crate::api::auth::{login, AuthResponse};
12use crate::components::mascot::Mascot;
13use crate::components::ui::{Button, ButtonSize, ButtonVariant, Icon, IconName};
14use crate::context::UserContext;
15use crate::routes::Route;
16use crate::utils::error::failure_message;
17
18#[component]
20pub(crate) fn AuthShell(
21 title: &'static str,
22 #[prop(optional)] subtitle: Option<&'static str>,
23 children: Children,
24 #[prop(into)] footer: ViewFn,
25) -> impl IntoView {
26 view! {
27 <div class="auth">
28 <a class="auth-wordmark" href=Route::Home {}.to_string()>
29 <span class="wordmark-mark" aria-hidden="true"></span>
30 "Yggdrasil"
31 </a>
32 <main class="auth-panel page-enter" aria-labelledby="auth-title">
33 <h1 id="auth-title" class="auth-title">
34 {title}
35 </h1>
36 {subtitle.map(|subtitle| view! { <p class="auth-subtitle">{subtitle}</p> })}
37 {children()}
38 <p class="auth-switch">{footer.run()}</p>
39 </main>
40 <a class="auth-home" href=Route::Home {}.to_string()>
41 <Icon name=IconName::ArrowLeft size=14/>
42 "返回首页"
43 </a>
44 </div>
45 }
46}
47
48#[component]
50pub(crate) fn AuthField(
51 id: &'static str,
52 label: &'static str,
53 #[prop(default = "text")] r#type: &'static str,
54 #[prop(default = "")] autocomplete: &'static str,
55 #[prop(default = "")] hint: &'static str,
56 #[prop(optional, into)] error: Signal<Option<String>>,
57 #[prop(optional, into)] disabled: Signal<bool>,
58 #[prop(into)] value: Signal<String>,
59 #[prop(into)] oninput: Callback<String>,
60 #[prop(optional, into)] onblur: Option<Callback<()>>,
61) -> impl IntoView {
62 let error_id = format!("{id}-error");
63 let invalid = move || error.with(Option::is_some);
64 view! {
65 <div class="auth-field">
66 <label class="auth-label" for=id>
67 <span>{label}</span>
68 {(!hint.is_empty()).then(|| view! { <span class="auth-hint">{hint}</span> })}
69 </label>
70 <div class="field auth-input">
71 <input
72 id=id
73 type=r#type
74 autocomplete=autocomplete
75 prop:value=move || value.get()
76 disabled=move || disabled.get()
77 aria-invalid=move || invalid().then_some("true")
78 aria-describedby={
79 let error_id = error_id.clone();
80 move || invalid().then(|| error_id.clone())
81 }
82 on:input=move |e| oninput.run(event_target_value(&e))
83 on:blur=move |_| {
84 if let Some(handler) = onblur {
85 handler.run(());
86 }
87 }
88 />
89 </div>
90 {move || {
91 error
92 .get()
93 .map(|message| view! { <p id=error_id.clone() class="auth-error">{message}</p> })
94 }}
95 </div>
96 }
97}
98
99#[component]
101pub(crate) fn PasswordField(
102 id: &'static str,
103 label: &'static str,
104 autocomplete: &'static str,
105 #[prop(default = "")] hint: &'static str,
106 #[prop(optional, into)] error: Signal<Option<String>>,
107 #[prop(optional, into)] disabled: Signal<bool>,
108 #[prop(into)] value: Signal<String>,
109 #[prop(into)] oninput: Callback<String>,
110 #[prop(optional, into)] onblur: Option<Callback<()>>,
111) -> impl IntoView {
112 let visible = RwSignal::new(false);
113 let error_id = format!("{id}-error");
114 let invalid = move || error.with(Option::is_some);
115 view! {
116 <div class="auth-field">
117 <label class="auth-label" for=id>
118 <span>{label}</span>
119 {(!hint.is_empty()).then(|| view! { <span class="auth-hint">{hint}</span> })}
120 </label>
121 <div class="field auth-input has-action">
122 <input
123 id=id
124 type=move || if visible.get() { "text" } else { "password" }
125 autocomplete=autocomplete
126 prop:value=move || value.get()
127 disabled=move || disabled.get()
128 aria-invalid=move || invalid().then_some("true")
129 aria-describedby={
130 let error_id = error_id.clone();
131 move || invalid().then(|| error_id.clone())
132 }
133 on:input=move |e| oninput.run(event_target_value(&e))
134 on:blur=move |_| {
135 if let Some(handler) = onblur {
136 handler.run(());
137 }
138 }
139 />
140 <button
141 type="button"
142 class="auth-eye"
143 aria-label=move || if visible.get() { "隐藏密码" } else { "显示密码" }
144 aria-pressed=move || visible.get().to_string()
145 on:click=move |_| visible.update(|v| *v = !*v)
146 >
147 {move || {
148 let name = if visible.get() { IconName::EyeOff } else { IconName::Eye };
149 view! { <Icon name=name size=16/> }
150 }}
151 </button>
152 </div>
153 {move || {
154 error
155 .get()
156 .map(|message| view! { <p id=error_id.clone() class="auth-error">{message}</p> })
157 }}
158 </div>
159 }
160}
161
162#[component]
164pub(crate) fn AuthAlert(#[prop(into)] message: String) -> impl IntoView {
165 view! {
166 <p class="alert auth-alert" role="alert" data-tone="danger">
167 <Icon name=IconName::CircleAlert size=15/>
168 <span>{message}</span>
169 </p>
170 }
171}
172
173pub struct LoginRoute;
175
176#[lazy_route]
177impl LazyRoute for LoginRoute {
178 fn data() -> Self {
179 Self
180 }
181
182 fn view(_this: Self) -> AnyView {
183 view! { <Login/> }.into_any()
184 }
185}
186
187#[component]
189fn Login() -> impl IntoView {
190 let username = RwSignal::new(String::new());
191 let password = RwSignal::new(String::new());
192 let error = RwSignal::new(None::<String>);
193 let loading = RwSignal::new(false);
194 let ctx = expect_context::<UserContext>();
196
197 let on_submit = move |event: leptos::ev::SubmitEvent| {
198 event.prevent_default();
199 if loading.get_untracked() {
200 return;
201 }
202 let (username_val, password_val) = (username.get_untracked(), password.get_untracked());
203 if username_val.trim().is_empty() || password_val.is_empty() {
204 error.set(Some("请填写用户名和密码。".to_string()));
205 return;
206 }
207 error.set(None);
208 loading.set(true);
209 spawn_local(async move {
210 match login(username_val, password_val).await {
211 Ok(AuthResponse { success: true, .. }) => {
212 let _ = ctx.checked.try_set(false);
214 crate::navigation::push("/admin");
215 }
216 Ok(AuthResponse { message, .. }) => {
217 let _ = error.try_set(Some(message));
218 }
219 Err(e) => {
220 let _ = error.try_set(Some(failure_message("登录失败", &e)));
221 }
222 }
223 let _ = loading.try_set(false);
224 });
225 };
226
227 view! {
228 <Title text="登录 · Yggdrasil"/>
229 <AuthShell
230 title="登录"
231 subtitle="进入后台,管理文章、笔记与站点。"
232 footer=|| {
233 view! {
234 "还没有账号?"
235 <a href=Route::Register {}.to_string()>"去注册"</a>
236 }
237 }
238 >
239 <span class="auth-mascot" class:is-upset=move || error.with(Option::is_some) aria-hidden="true">
240 {Mascot::Curious.img("mascot auth-mascot-img")}
241 {Mascot::Pout.img("mascot auth-mascot-img auth-mascot-upset")}
242 </span>
243 <form class="auth-form" on:submit=on_submit>
244 <AuthField
245 id="login-username"
246 label="用户名或邮箱"
247 autocomplete="username"
248 value=username
249 disabled=loading
250 oninput=move |v| username.set(v)
251 />
252 <PasswordField
253 id="login-password"
254 label="密码"
255 autocomplete="current-password"
256 value=password
257 disabled=loading
258 oninput=move |v| password.set(v)
259 />
260 {move || error.get().map(|message| view! { <AuthAlert message=message/> })}
261 <Button
262 r#type="submit"
263 variant=ButtonVariant::Primary
264 size=ButtonSize::Lg
265 loading=loading
266 class="auth-submit"
267 >
268 "登录"
269 </Button>
270 </form>
271 </AuthShell>
272 }
273}