重构 admin 模块,新增共享组件和文章撰写页面

- 将 monolithic admin.rs 拆分为 admin/ 目录模块(dashboard、write)
- 新增 components 模块:Header、Footer、AdminLayout
- 新增 /admin/write 文章撰写页面,支持 Markdown 实时预览
- 添加 pulldown-cmark 依赖用于 Markdown 渲染
- .env 移出版本控制,新增 .env.example 模板
- Home、Archives、Tags、Login、Register 等页面适配新组件

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
xfy 2026-05-26 16:13:18 +08:00
parent 4a73e4d931
commit 8b9c6aa83d
19 changed files with 511 additions and 262 deletions

View File

1
.gitignore vendored
View File

@ -6,3 +6,4 @@
/package-lock.json
others/
public/style.css
.env

35
Cargo.lock generated
View File

@ -1572,6 +1572,15 @@ dependencies = [
"version_check",
]
[[package]]
name = "getopts"
version = "0.2.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df"
dependencies = [
"unicode-width",
]
[[package]]
name = "getrandom"
version = "0.2.17"
@ -2708,6 +2717,25 @@ dependencies = [
"psl-types",
]
[[package]]
name = "pulldown-cmark"
version = "0.13.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e9f068eba8e7071c5f9511831b44f32c740d5adf574e990f946ddb53db2f314e"
dependencies = [
"bitflags",
"getopts",
"memchr",
"pulldown-cmark-escape",
"unicase",
]
[[package]]
name = "pulldown-cmark-escape"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae"
[[package]]
name = "quinn"
version = "0.11.9"
@ -3880,6 +3908,12 @@ version = "1.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c"
[[package]]
name = "unicode-width"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
[[package]]
name = "unicode-xid"
version = "0.2.6"
@ -4595,6 +4629,7 @@ dependencies = [
"dioxus",
"dotenvy",
"getrandom 0.2.17",
"pulldown-cmark",
"rand 0.8.6",
"regex",
"serde",

View File

@ -13,6 +13,7 @@ argon2 = "0.5"
uuid = { version = "1", features = ["v4", "js"] }
chrono = { version = "0.4", features = ["serde"] }
regex = "1.12"
pulldown-cmark = "0.13"
dotenvy = { version = "0.15", optional = true }
rand = { version = "0.8", features = ["getrandom"] }
getrandom = { version = "0.2", features = ["js"] }

View File

@ -0,0 +1,92 @@
use dioxus::prelude::*;
#[cfg(target_arch = "wasm32")]
use wasm_bindgen::JsCast;
use crate::api::auth::{get_current_user, logout};
use crate::components::header::{Header, NavItemConfig};
use crate::components::footer::Footer;
use crate::router::Route;
#[component]
pub fn AdminLayout(children: Element) -> Element {
let user_resource =
use_resource(|| async move { get_current_user().await.ok().and_then(|r| r.user) });
let navigator = dioxus::router::navigator();
let route = use_route::<Route>();
let admin_nav_items = vec![
NavItemConfig {
href: "/admin",
label: "仪表盘",
is_active: matches!(route, Route::AdminPage {}),
},
NavItemConfig {
href: "/admin/write",
label: "写文章",
is_active: matches!(route, Route::WritePage {}),
},
NavItemConfig {
href: "/",
label: "前台",
is_active: false,
},
];
let nav = navigator;
let logout_button = rsx! {
button {
class: "text-sm text-gray-600 dark:text-[#9b9c9d] hover:text-gray-900 dark:hover:text-[#dadadb] transition-colors",
onclick: move |_| {
let nav = nav;
spawn(async move {
let _ = logout().await;
#[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 _ = nav.push("/login");
});
},
"登出"
}
};
let user_data = user_resource.read().clone();
let should_redirect = matches!(user_data.as_ref(), Some(None));
use_effect(move || {
if should_redirect {
navigator.push("/login");
}
});
match user_data.as_ref() {
Some(Some(_user)) => {
rsx! {
div { class: "min-h-screen flex flex-col bg-white dark:bg-[#1d1e20]",
Header { nav_items: admin_nav_items, right_content: logout_button }
main { class: "flex-1 w-full max-w-5xl mx-auto px-6 py-8",
{children}
}
Footer {}
}
}
}
_ => {
rsx! {
div { class: "min-h-screen flex items-center justify-center bg-white dark:bg-[#1d1e20]",
p { class: "text-gray-600 dark:text-[#9b9c9d]", "加载中..." }
}
}
}
}
}

88
src/components/footer.rs Normal file
View File

@ -0,0 +1,88 @@
use dioxus::prelude::*;
#[component]
pub fn Footer() -> Element {
let mut visible = use_signal(|| false);
use_effect(move || {
#[cfg(target_arch = "wasm32")]
{
if let Some(window) = web_sys::window() {
let closure = wasm_bindgen::prelude::Closure::wrap(Box::new(move || {
if let Some(w) = web_sys::window() {
let threshold = w.inner_height().ok()
.and_then(|h| h.as_f64())
.unwrap_or(0.0);
let scroll_y = w.scroll_y().unwrap_or(0.0);
let new_visible = scroll_y > threshold;
visible.set(new_visible);
}
}) as Box<dyn FnMut()>);
let _ = window.add_event_listener_with_callback("scroll", wasm_bindgen::JsCast::unchecked_ref(closure.as_ref()));
let threshold = window.inner_height().ok()
.and_then(|h| h.as_f64())
.unwrap_or(0.0);
let scroll_y = window.scroll_y().unwrap_or(0.0);
visible.set(scroll_y > threshold);
closure.forget();
}
}
});
let link_class = use_memo(move || {
let base = "p-2 rounded-full cursor-pointer hover:opacity-80 transition-all duration-300 text-gray-600 dark:text-gray-300";
if visible() {
format!("{} opacity-100 translate-y-0", base)
} else {
format!("{} opacity-0 translate-y-2 pointer-events-none", base)
}
});
rsx! {
footer { class: "w-full border-t border-gray-200 dark:border-[#333] mt-auto",
div { class: "max-w-3xl mx-auto px-6 py-5 flex items-center justify-between text-sm text-gray-400 dark:text-[#9b9c9d]",
span { "© 2026 Yggdrasil Blog" }
a {
class: "{link_class}",
href: "#top",
aria_label: "go to top",
title: "Go to Top (Alt + G)",
accesskey: "g",
onclick: move |evt| {
evt.prevent_default();
scroll_to_top();
},
svg {
xmlns: "http://www.w3.org/2000/svg",
height: "24px",
view_box: "0 -960 960 960",
width: "24px",
fill: "currentColor",
path {
d: "m296-224-56-56 240-240 240 240-56 56-184-183-184 183Zm0-240-56-56 240-240 240 240-56 56-184-183-184 183Z",
}
}
}
}
}
}
}
fn scroll_to_top() {
#[cfg(target_arch = "wasm32")]
{
if let Some(window) = web_sys::window() {
let mut options = web_sys::ScrollToOptions::new();
options.top(0.0);
options.behavior(web_sys::ScrollBehavior::Smooth);
let _ = window.scroll_to_with_scroll_to_options(&options);
if let Ok(history) = window.history() {
let _ = history.replace_state_with_url(&wasm_bindgen::JsValue::NULL, "", Some(" "));
}
}
}
}

51
src/components/header.rs Normal file
View File

@ -0,0 +1,51 @@
use dioxus::prelude::*;
#[derive(Clone, PartialEq)]
pub struct NavItemConfig {
pub href: &'static str,
pub label: &'static str,
pub is_active: bool,
}
#[component]
pub fn Header(nav_items: Vec<NavItemConfig>, right_content: Element) -> Element {
rsx! {
header { class: "sticky top-0 z-40 w-full border-b border-gray-200 dark:border-[#333] bg-white/80 dark:bg-[#1d1e20]/80 backdrop-blur-sm",
nav { class: "max-w-3xl mx-auto px-6 h-[60px] flex items-center justify-between",
a {
class: "text-2xl font-bold text-gray-900 dark:text-[#dadadb] hover:opacity-80 transition-opacity",
href: "/",
"Yggdrasil"
}
div { class: "flex items-center gap-2",
ul { class: "hidden md:flex items-center gap-1",
for item in nav_items.iter().cloned() {
NavItem {
href: item.href,
label: item.label,
is_active: item.is_active,
}
}
}
{right_content}
}
}
}
}
}
#[component]
fn NavItem(href: &'static str, label: &'static str, is_active: bool) -> Element {
let base_class = "px-3 py-1 text-base rounded-lg transition-colors";
let class_str = if is_active {
format!("{} font-medium text-gray-900 dark:text-[#dadadb] underline underline-offset-[0.3rem] decoration-2 decoration-gray-900 dark:decoration-[#dadadb]", base_class)
} else {
format!("{} text-gray-600 dark:text-[#9b9c9d] hover:text-gray-900 dark:hover:text-[#dadadb]", base_class)
};
rsx! {
li {
a { class: "{class_str}", href: "{href}", "{label}" }
}
}
}

3
src/components/mod.rs Normal file
View File

@ -0,0 +1,3 @@
pub mod header;
pub mod footer;
pub mod admin_layout;

View File

@ -1,5 +1,6 @@
mod api;
mod auth;
mod components;
mod db;
mod models;
mod pages;

View File

@ -1,81 +0,0 @@
use dioxus::prelude::*;
#[cfg(target_arch = "wasm32")]
use wasm_bindgen::JsCast;
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();
let user_data = user_resource.read().clone();
match user_data.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 |_| {
let nav = navigator;
spawn(async move {
let _ = logout().await;
#[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 _ = nav.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", "加载中..." }
}
}
}
}
}

