US-004: 前端页面 - 注册与登录
- src/pages/register.rs: 注册表单 + 前端验证 + 错误提示 - src/pages/login.rs: 登录表单 + cookie 设置 + 跳转 - src/pages/admin.rs: 认证检查 + 欢迎信息 + 登出 - src/theme.rs: 暗色/亮色主题切换 + localStorage 持久化 - Tailwind CSS CDN + dark: modifier 实现主题 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
b2a1e13c79
commit
4a77f2c457
78
src/pages/admin.rs
Normal file
78
src/pages/admin.rs
Normal file
@ -0,0 +1,78 @@
|
||||
use dioxus::prelude::*;
|
||||
|
||||
use crate::api::auth::{get_current_user, logout};
|
||||
|
||||
#[component]
|
||||
pub fn AdminPage() -> Element {
|
||||
let user_resource = use_resource(|| async move {
|
||||
get_current_user().await.ok().and_then(|r| r.user)
|
||||
});
|
||||
|
||||
let navigator = dioxus::router::navigator();
|
||||
|
||||
match user_resource.read().as_ref() {
|
||||
Some(Some(user)) => {
|
||||
let username = user.username.clone();
|
||||
rsx! {
|
||||
div { class: "min-h-screen bg-gray-50 dark:bg-gray-900",
|
||||
header { class: "bg-white dark:bg-gray-800 shadow",
|
||||
div { class: "max-w-7xl mx-auto px-4 py-4 flex justify-between items-center",
|
||||
h1 { class: "text-xl font-bold text-gray-900 dark:text-white",
|
||||
"后台管理"
|
||||
}
|
||||
div { class: "flex items-center gap-4",
|
||||
span { class: "text-gray-600 dark:text-gray-300",
|
||||
"欢迎, {username}"
|
||||
}
|
||||
button {
|
||||
class: "px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg transition-colors",
|
||||
onclick: move |_| {
|
||||
spawn(async move {
|
||||
let _ = logout().await;
|
||||
// 清除 cookie
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
let cookie = "session=; path=/; max-age=0";
|
||||
if let Some(window) = web_sys::window() {
|
||||
if let Some(document) = window.document() {
|
||||
let _ = document.dyn_into::<web_sys::HtmlDocument>()
|
||||
.map(|d| d.set_cookie(cookie));
|
||||
}
|
||||
}
|
||||
}
|
||||
let _ = navigator.push("/login");
|
||||
});
|
||||
},
|
||||
"登出"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
main { class: "max-w-7xl mx-auto px-4 py-8",
|
||||
p { class: "text-gray-600 dark:text-gray-300",
|
||||
"后台管理界面开发中..."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(None) => {
|
||||
// 未登录,重定向到登录页
|
||||
use_effect(move || {
|
||||
navigator.push("/login");
|
||||
});
|
||||
rsx! {
|
||||
div { class: "min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900",
|
||||
p { class: "text-gray-600 dark:text-gray-300", "正在跳转..." }
|
||||
}
|
||||
}
|
||||
}
|
||||
None => {
|
||||
rsx! {
|
||||
div { class: "min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900",
|
||||
p { class: "text-gray-600 dark:text-gray-300", "加载中..." }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
98
src/pages/login.rs
Normal file
98
src/pages/login.rs
Normal file
@ -0,0 +1,98 @@
|
||||
use dioxus::prelude::*;
|
||||
|
||||
use crate::api::auth::{login, AuthResponse};
|
||||
|
||||
#[component]
|
||||
pub fn LoginPage() -> Element {
|
||||
let mut username = use_signal(|| "".to_string());
|
||||
let mut password = use_signal(|| "".to_string());
|
||||
let mut error = use_signal(|| None::<String>);
|
||||
|
||||
let on_submit = move |_| {
|
||||
error.set(None);
|
||||
|
||||
let username_val = username();
|
||||
let password_val = password();
|
||||
|
||||
spawn(async move {
|
||||
match login(username_val, password_val).await {
|
||||
Ok(AuthResponse { success: true, token: Some(token), .. }) => {
|
||||
// 设置 cookie (client-side, not HttpOnly but works for now)
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
let cookie = format!(
|
||||
"session={}; path=/; max-age={}; SameSite=Lax",
|
||||
token,
|
||||
30 * 24 * 60 * 60 // 30 days
|
||||
);
|
||||
if let Some(window) = web_sys::window() {
|
||||
if let Some(document) = window.document() {
|
||||
let _ = document.dyn_into::<web_sys::HtmlDocument>()
|
||||
.map(|d| d.set_cookie(&cookie));
|
||||
}
|
||||
}
|
||||
}
|
||||
// 跳转到 admin 页面
|
||||
let _ = dioxus::router::navigator().push("/admin");
|
||||
}
|
||||
Ok(AuthResponse { success: false, message, .. }) => {
|
||||
error.set(Some(message));
|
||||
}
|
||||
Ok(AuthResponse { success: true, token: None, .. }) => {
|
||||
error.set(Some("登录异常".to_string()));
|
||||
}
|
||||
Err(e) => {
|
||||
error.set(Some(format!("请求失败: {}", e)));
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
rsx! {
|
||||
div { class: "min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900",
|
||||
div { class: "w-full max-w-md p-8 bg-white dark:bg-gray-800 rounded-2xl shadow-lg",
|
||||
h1 { class: "text-2xl font-bold text-center text-gray-900 dark:text-white mb-6",
|
||||
"登录"
|
||||
}
|
||||
|
||||
if let Some(err) = error() {
|
||||
div { class: "mb-4 p-3 bg-red-100 dark:bg-red-900 text-red-700 dark:text-red-300 rounded-lg text-center",
|
||||
"{err}"
|
||||
}
|
||||
}
|
||||
|
||||
div { class: "space-y-4",
|
||||
div {
|
||||
label { class: "block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1",
|
||||
"用户名"
|
||||
}
|
||||
input {
|
||||
class: "w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500",
|
||||
r#type: "text",
|
||||
placeholder: "用户名",
|
||||
value: username(),
|
||||
oninput: move |e| username.set(e.value()),
|
||||
}
|
||||
}
|
||||
div {
|
||||
label { class: "block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1",
|
||||
"密码"
|
||||
}
|
||||
input {
|
||||
class: "w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500",
|
||||
r#type: "password",
|
||||
placeholder: "密码",
|
||||
value: password(),
|
||||
oninput: move |e| password.set(e.value()),
|
||||
}
|
||||
}
|
||||
button {
|
||||
class: "w-full py-2 px-4 bg-blue-600 hover:bg-blue-700 text-white font-medium rounded-lg transition-colors",
|
||||
onclick: on_submit,
|
||||
"登录"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
3
src/pages/mod.rs
Normal file
3
src/pages/mod.rs
Normal file
@ -0,0 +1,3 @@
|
||||
pub mod admin;
|
||||
pub mod login;
|
||||
pub mod register;
|
||||
135
src/pages/register.rs
Normal file
135
src/pages/register.rs
Normal file
@ -0,0 +1,135 @@
|
||||
use dioxus::prelude::*;
|
||||
|
||||
use crate::api::auth::{register, AuthResponse};
|
||||
|
||||
#[component]
|
||||
pub fn RegisterPage() -> Element {
|
||||
let mut username = use_signal(|| "".to_string());
|
||||
let mut email = use_signal(|| "".to_string());
|
||||
let mut password = use_signal(|| "".to_string());
|
||||
let mut confirm_password = use_signal(|| "".to_string());
|
||||
let mut error = use_signal(|| None::<String>);
|
||||
let mut success = use_signal(|| false);
|
||||
|
||||
let on_submit = move |_| {
|
||||
error.set(None);
|
||||
success.set(false);
|
||||
|
||||
if password().len() < 8 {
|
||||
error.set(Some("密码长度至少 8 位".to_string()));
|
||||
return;
|
||||
}
|
||||
if password() != confirm_password() {
|
||||
error.set(Some("两次输入的密码不一致".to_string()));
|
||||
return;
|
||||
}
|
||||
|
||||
let username_val = username();
|
||||
let email_val = email();
|
||||
let password_val = password();
|
||||
|
||||
spawn(async move {
|
||||
match register(username_val, email_val, password_val).await {
|
||||
Ok(AuthResponse { success: true, .. }) => {
|
||||
success.set(true);
|
||||
}
|
||||
Ok(AuthResponse { success: false, message, .. }) => {
|
||||
error.set(Some(message));
|
||||
}
|
||||
Err(e) => {
|
||||
error.set(Some(format!("请求失败: {}", e)));
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
rsx! {
|
||||
div { class: "min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900",
|
||||
div { class: "w-full max-w-md p-8 bg-white dark:bg-gray-800 rounded-2xl shadow-lg",
|
||||
h1 { class: "text-2xl font-bold text-center text-gray-900 dark:text-white mb-2",
|
||||
"注册"
|
||||
}
|
||||
p { class: "text-sm text-center text-gray-500 dark:text-gray-400 mb-6",
|
||||
"首个注册账号将自动成为管理员"
|
||||
}
|
||||
|
||||
if success() {
|
||||
div { class: "mb-4 p-3 bg-green-100 dark:bg-green-900 text-green-700 dark:text-green-300 rounded-lg text-center",
|
||||
"注册成功!"
|
||||
a { class: "block mt-2 text-blue-600 dark:text-blue-400 hover:underline", href: "/login",
|
||||
"去登录"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(err) = error() {
|
||||
div { class: "mb-4 p-3 bg-red-100 dark:bg-red-900 text-red-700 dark:text-red-300 rounded-lg text-center",
|
||||
"{err}"
|
||||
}
|
||||
}
|
||||
|
||||
div { class: "space-y-4",
|
||||
div {
|
||||
label { class: "block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1",
|
||||
"用户名"
|
||||
}
|
||||
input {
|
||||
class: "w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500",
|
||||
r#type: "text",
|
||||
placeholder: "3-50 位字符",
|
||||
value: username(),
|
||||
oninput: move |e| username.set(e.value()),
|
||||
}
|
||||
}
|
||||
div {
|
||||
label { class: "block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1",
|
||||
"邮箱"
|
||||
}
|
||||
input {
|
||||
class: "w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500",
|
||||
r#type: "email",
|
||||
placeholder: "your@email.com",
|
||||
value: email(),
|
||||
oninput: move |e| email.set(e.value()),
|
||||
}
|
||||
}
|
||||
div {
|
||||
label { class: "block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1",
|
||||
"密码"
|
||||
}
|
||||
input {
|
||||
class: "w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500",
|
||||
r#type: "password",
|
||||
placeholder: "至少 8 位",
|
||||
value: password(),
|
||||
oninput: move |e| password.set(e.value()),
|
||||
}
|
||||
}
|
||||
div {
|
||||
label { class: "block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1",
|
||||
"确认密码"
|
||||
}
|
||||
input {
|
||||
class: "w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500",
|
||||
r#type: "password",
|
||||
placeholder: "再次输入密码",
|
||||
value: confirm_password(),
|
||||
oninput: move |e| confirm_password.set(e.value()),
|
||||
}
|
||||
}
|
||||
button {
|
||||
class: "w-full py-2 px-4 bg-blue-600 hover:bg-blue-700 text-white font-medium rounded-lg transition-colors",
|
||||
onclick: on_submit,
|
||||
"注册"
|
||||
}
|
||||
}
|
||||
p { class: "mt-4 text-center text-sm text-gray-500 dark:text-gray-400",
|
||||
"已有账号?"
|
||||
a { class: "text-blue-600 dark:text-blue-400 hover:underline", href: "/login",
|
||||
"去登录"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
85
src/theme.rs
Normal file
85
src/theme.rs
Normal file
@ -0,0 +1,85 @@
|
||||
use dioxus::prelude::*;
|
||||
|
||||
const THEME_KEY: &str = "yggdrasil-theme";
|
||||
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
pub enum Theme {
|
||||
Light,
|
||||
Dark,
|
||||
}
|
||||
|
||||
impl Theme {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Theme::Light => "light",
|
||||
Theme::Dark => "dark",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn toggle(&self) -> Self {
|
||||
match self {
|
||||
Theme::Light => Theme::Dark,
|
||||
Theme::Dark => Theme::Light,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn use_theme() -> Signal<Theme> {
|
||||
let theme = use_signal(|| {
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
let storage = web_sys::window()
|
||||
.and_then(|w| w.local_storage().ok())
|
||||
.flatten();
|
||||
if let Some(storage) = storage {
|
||||
if let Ok(Some(value)) = storage.get_item(THEME_KEY) {
|
||||
if value == "dark" {
|
||||
return Theme::Dark;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Theme::Light
|
||||
});
|
||||
|
||||
use_effect(move || {
|
||||
let current = theme();
|
||||
let theme_str = current.as_str();
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
if let Some(window) = web_sys::window() {
|
||||
if let Some(document) = window.document() {
|
||||
if let Some(html) = document.document_element() {
|
||||
let _ = html.set_attribute("data-theme", theme_str);
|
||||
}
|
||||
}
|
||||
if let Some(storage) = window.local_storage().ok().flatten() {
|
||||
let _ = storage.set_item(THEME_KEY, theme_str);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// For SSR, the theme will be applied client-side after hydration
|
||||
let _ = theme_str;
|
||||
});
|
||||
|
||||
theme
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn ThemeToggle() -> Element {
|
||||
let theme = use_theme();
|
||||
|
||||
rsx! {
|
||||
button {
|
||||
class: "fixed top-4 right-4 z-50 p-2 rounded-full bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors",
|
||||
onclick: move |_| theme.set(theme().toggle()),
|
||||
if theme() == Theme::Dark {
|
||||
"🌙"
|
||||
} else {
|
||||
"☀️"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user