diff --git a/src/pages/admin.rs b/src/pages/admin.rs new file mode 100644 index 0000000..94983d9 --- /dev/null +++ b/src/pages/admin.rs @@ -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::() + .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", "加载中..." } + } + } + } + } +} diff --git a/src/pages/login.rs b/src/pages/login.rs new file mode 100644 index 0000000..e5563b5 --- /dev/null +++ b/src/pages/login.rs @@ -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::); + + 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::() + .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, + "登录" + } + } + } + } + } +} diff --git a/src/pages/mod.rs b/src/pages/mod.rs new file mode 100644 index 0000000..ed76a34 --- /dev/null +++ b/src/pages/mod.rs @@ -0,0 +1,3 @@ +pub mod admin; +pub mod login; +pub mod register; diff --git a/src/pages/register.rs b/src/pages/register.rs new file mode 100644 index 0000000..3afc8f6 --- /dev/null +++ b/src/pages/register.rs @@ -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::); + 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", + "去登录" + } + } + } + } + } +} diff --git a/src/theme.rs b/src/theme.rs new file mode 100644 index 0000000..e7a1033 --- /dev/null +++ b/src/theme.rs @@ -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 { + 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 { + "☀️" + } + } + } +}