View File

@ -0,0 +1,77 @@
use dioxus::prelude::*;
use crate::components::admin_layout::AdminLayout;
use crate::pages::home::{Post, POSTS};
#[component]
pub fn AdminPage() -> Element {
rsx! {
AdminLayout {
div { class: "space-y-8",
// 统计卡片
div { class: "grid grid-cols-1 md:grid-cols-3 gap-6",
StatCard { value: POSTS.len().to_string(), label: "文章总数" }
StatCard { value: "0".to_string(), label: "草稿数" }
StatCard { value: POSTS.len().to_string(), label: "已发布" }
}
// 快捷操作
div { class: "grid grid-cols-1 md:grid-cols-2 gap-4",
a {
class: "bg-gray-900 dark:bg-[#dadadb] text-white dark:text-gray-900 rounded-full px-6 py-3 text-center font-medium hover:opacity-80 transition-opacity",
href: "/admin/write",
"写文章"
}
button {
class: "bg-gray-200 dark:bg-[#333] text-gray-700 dark:text-[#dadadb] rounded-full px-6 py-3 text-center font-medium hover:opacity-80 transition-opacity",
onclick: move |_| {
#[cfg(target_arch = "wasm32")]
web_sys::window().map(|w| w.alert_with_message("开发中").ok());
},
"管理文章"
}
}
// 最近文章
div { class: "mb-8",
h2 { class: "text-xl font-bold text-gray-900 dark:text-[#dadadb] mb-4",
"最近文章"
}
div { class: "space-y-0",
for post in POSTS.iter().take(5) {
RecentPostItem { post: post.clone() }
}
}
}
}
}
}
}
#[component]
fn StatCard(value: String, label: String) -> Element {
rsx! {
div { class: "rounded-xl bg-white dark:bg-[#2e2e33] border border-gray-200 dark:border-[#333] p-6 text-center",
div { class: "text-3xl font-bold text-gray-900 dark:text-[#dadadb]",
"{value}"
}
div { class: "text-sm text-gray-500 dark:text-[#9b9c9d] mt-2",
"{label}"
}
}
}
}
#[component]
fn RecentPostItem(post: Post) -> Element {
rsx! {
div { class: "flex justify-between items-center py-3 border-b border-gray-100 dark:border-[#333]",
span { class: "text-gray-700 dark:text-[#dadadb]",
"{post.title}"
}
span { class: "text-sm text-gray-400 dark:text-[#9b9c9d]",
"{post.date}"
}
}
}
}

