Skip to main content

yggdrasil/pages/
login.rs

1//! 登录页面,以及登录 / 注册共用的外壳与输入框。
2//!
3//! 页面居中一张 popover 级面板:上方文字标,下方「返回首页」。
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::{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/// 登录与注册共用的外壳:文字标、居中面板、返回首页。
19#[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/// 带标签、提示与错误的输入框。`error` 为 `Some` 时标红并朗读说明。
49#[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/// 密码框:右侧「显示密码」切换。
100#[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/// 表单级错误(服务端拒绝、网络失败)。
163#[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
173/// 登录页面路由(无 SSR 数据)。
174pub 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/// 登录页面组件
188#[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    // 全局用户上下文,用于触发登录后的状态刷新
195    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                    // 登录成功:重置上下文检查标记并跳转到后台
213                    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}