From 61376f6ba9d8d18cec90a4ec7521f3c6724b2217 Mon Sep 17 00:00:00 2001 From: xfy Date: Wed, 27 May 2026 00:18:15 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20admin=20=E8=B7=AF=E7=94=B1?= =?UTF-8?q?=E5=88=87=E6=8D=A2=E9=97=AA=E7=83=81=EF=BC=8C=E5=85=A8=E5=B1=80?= =?UTF-8?q?=E6=9B=BF=E6=8D=A2=E5=8E=9F=E7=94=9F=E5=AF=BC=E8=88=AA=E4=B8=BA?= =?UTF-8?q?=E5=AE=A2=E6=88=B7=E7=AB=AF=E8=B7=AF=E7=94=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增全局 UserContext,将用户认证状态提升到 App 级别缓存 - 将 /admin 和 /admin/write 改为嵌套路由,AdminLayout 作为共享父布局 - AdminLayout 使用 Outlet 渲染子页面,避免路由切换时重复挂载 - 修复所有原生 标签导致的整页刷新问题: - Header 导航栏 Logo 和 NavItem - 首页文章卡片、分页按钮 - 归档页文章条目 - 标签页标签云、文章卡片、标签链接 - 登录/注册页面链接 - Dashboard 快捷操作按钮 Co-Authored-By: Claude Opus 4.7 (1M context) --- src/components/admin_layout.rs | 57 +++++++++++++++++---------- src/components/header.rs | 15 ++++++- src/context.rs | 10 +++++ src/main.rs | 1 + src/pages/admin/dashboard.rs | 65 +++++++++++++++--------------- src/pages/admin/write.rs | 72 ++++++++++++++++------------------ src/pages/archives.rs | 4 ++ src/pages/home.rs | 15 +++++-- src/pages/login.rs | 6 +-- src/pages/register.rs | 6 ++- src/pages/tags.rs | 31 +++++++++++---- src/router.rs | 22 +++++++++-- 12 files changed, 191 insertions(+), 113 deletions(-) create mode 100644 src/context.rs diff --git a/src/components/admin_layout.rs b/src/components/admin_layout.rs index e88f14d..8992738 100644 --- a/src/components/admin_layout.rs +++ b/src/components/admin_layout.rs @@ -3,16 +3,36 @@ use dioxus::prelude::*; use crate::api::auth::{get_current_user, logout}; use crate::components::header::{Header, NavItemConfig}; use crate::components::footer::Footer; +use crate::context::UserContext; 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) }); - +pub fn AdminLayout() -> Element { + let mut ctx: UserContext = use_context(); let navigator = dioxus::router::navigator(); let route = use_route::(); + // 只在首次挂载时加载用户数据 + use_effect(move || { + if !(ctx.checked)() { + (ctx.checked).set(true); + spawn(async move { + match get_current_user().await { + Ok(response) => { + if let Some(user) = response.user { + ctx.user.set(Some(std::sync::Arc::new(user))); + } else { + let _ = navigator.push("/login"); + } + } + Err(_) => { + let _ = navigator.push("/login"); + } + } + }); + } + }); + let admin_nav_items = vec![ NavItemConfig { href: "/admin", @@ -31,12 +51,12 @@ pub fn AdminLayout(children: Element) -> Element { }, ]; - let nav = navigator; + let nav = navigator.clone(); 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; + let nav = nav.clone(); spawn(async move { let _ = logout().await; let _ = nav.push("/login"); @@ -46,29 +66,26 @@ pub fn AdminLayout(children: Element) -> Element { } }; - 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)) => { + match ((ctx.checked)(), (ctx.user)()) { + (true, Some(_)) => { 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} + Outlet:: {} } Footer {} } } } - _ => { + (true, None) => { + rsx! { + div { class: "min-h-screen flex items-center justify-center bg-white dark:bg-[#1d1e20]", + p { class: "text-gray-600 dark:text-[#9b9c9d]", "未登录,正在跳转..." } + } + } + } + (false, _) => { rsx! { div { class: "min-h-screen flex items-center justify-center bg-white dark:bg-[#1d1e20]", p { class: "text-gray-600 dark:text-[#9b9c9d]", "加载中..." } diff --git a/src/components/header.rs b/src/components/header.rs index 6c5cfc8..8b3ae4f 100644 --- a/src/components/header.rs +++ b/src/components/header.rs @@ -15,6 +15,10 @@ pub fn Header(nav_items: Vec, right_content: Element) -> Element a { class: "text-2xl font-bold text-gray-900 dark:text-[#dadadb] hover:opacity-80 transition-opacity", href: "/", + onclick: move |evt| { + evt.prevent_default(); + dioxus::router::navigator().push("/"); + }, "Yggdrasil" } div { class: "flex items-center gap-2", @@ -43,9 +47,18 @@ fn NavItem(href: &'static str, label: &'static str, is_active: bool) -> Element format!("{} text-gray-600 dark:text-[#9b9c9d] hover:text-gray-900 dark:hover:text-[#dadadb]", base_class) }; + let href = href; rsx! { li { - a { class: "{class_str}", href: "{href}", "{label}" } + a { + class: "{class_str}", + href: "{href}", + onclick: move |evt| { + evt.prevent_default(); + dioxus::router::navigator().push(href); + }, + "{label}" + } } } } diff --git a/src/context.rs b/src/context.rs new file mode 100644 index 0000000..1b75899 --- /dev/null +++ b/src/context.rs @@ -0,0 +1,10 @@ +use dioxus::prelude::*; +use std::sync::Arc; + +use crate::models::user::User; + +#[derive(Clone, Copy)] +pub struct UserContext { + pub user: Signal>>, + pub checked: Signal, +} diff --git a/src/main.rs b/src/main.rs index 206fa7c..1a156ec 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,6 +1,7 @@ mod api; mod auth; mod components; +mod context; mod db; mod models; mod pages; diff --git a/src/pages/admin/dashboard.rs b/src/pages/admin/dashboard.rs index a9bb685..51640f7 100644 --- a/src/pages/admin/dashboard.rs +++ b/src/pages/admin/dashboard.rs @@ -1,46 +1,45 @@ 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: "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: "grid grid-cols-1 md:grid-cols-2 gap-4", + button { + 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 cursor-pointer", + onclick: move |_| { + dioxus::router::navigator().push("/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() } - } + // 最近文章 + 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() } } } } diff --git a/src/pages/admin/write.rs b/src/pages/admin/write.rs index 8ef4b14..d03013f 100644 --- a/src/pages/admin/write.rs +++ b/src/pages/admin/write.rs @@ -1,7 +1,5 @@ use dioxus::prelude::*; -use crate::components::admin_layout::AdminLayout; - #[component] pub fn WritePage() -> Element { let mut title = use_signal(|| "".to_string()); @@ -36,44 +34,42 @@ pub fn WritePage() -> Element { }); 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: "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()), + } - // Tiptap 编辑器容器 - div { - class: "w-full h-[600px] border border-gray-200 dark:border-[#333] rounded-lg overflow-hidden bg-white dark:bg-[#1e1e1e]", - id: "tiptap-editor", - } + // Tiptap 编辑器容器 + div { + class: "w-full h-[600px] border border-gray-200 dark:border-[#333] rounded-lg overflow-hidden bg-white dark:bg-[#1e1e1e]", + id: "tiptap-editor", + } - // 保存按钮 - 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 |_| { - #[cfg(target_arch = "wasm32")] - { - let md = js_sys::eval(r#" - (function() { - var editor = window.TiptapEditor && window.TiptapEditor._instances && window.TiptapEditor._instances.get('tiptap-editor'); - return editor ? editor.getMarkdown() : (window.__tiptap_content || ''); - })() - "#).ok().and_then(|v| v.as_string()).unwrap_or_default(); - content.set(md.clone()); - println!("保存文章: title={}, content_len={}", title(), md.len()); - } - #[cfg(not(target_arch = "wasm32"))] - { - println!("保存文章: title={}, content_len={}", title(), content().len()); - } - }, - "保存草稿" - } + // 保存按钮 + 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 |_| { + #[cfg(target_arch = "wasm32")] + { + let md = js_sys::eval(r#" + (function() { + var editor = window.TiptapEditor && window.TiptapEditor._instances && window.TiptapEditor._instances.get('tiptap-editor'); + return editor ? editor.getMarkdown() : (window.__tiptap_content || ''); + })() + "#).ok().and_then(|v| v.as_string()).unwrap_or_default(); + content.set(md.clone()); + println!("保存文章: title={}, content_len={}", title(), md.len()); + } + #[cfg(not(target_arch = "wasm32"))] + { + println!("保存文章: title={}, content_len={}", title(), content().len()); + } + }, + "保存草稿" } } } diff --git a/src/pages/archives.rs b/src/pages/archives.rs index 7440df2..82ea2db 100644 --- a/src/pages/archives.rs +++ b/src/pages/archives.rs @@ -211,6 +211,10 @@ fn ArchiveEntry(post: Post) -> Element { class: "entry-link absolute inset-0 z-10", aria_label: "post link to {post.title}", href: "/post/{post.slug}", + onclick: move |evt| { + evt.prevent_default(); + dioxus::router::navigator().push(format!("/post/{}", post.slug).as_str()); + }, } } } diff --git a/src/pages/home.rs b/src/pages/home.rs index a0efba0..5148c71 100644 --- a/src/pages/home.rs +++ b/src/pages/home.rs @@ -103,9 +103,16 @@ fn HomeInfo() -> Element { fn PostEntry(post: Post) -> Element { let tag_items = post.tags.to_vec(); + let post_slug = post.slug; 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", - a { class: "block group", href: "/post/{post.slug}", + a { + class: "block group", + href: "/post/{post_slug}", + onclick: move |evt| { + evt.prevent_default(); + dioxus::router::navigator().push(format!("/post/{}", post_slug).as_str()); + }, h2 { class: "text-2xl font-bold leading-tight text-gray-900 dark:text-[#dadadb] group-hover:opacity-80 transition-opacity", "{post.title}" } @@ -131,9 +138,9 @@ fn PostEntry(post: Post) -> Element { fn Pagination() -> Element { rsx! { nav { class: "flex mt-10 mb-6", - a { - class: "ml-auto inline-flex items-center px-4 py-2 text-sm text-white bg-gray-900 dark:bg-[#dadadb] dark:text-gray-900 rounded-full hover:opacity-80 transition-opacity", - href: "/page/2", + button { + class: "ml-auto inline-flex items-center px-4 py-2 text-sm text-white bg-gray-900 dark:bg-[#dadadb] dark:text-gray-900 rounded-full hover:opacity-80 transition-opacity cursor-pointer", + onclick: move |_| { dioxus::router::navigator().push("/page/2"); }, "下一页" span { class: "ml-1", "»" } } diff --git a/src/pages/login.rs b/src/pages/login.rs index 7e7c113..9d3a89d 100644 --- a/src/pages/login.rs +++ b/src/pages/login.rs @@ -89,9 +89,9 @@ pub fn LoginPage() -> Element { onclick: move |_| on_submit(()), "登录" } - a { - 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", + button { + 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 cursor-pointer", + onclick: move |_| { dioxus::router::navigator().push("/register"); }, "还没有账号?去注册" } } diff --git a/src/pages/register.rs b/src/pages/register.rs index 3ec12db..b77d66a 100644 --- a/src/pages/register.rs +++ b/src/pages/register.rs @@ -56,7 +56,8 @@ pub fn RegisterPage() -> Element { if success() { 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-gray-700 dark:text-[#dadadb] hover:underline", href: "/login", + button { class: "block mt-2 text-gray-700 dark:text-[#dadadb] hover:underline cursor-pointer", + onclick: move |_| { dioxus::router::navigator().push("/login"); }, "去登录" } } @@ -125,7 +126,8 @@ pub fn RegisterPage() -> Element { } p { class: "mt-4 text-center text-sm text-gray-500 dark:text-[#9b9c9d]", "已有账号?" - a { class: "text-gray-700 dark:text-[#dadadb] hover:underline", href: "/login", + button { class: "text-gray-700 dark:text-[#dadadb] hover:underline cursor-pointer", + onclick: move |_| { dioxus::router::navigator().push("/login"); }, "去登录" } } diff --git a/src/pages/tags.rs b/src/pages/tags.rs index 4fdb8fc..f47f356 100644 --- a/src/pages/tags.rs +++ b/src/pages/tags.rs @@ -70,13 +70,17 @@ pub fn TagsPage() -> Element { } } ul { class: "flex flex-wrap gap-4 mt-6", - for tag in tags.iter() { + for (name, count) in tags.into_iter().map(|t| (t.name, t.count)) { li { a { class: "inline-flex items-center px-3 py-1.5 text-base font-medium bg-gray-100 dark:bg-[#2e2e33] text-gray-700 dark:text-[#9b9c9d] rounded-lg hover:bg-gray-200 dark:hover:bg-[#333] transition-colors", - href: "/tags/{tag.name}", - "{tag.name}" - sup { class: "ml-1 text-sm text-gray-500 dark:text-[#9b9c9d]", "{tag.count}" } + href: "/tags/{name}", + onclick: move |evt| { + evt.prevent_default(); + dioxus::router::navigator().push(format!("/tags/{}", name).as_str()); + }, + "{name}" + sup { class: "ml-1 text-sm text-gray-500 dark:text-[#9b9c9d]", "{count}" } } } } @@ -126,10 +130,17 @@ pub fn TagDetailPage(tag: String) -> Element { #[component] fn TagPostEntry(post: Post) -> Element { let tag_items = post.tags.to_vec(); + let post_slug = post.slug; 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", - a { class: "block group", href: "/post/{post.slug}", + a { + class: "block group", + href: "/post/{post_slug}", + onclick: move |evt| { + evt.prevent_default(); + dioxus::router::navigator().push(format!("/post/{}", post_slug).as_str()); + }, h2 { class: "text-2xl font-bold leading-tight text-gray-900 dark:text-[#dadadb] group-hover:opacity-80 transition-opacity", "{post.title}" } @@ -139,15 +150,19 @@ fn TagPostEntry(post: Post) -> Element { div { class: "mt-3 flex items-center gap-3 text-[13px] text-gray-400 dark:text-[#9b9c9d]", span { "{post.date}" } span { "·" } - for (i, t) in tag_items.iter().enumerate() { + for (i, tag_name) in tag_items.into_iter().enumerate() { if i > 0 { span { "," } } span { a { class: "hover:text-gray-600 dark:hover:text-[#dadadb] transition-colors", - href: "/tags/{t}", - "{t}" + href: "/tags/{tag_name}", + onclick: move |evt| { + evt.prevent_default(); + dioxus::router::navigator().push(format!("/tags/{}", tag_name).as_str()); + }, + "{tag_name}" } } } diff --git a/src/router.rs b/src/router.rs index fd40c78..4259077 100644 --- a/src/router.rs +++ b/src/router.rs @@ -1,5 +1,8 @@ use dioxus::prelude::*; +use std::sync::Arc; +use crate::components::admin_layout::AdminLayout; +use crate::context::UserContext; use crate::pages::admin::{AdminPage, WritePage}; use crate::pages::archives::ArchivesPage; use crate::pages::home::HomePage; @@ -9,6 +12,7 @@ use crate::pages::tags::{TagsPage, TagDetailPage}; use crate::theme::{Theme, ThemePreload, use_theme_provider}; #[derive(Clone, Routable, Debug, PartialEq)] +#[rustfmt::skip] pub enum Route { #[route("/")] HomePage {}, @@ -16,10 +20,16 @@ pub enum Route { LoginPage {}, #[route("/register")] RegisterPage {}, - #[route("/admin")] - AdminPage {}, - #[route("/admin/write")] - WritePage {}, + + #[nest("/admin")] + #[layout(AdminLayout)] + #[route("/")] + AdminPage {}, + #[route("/write")] + WritePage {}, + #[end_layout] + #[end_nest] + #[route("/archives")] ArchivesPage {}, #[route("/tags")] @@ -40,6 +50,10 @@ pub fn AppRouter() -> Element { Theme::Light => "", }; + let user = use_signal(|| None::>); + let checked = use_signal(|| false); + use_context_provider(|| UserContext { user, checked }); + rsx! { div { class: "{theme_class}",