5
src/pages/admin/mod.rs Normal file
View File

@ -0,0 +1,5 @@
pub mod dashboard;
pub mod write;
pub use dashboard::AdminPage;
pub use write::WritePage;

71
src/pages/admin/write.rs Normal file
View File

@ -0,0 +1,71 @@
use dioxus::prelude::*;
use crate::components::admin_layout::AdminLayout;
fn markdown_to_html(input: &str) -> String {
let parser = pulldown_cmark::Parser::new(input);
let mut html = String::new();
pulldown_cmark::html::push_html(&mut html, parser);
html
}
#[component]
pub fn WritePage() -> Element {
let mut title = use_signal(|| "".to_string());
let mut content = use_signal(|| "".to_string());
let preview_html = use_memo(move || {
markdown_to_html(&content())
});
rsx! {
AdminLayout {
div { class: "space-y-4",
// 标题输入
input {
class: "w-full text-2xl font-bold bg-transparent border-b border-gray-200 dark:border-[#333] py-2 mb-4 text-gray-900 dark:text-[#dadadb] placeholder-gray-400 dark:placeholder-[#9b9c9d] focus:outline-none",
placeholder: "文章标题...",
value: "{title}",
oninput: move |evt| title.set(evt.value()),
}
// 两栏布局
div { class: "grid grid-cols-1 md:grid-cols-2 gap-6",
// 编辑区
div { class: "space-y-2",
label { class: "text-sm text-gray-500 dark:text-[#9b9c9d]",
"Markdown"
}
textarea {
class: "w-full h-[500px] bg-gray-50 dark:bg-[#2e2e33] rounded-lg p-4 font-mono text-sm text-gray-800 dark:text-[#dadadb] placeholder-gray-400 dark:placeholder-[#9b9c9d] border border-gray-200 dark:border-[#333] focus:outline-none focus:border-gray-400 dark:focus:border-gray-600 resize-none",
placeholder: "在此输入 Markdown...",
value: "{content}",
oninput: move |evt| content.set(evt.value()),
}
}
// 预览区
div { class: "space-y-2",
label { class: "text-sm text-gray-500 dark:text-[#9b9c9d]",
"预览"
}
div {
class: "w-full h-[500px] overflow-y-auto bg-white dark:bg-[#2e2e33] rounded-lg p-4 border border-gray-200 dark:border-[#333] prose dark:prose-invert max-w-none",
dangerous_inner_html: "{preview_html}",
}
}
}
// 保存按钮
button {
class: "mt-4 px-6 py-2 bg-gray-900 dark:bg-[#dadadb] text-white dark:text-gray-900 rounded-full font-medium hover:opacity-80 transition-opacity",
onclick: move |_| {
let t = title();
let c = content();
println!("保存文章: title={}, content_len={}", t, c.len());
},
"保存草稿"
}
}
}
}
}

View File

@ -1,6 +1,9 @@
use dioxus::prelude::*;
use crate::pages::home::{Footer, Header};
use crate::components::header::{Header, NavItemConfig};
use crate::components::footer::Footer;
use crate::router::Route;
use crate::theme::ThemeToggle;
#[derive(Clone, PartialEq)]
pub struct Post {
@ -112,11 +115,20 @@ fn group_posts(posts: &[Post]) -> Vec<YearGroup> {
#[component]
pub fn ArchivesPage() -> Element {
let route = use_route::<Route>();
let nav_items = vec![
NavItemConfig { href: "/", label: "首页", is_active: matches!(route, Route::HomePage {}) },
NavItemConfig { href: "/archives", label: "归档", is_active: matches!(route, Route::ArchivesPage {}) },
NavItemConfig { href: "/tags", label: "标签", is_active: matches!(route, Route::TagsPage {}) || matches!(route, Route::TagDetailPage { .. }) },
NavItemConfig { href: "/search", label: "搜索", is_active: matches!(route, Route::SearchPage {}) },
NavItemConfig { href: "/about", label: "关于", is_active: matches!(route, Route::AboutPage {}) },
];
let grouped = group_posts(POSTS);
rsx! {
div { class: "min-h-screen flex flex-col bg-white dark:bg-[#1d1e20] transition-colors duration-300",
Header {}
Header { nav_items, right_content: rsx! { ThemeToggle {} } }
main { class: "flex-1 w-full max-w-3xl mx-auto px-6 py-6",
header { class: "page-header mb-6",
h1 { class: "text-[34px] font-bold text-gray-900 dark:text-[#dadadb]",

View File

@ -1,5 +1,7 @@
use dioxus::prelude::*;
use crate::components::header::{Header, NavItemConfig};
use crate::components::footer::Footer;
use crate::router::Route;
use crate::theme::ThemeToggle;
@ -59,9 +61,18 @@ pub const POSTS: &[Post] = &[
#[component]
pub fn HomePage() -> Element {
let route = use_route::<Route>();
let nav_items = vec![
NavItemConfig { href: "/", label: "首页", is_active: matches!(route, Route::HomePage {}) },
NavItemConfig { href: "/archives", label: "归档", is_active: matches!(route, Route::ArchivesPage {}) },
NavItemConfig { href: "/tags", label: "标签", is_active: matches!(route, Route::TagsPage {}) || matches!(route, Route::TagDetailPage { .. }) },
NavItemConfig { href: "/search", label: "搜索", is_active: matches!(route, Route::SearchPage {}) },
NavItemConfig { href: "/about", label: "关于", is_active: matches!(route, Route::AboutPage {}) },
];
rsx! {
div { class: "min-h-screen flex flex-col bg-white dark:bg-[#1d1e20] transition-colors duration-300",
Header {}
Header { nav_items, right_content: rsx! { ThemeToggle {} } }
main { class: "flex-1 w-full max-w-3xl mx-auto px-6 py-6",
HomeInfo {}
for post in POSTS.iter() {
@ -74,59 +85,6 @@ pub fn HomePage() -> Element {
}
}
#[component]
pub fn Header() -> Element {
let route = use_route::<Route>();
rsx! {
header { class: "sticky top-0 z-40 w-full border-b border-gray-200 dark:border-[#333] bg-white/80 dark:bg-[#1d1e20]/80 backdrop-blur-sm",
nav { class: "max-w-3xl mx-auto px-6 h-[60px] flex items-center justify-between",
a {
class: "text-2xl font-bold text-gray-900 dark:text-[#dadadb] hover:opacity-80 transition-opacity",
href: "/",
"Yggdrasil"
}
div { class: "flex items-center gap-2",
ul { class: "hidden md:flex items-center gap-1",
NavItem { href: "/", label: "首页", route: route.clone() }
NavItem { href: "/archives", label: "归档", route: route.clone() }
NavItem { href: "/tags", label: "标签", route: route.clone() }
NavItem { href: "/search", label: "搜索", route: route.clone() }
NavItem { href: "/about", label: "关于", route: route.clone() }
}
ThemeToggle {}
}
}
}
}
}
#[component]
pub fn NavItem(href: &'static str, label: &'static str, route: Route) -> Element {
let is_active = match (href, route) {
("/", Route::HomePage {}) => true,
("/archives", Route::ArchivesPage {}) => true,
("/tags", Route::TagsPage {}) => true,
("/tags", Route::TagDetailPage { .. }) => true,
("/search", Route::SearchPage {}) => true,
("/about", Route::AboutPage {}) => true,
_ => false,
};
let base_class = "px-3 py-1 text-base rounded-lg transition-colors";
let class_str = if is_active {
format!("{} font-medium text-gray-900 dark:text-[#dadadb] underline underline-offset-[0.3rem] decoration-2 decoration-gray-900 dark:decoration-[#dadadb]", base_class)
} else {
format!("{} text-gray-600 dark:text-[#9b9c9d] hover:text-gray-900 dark:hover:text-[#dadadb]", base_class)
};
rsx! {
li {
a { class: "{class_str}", href: "{href}", "{label}" }
}
}
}
#[component]
fn HomeInfo() -> Element {
rsx! {
@ -143,7 +101,7 @@ fn HomeInfo() -> Element {
#[component]
fn PostEntry(post: Post) -> Element {
let tag_items = post.tags.iter().map(|t| *t).collect::<Vec<_>>();
let tag_items = post.tags.to_vec();
rsx! {
article { class: "relative mb-6 p-6 bg-white dark:bg-[#2e2e33] rounded-lg border border-gray-200 dark:border-[#333] hover:-translate-y-0.5 hover:border-gray-300 dark:hover:border-gray-600 transition-all duration-250",
@ -182,90 +140,3 @@ fn Pagination() -> Element {
}
}
}
#[component]
pub fn Footer() -> Element {
let mut visible = use_signal(|| false);
use_effect(move || {
#[cfg(target_arch = "wasm32")]
{
if let Some(window) = web_sys::window() {
let closure = wasm_bindgen::prelude::Closure::wrap(Box::new(move || {
if let Some(w) = web_sys::window() {
let threshold = w.inner_height().ok()
.and_then(|h| h.as_f64())
.unwrap_or(0.0);
let scroll_y = w.scroll_y().unwrap_or(0.0);
let new_visible = scroll_y > threshold;
visible.set(new_visible);
}
}) as Box<dyn FnMut()>);
let _ = window.add_event_listener_with_callback("scroll", wasm_bindgen::JsCast::unchecked_ref(closure.as_ref()));
let threshold = window.inner_height().ok()
.and_then(|h| h.as_f64())
.unwrap_or(0.0);
let scroll_y = window.scroll_y().unwrap_or(0.0);
visible.set(scroll_y > threshold);
closure.forget();
}
}
});
let link_class = use_memo(move || {
let base = "p-2 rounded-full cursor-pointer hover:opacity-80 transition-all duration-300 text-gray-600 dark:text-gray-300";
if visible() {
format!("{} opacity-100 translate-y-0", base)
} else {
format!("{} opacity-0 translate-y-2 pointer-events-none", base)
}
});
rsx! {
footer { class: "w-full border-t border-gray-200 dark:border-[#333] mt-auto",
div { class: "max-w-3xl mx-auto px-6 py-5 flex items-center justify-between text-sm text-gray-400 dark:text-[#9b9c9d]",
span { "© 2026 Yggdrasil Blog" }
a {
class: "{link_class}",
href: "#top",
aria_label: "go to top",
title: "Go to Top (Alt + G)",
accesskey: "g",
onclick: move |evt| {
evt.prevent_default();
scroll_to_top();
},
svg {
xmlns: "http://www.w3.org/2000/svg",
height: "24px",
view_box: "0 -960 960 960",
width: "24px",
fill: "currentColor",
path {
d: "m296-224-56-56 240-240 240 240-56 56-184-183-184 183Zm0-240-56-56 240-240 240 240-56 56-184-183-184 183Z",
}
}
}
}
}
}
}
fn scroll_to_top() {
#[cfg(target_arch = "wasm32")]
{
if let Some(window) = web_sys::window() {
let mut options = web_sys::ScrollToOptions::new();
options.top(0.0);
options.behavior(web_sys::ScrollBehavior::Smooth);
let _ = window.scroll_to_with_scroll_to_options(&options);
if let Ok(history) = window.history() {
let _ = history.replace_state_with_url(&wasm_bindgen::JsValue::NULL, "", Some(" "));
}
}
}
}

View File

@ -24,13 +24,12 @@ pub fn LoginPage() -> Element {
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
30 * 24 * 60 * 60
);
if let Some(window) = web_sys::window() {
if let Some(document) = window.document() {
@ -40,7 +39,6 @@ pub fn LoginPage() -> Element {
}
}
}
// 跳转到 admin 页面
let _ = dioxus::router::navigator().push("/admin");
}
Ok(AuthResponse {
@ -65,25 +63,25 @@ pub fn LoginPage() -> Element {
};
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",
div { class: "min-h-screen flex items-center justify-center bg-white dark:bg-[#1d1e20]",
div { class: "w-full max-w-md p-8 bg-white dark:bg-[#2e2e33] rounded-2xl border border-gray-200 dark:border-[#333]",
h1 { class: "text-2xl font-bold text-center text-gray-900 dark:text-[#dadadb] 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",
div { class: "mb-4 p-3 bg-red-100 dark:bg-red-900/30 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",
label { class: "block text-sm font-medium text-gray-700 dark:text-[#9b9c9d] 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",
class: "w-full px-4 py-2 border border-gray-200 dark:border-[#333] rounded-lg bg-white dark:bg-[#2e2e33] text-gray-900 dark:text-[#dadadb] focus:outline-none focus:border-gray-400 dark:focus:border-gray-600",
r#type: "text",
placeholder: "用户名",
value: username(),
@ -91,11 +89,11 @@ pub fn LoginPage() -> Element {
}
}
div {
label { class: "block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1",
label { class: "block text-sm font-medium text-gray-700 dark:text-[#9b9c9d] 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",
class: "w-full px-4 py-2 border border-gray-200 dark:border-[#333] rounded-lg bg-white dark:bg-[#2e2e33] text-gray-900 dark:text-[#dadadb] focus:outline-none focus:border-gray-400 dark:focus:border-gray-600",
r#type: "password",
placeholder: "密码",
value: password(),
@ -103,12 +101,12 @@ pub fn LoginPage() -> Element {
}
}
button {
class: "w-full py-2 px-4 bg-blue-600 hover:bg-blue-700 text-white font-medium rounded-lg transition-colors",
class: "w-full py-2 px-4 bg-gray-900 dark:bg-[#dadadb] text-white dark:text-gray-900 font-medium rounded-full hover:opacity-80 transition-opacity",
onclick: on_submit,
"登录"
}
a {
class: "block w-full py-2 px-4 text-center text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 font-medium rounded-lg transition-colors",
class: "block w-full py-2 px-4 text-center text-gray-500 dark:text-[#9b9c9d] hover:text-gray-700 dark:hover:text-[#dadadb] font-medium rounded-lg transition-colors",
href: "/register",
"还没有账号?去注册"
}

View File

@ -44,37 +44,37 @@ pub fn RegisterPage() -> Element {
};
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",
div { class: "min-h-screen flex items-center justify-center bg-white dark:bg-[#1d1e20]",
div { class: "w-full max-w-md p-8 bg-white dark:bg-[#2e2e33] rounded-2xl border border-gray-200 dark:border-[#333]",
h1 { class: "text-2xl font-bold text-center text-gray-900 dark:text-[#dadadb] mb-2",
"注册"
}
p { class: "text-sm text-center text-gray-500 dark:text-gray-400 mb-6",
p { class: "text-sm text-center text-gray-500 dark:text-[#9b9c9d] 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",
div { class: "mb-4 p-3 bg-green-100 dark:bg-green-900/30 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",
a { class: "block mt-2 text-gray-700 dark:text-[#dadadb] 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",
div { class: "mb-4 p-3 bg-red-100 dark:bg-red-900/30 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",
label { class: "block text-sm font-medium text-gray-700 dark:text-[#9b9c9d] 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",
class: "w-full px-4 py-2 border border-gray-200 dark:border-[#333] rounded-lg bg-white dark:bg-[#2e2e33] text-gray-900 dark:text-[#dadadb] focus:outline-none focus:border-gray-400 dark:focus:border-gray-600",
r#type: "text",
placeholder: "3-50 位字符",
value: username(),
@ -82,11 +82,11 @@ pub fn RegisterPage() -> Element {
}
}
div {
label { class: "block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1",
label { class: "block text-sm font-medium text-gray-700 dark:text-[#9b9c9d] 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",
class: "w-full px-4 py-2 border border-gray-200 dark:border-[#333] rounded-lg bg-white dark:bg-[#2e2e33] text-gray-900 dark:text-[#dadadb] focus:outline-none focus:border-gray-400 dark:focus:border-gray-600",
r#type: "email",
placeholder: "your@email.com",
value: email(),
@ -94,11 +94,11 @@ pub fn RegisterPage() -> Element {
}
}
div {
label { class: "block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1",
label { class: "block text-sm font-medium text-gray-700 dark:text-[#9b9c9d] 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",
class: "w-full px-4 py-2 border border-gray-200 dark:border-[#333] rounded-lg bg-white dark:bg-[#2e2e33] text-gray-900 dark:text-[#dadadb] focus:outline-none focus:border-gray-400 dark:focus:border-gray-600",
r#type: "password",
placeholder: "至少 8 位",
value: password(),
@ -106,11 +106,11 @@ pub fn RegisterPage() -> Element {
}
}
div {
label { class: "block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1",
label { class: "block text-sm font-medium text-gray-700 dark:text-[#9b9c9d] 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",
class: "w-full px-4 py-2 border border-gray-200 dark:border-[#333] rounded-lg bg-white dark:bg-[#2e2e33] text-gray-900 dark:text-[#dadadb] focus:outline-none focus:border-gray-400 dark:focus:border-gray-600",
r#type: "password",
placeholder: "再次输入密码",
value: confirm_password(),
@ -118,14 +118,14 @@ pub fn RegisterPage() -> Element {
}
}
button {
class: "w-full py-2 px-4 bg-blue-600 hover:bg-blue-700 text-white font-medium rounded-lg transition-colors",
class: "w-full py-2 px-4 bg-gray-900 dark:bg-[#dadadb] text-white dark:text-gray-900 font-medium rounded-full hover:opacity-80 transition-opacity",
onclick: on_submit,
"注册"
}
}
p { class: "mt-4 text-center text-sm text-gray-500 dark:text-gray-400",
p { class: "mt-4 text-center text-sm text-gray-500 dark:text-[#9b9c9d]",
"已有账号?"
a { class: "text-blue-600 dark:text-blue-400 hover:underline", href: "/login",
a { class: "text-gray-700 dark:text-[#dadadb] hover:underline", href: "/login",
"去登录"
}
}

View File

@ -1,6 +1,10 @@
use dioxus::prelude::*;
use crate::pages::home::{Footer, Header, Post, POSTS};
use crate::components::header::{Header, NavItemConfig};
use crate::components::footer::Footer;
use crate::pages::home::{Post, POSTS};
use crate::router::Route;
use crate::theme::ThemeToggle;
#[derive(Clone, PartialEq)]
struct TagInfo {
@ -30,19 +34,28 @@ fn collect_tags() -> Vec<TagInfo> {
fn posts_for_tag(tag: &str) -> Vec<Post> {
POSTS
.iter()
.filter(|p| p.tags.iter().any(|t| *t == tag))
.filter(|p| p.tags.contains(&tag))
.cloned()
.collect()
}
#[component]
pub fn TagsPage() -> Element {
let route = use_route::<Route>();
let nav_items = vec![
NavItemConfig { href: "/", label: "首页", is_active: matches!(route, Route::HomePage {}) },
NavItemConfig { href: "/archives", label: "归档", is_active: matches!(route, Route::ArchivesPage {}) },
NavItemConfig { href: "/tags", label: "标签", is_active: matches!(route, Route::TagsPage {}) || matches!(route, Route::TagDetailPage { .. }) },
NavItemConfig { href: "/search", label: "搜索", is_active: matches!(route, Route::SearchPage {}) },
NavItemConfig { href: "/about", label: "关于", is_active: matches!(route, Route::AboutPage {}) },
];
let tags = collect_tags();
let total_posts = POSTS.len();
rsx! {
div { class: "min-h-screen flex flex-col bg-white dark:bg-[#1d1e20] transition-colors duration-300",
Header {}
Header { nav_items, right_content: rsx! { ThemeToggle {} } }
main { class: "flex-1 w-full max-w-3xl mx-auto px-6 py-6",
header { class: "page-header mb-6",
h1 { class: "text-[34px] font-bold text-gray-900 dark:text-[#dadadb]",
@ -76,11 +89,20 @@ pub fn TagsPage() -> Element {
#[component]
pub fn TagDetailPage(tag: String) -> Element {
let route = use_route::<Route>();
let nav_items = vec![
NavItemConfig { href: "/", label: "首页", is_active: matches!(route, Route::HomePage {}) },
NavItemConfig { href: "/archives", label: "归档", is_active: matches!(route, Route::ArchivesPage {}) },
NavItemConfig { href: "/tags", label: "标签", is_active: matches!(route, Route::TagsPage {}) || matches!(route, Route::TagDetailPage { .. }) },
NavItemConfig { href: "/search", label: "搜索", is_active: matches!(route, Route::SearchPage {}) },
NavItemConfig { href: "/about", label: "关于", is_active: matches!(route, Route::AboutPage {}) },
];
let posts = posts_for_tag(&tag);
rsx! {
div { class: "min-h-screen flex flex-col bg-white dark:bg-[#1d1e20] transition-colors duration-300",
Header {}
Header { nav_items, right_content: rsx! { ThemeToggle {} } }
main { class: "flex-1 w-full max-w-3xl mx-auto px-6 py-6",
header { class: "page-header mb-6",
h1 { class: "text-[34px] font-bold text-gray-900 dark:text-[#dadadb]",
@ -103,7 +125,7 @@ pub fn TagDetailPage(tag: String) -> Element {
#[component]
fn TagPostEntry(post: Post) -> Element {
let tag_items = post.tags.iter().map(|t| *t).collect::<Vec<_>>();
let tag_items = post.tags.to_vec();
rsx! {
article { class: "relative mb-6 p-6 bg-white dark:bg-[#2e2e33] rounded-lg border border-gray-200 dark:border-[#333] hover:-translate-y-0.5 hover:border-gray-300 dark:hover:border-gray-600 transition-all duration-250",

View File

@ -1,6 +1,6 @@
use dioxus::prelude::*;
use crate::pages::admin::AdminPage;
use crate::pages::admin::{AdminPage, WritePage};
use crate::pages::archives::ArchivesPage;
use crate::pages::home::HomePage;
use crate::pages::login::LoginPage;
@ -18,6 +18,8 @@ pub enum Route {
RegisterPage {},
#[route("/admin")]
AdminPage {},
#[route("/admin/write")]
WritePage {},
#[route("/archives")]
ArchivesPage {},
#[route("/tags")]