修复 admin 路由切换闪烁,全局替换原生导航为客户端路由

- 新增全局 UserContext,将用户认证状态提升到 App 级别缓存
- 将 /admin 和 /admin/write 改为嵌套路由,AdminLayout 作为共享父布局
- AdminLayout 使用 Outlet 渲染子页面,避免路由切换时重复挂载
- 修复所有原生 <a> 标签导致的整页刷新问题:
  - Header 导航栏 Logo 和 NavItem
  - 首页文章卡片、分页按钮
  - 归档页文章条目
  - 标签页标签云、文章卡片、标签链接
  - 登录/注册页面链接
  - Dashboard 快捷操作按钮

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
xfy 2026-05-27 00:18:15 +08:00
parent 5d523fbfc7
commit 61376f6ba9
12 changed files with 191 additions and 113 deletions

View File

@ -3,16 +3,36 @@ use dioxus::prelude::*;
use crate::api::auth::{get_current_user, logout}; use crate::api::auth::{get_current_user, logout};
use crate::components::header::{Header, NavItemConfig}; use crate::components::header::{Header, NavItemConfig};
use crate::components::footer::Footer; use crate::components::footer::Footer;
use crate::context::UserContext;
use crate::router::Route; use crate::router::Route;
#[component] #[component]
pub fn AdminLayout(children: Element) -> Element { pub fn AdminLayout() -> Element {
let user_resource = let mut ctx: UserContext = use_context();
use_resource(|| async move { get_current_user().await.ok().and_then(|r| r.user) });
let navigator = dioxus::router::navigator(); let navigator = dioxus::router::navigator();
let route = use_route::<Route>(); let route = use_route::<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![ let admin_nav_items = vec![
NavItemConfig { NavItemConfig {
href: "/admin", href: "/admin",
@ -31,12 +51,12 @@ pub fn AdminLayout(children: Element) -> Element {
}, },
]; ];
let nav = navigator; let nav = navigator.clone();
let logout_button = rsx! { let logout_button = rsx! {
button { button {
class: "text-sm text-gray-600 dark:text-[#9b9c9d] hover:text-gray-900 dark:hover:text-[#dadadb] transition-colors", class: "text-sm text-gray-600 dark:text-[#9b9c9d] hover:text-gray-900 dark:hover:text-[#dadadb] transition-colors",
onclick: move |_| { onclick: move |_| {
let nav = nav; let nav = nav.clone();
spawn(async move { spawn(async move {
let _ = logout().await; let _ = logout().await;
let _ = nav.push("/login"); let _ = nav.push("/login");
@ -46,29 +66,26 @@ pub fn AdminLayout(children: Element) -> Element {
} }
}; };
let user_data = user_resource.read().clone(); match ((ctx.checked)(), (ctx.user)()) {
(true, Some(_)) => {
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! { rsx! {
div { class: "min-h-screen flex flex-col bg-white dark:bg-[#1d1e20]", div { class: "min-h-screen flex flex-col bg-white dark:bg-[#1d1e20]",
Header { nav_items: admin_nav_items, right_content: logout_button } 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", main { class: "flex-1 w-full max-w-5xl mx-auto px-6 py-8",
{children} Outlet::<Route> {}
} }
Footer {} 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! { rsx! {
div { class: "min-h-screen flex items-center justify-center bg-white dark:bg-[#1d1e20]", div { class: "min-h-screen flex items-center justify-center bg-white dark:bg-[#1d1e20]",
p { class: "text-gray-600 dark:text-[#9b9c9d]", "加载中..." } p { class: "text-gray-600 dark:text-[#9b9c9d]", "加载中..." }

View File

@ -15,6 +15,10 @@ pub fn Header(nav_items: Vec<NavItemConfig>, right_content: Element) -> Element
a { a {
class: "text-2xl font-bold text-gray-900 dark:text-[#dadadb] hover:opacity-80 transition-opacity", class: "text-2xl font-bold text-gray-900 dark:text-[#dadadb] hover:opacity-80 transition-opacity",
href: "/", href: "/",
onclick: move |evt| {
evt.prevent_default();
dioxus::router::navigator().push("/");
},
"Yggdrasil" "Yggdrasil"
} }
div { class: "flex items-center gap-2", 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) format!("{} text-gray-600 dark:text-[#9b9c9d] hover:text-gray-900 dark:hover:text-[#dadadb]", base_class)
}; };
let href = href;
rsx! { rsx! {
li { 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}"
}
} }
} }
} }

10
src/context.rs Normal file
View File

@ -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<Option<Arc<User>>>,
pub checked: Signal<bool>,
}

View File

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

View File

@ -1,12 +1,10 @@
use dioxus::prelude::*; use dioxus::prelude::*;
use crate::components::admin_layout::AdminLayout;
use crate::pages::home::{Post, POSTS}; use crate::pages::home::{Post, POSTS};
#[component] #[component]
pub fn AdminPage() -> Element { pub fn AdminPage() -> Element {
rsx! { rsx! {
AdminLayout {
div { class: "space-y-8", div { class: "space-y-8",
// 统计卡片 // 统计卡片
div { class: "grid grid-cols-1 md:grid-cols-3 gap-6", div { class: "grid grid-cols-1 md:grid-cols-3 gap-6",
@ -17,9 +15,11 @@ pub fn AdminPage() -> Element {
// 快捷操作 // 快捷操作
div { class: "grid grid-cols-1 md:grid-cols-2 gap-4", div { class: "grid grid-cols-1 md:grid-cols-2 gap-4",
a { 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", 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",
href: "/admin/write", onclick: move |_| {
dioxus::router::navigator().push("/admin/write");
},
"写文章" "写文章"
} }
button { button {
@ -45,7 +45,6 @@ pub fn AdminPage() -> Element {
} }
} }
} }
}
} }
#[component] #[component]

View File

@ -1,7 +1,5 @@
use dioxus::prelude::*; use dioxus::prelude::*;
use crate::components::admin_layout::AdminLayout;
#[component] #[component]
pub fn WritePage() -> Element { pub fn WritePage() -> Element {
let mut title = use_signal(|| "".to_string()); let mut title = use_signal(|| "".to_string());
@ -36,7 +34,6 @@ pub fn WritePage() -> Element {
}); });
rsx! { rsx! {
AdminLayout {
div { class: "space-y-4", div { class: "space-y-4",
// 标题输入 // 标题输入
input { input {
@ -76,5 +73,4 @@ pub fn WritePage() -> Element {
} }
} }
} }
}
} }

View File

@ -211,6 +211,10 @@ fn ArchiveEntry(post: Post) -> Element {
class: "entry-link absolute inset-0 z-10", class: "entry-link absolute inset-0 z-10",
aria_label: "post link to {post.title}", aria_label: "post link to {post.title}",
href: "/post/{post.slug}", href: "/post/{post.slug}",
onclick: move |evt| {
evt.prevent_default();
dioxus::router::navigator().push(format!("/post/{}", post.slug).as_str());
},
} }
} }
} }

View File

@ -103,9 +103,16 @@ fn HomeInfo() -> Element {
fn PostEntry(post: Post) -> Element { fn PostEntry(post: Post) -> Element {
let tag_items = post.tags.to_vec(); let tag_items = post.tags.to_vec();
let post_slug = post.slug;
rsx! { 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", 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", h2 { class: "text-2xl font-bold leading-tight text-gray-900 dark:text-[#dadadb] group-hover:opacity-80 transition-opacity",
"{post.title}" "{post.title}"
} }
@ -131,9 +138,9 @@ fn PostEntry(post: Post) -> Element {
fn Pagination() -> Element { fn Pagination() -> Element {
rsx! { rsx! {
nav { class: "flex mt-10 mb-6", nav { class: "flex mt-10 mb-6",
a { 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", 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",
href: "/page/2", onclick: move |_| { dioxus::router::navigator().push("/page/2"); },
"下一页" "下一页"
span { class: "ml-1", "»" } span { class: "ml-1", "»" }
} }

View File

@ -89,9 +89,9 @@ pub fn LoginPage() -> Element {
onclick: move |_| on_submit(()), onclick: move |_| on_submit(()),
"登录" "登录"
} }
a { 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", 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",
href: "/register", onclick: move |_| { dioxus::router::navigator().push("/register"); },
"还没有账号?去注册" "还没有账号?去注册"
} }
} }

View File

@ -56,7 +56,8 @@ pub fn RegisterPage() -> Element {
if success() { 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", 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]", 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"); },
"去登录" "去登录"
} }
} }

View File

@ -70,13 +70,17 @@ pub fn TagsPage() -> Element {
} }
} }
ul { class: "flex flex-wrap gap-4 mt-6", 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 { li {
a { 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", 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}", href: "/tags/{name}",
"{tag.name}" onclick: move |evt| {
sup { class: "ml-1 text-sm text-gray-500 dark:text-[#9b9c9d]", "{tag.count}" } 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] #[component]
fn TagPostEntry(post: Post) -> Element { fn TagPostEntry(post: Post) -> Element {
let tag_items = post.tags.to_vec(); let tag_items = post.tags.to_vec();
let post_slug = post.slug;
rsx! { 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", 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", h2 { class: "text-2xl font-bold leading-tight text-gray-900 dark:text-[#dadadb] group-hover:opacity-80 transition-opacity",
"{post.title}" "{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]", div { class: "mt-3 flex items-center gap-3 text-[13px] text-gray-400 dark:text-[#9b9c9d]",
span { "{post.date}" } span { "{post.date}" }
span { "·" } span { "·" }
for (i, t) in tag_items.iter().enumerate() { for (i, tag_name) in tag_items.into_iter().enumerate() {
if i > 0 { if i > 0 {
span { "," } span { "," }
} }
span { span {
a { a {
class: "hover:text-gray-600 dark:hover:text-[#dadadb] transition-colors", class: "hover:text-gray-600 dark:hover:text-[#dadadb] transition-colors",
href: "/tags/{t}", href: "/tags/{tag_name}",
"{t}" onclick: move |evt| {
evt.prevent_default();
dioxus::router::navigator().push(format!("/tags/{}", tag_name).as_str());
},
"{tag_name}"
} }
} }
} }

View File

@ -1,5 +1,8 @@
use dioxus::prelude::*; 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::admin::{AdminPage, WritePage};
use crate::pages::archives::ArchivesPage; use crate::pages::archives::ArchivesPage;
use crate::pages::home::HomePage; use crate::pages::home::HomePage;
@ -9,6 +12,7 @@ use crate::pages::tags::{TagsPage, TagDetailPage};
use crate::theme::{Theme, ThemePreload, use_theme_provider}; use crate::theme::{Theme, ThemePreload, use_theme_provider};
#[derive(Clone, Routable, Debug, PartialEq)] #[derive(Clone, Routable, Debug, PartialEq)]
#[rustfmt::skip]
pub enum Route { pub enum Route {
#[route("/")] #[route("/")]
HomePage {}, HomePage {},
@ -16,10 +20,16 @@ pub enum Route {
LoginPage {}, LoginPage {},
#[route("/register")] #[route("/register")]
RegisterPage {}, RegisterPage {},
#[route("/admin")]
#[nest("/admin")]
#[layout(AdminLayout)]
#[route("/")]
AdminPage {}, AdminPage {},
#[route("/admin/write")] #[route("/write")]
WritePage {}, WritePage {},
#[end_layout]
#[end_nest]
#[route("/archives")] #[route("/archives")]
ArchivesPage {}, ArchivesPage {},
#[route("/tags")] #[route("/tags")]
@ -40,6 +50,10 @@ pub fn AppRouter() -> Element {
Theme::Light => "", Theme::Light => "",
}; };
let user = use_signal(|| None::<Arc<crate::models::user::User>>);
let checked = use_signal(|| false);
use_context_provider(|| UserContext { user, checked });
rsx! { rsx! {
div { div {
class: "{theme_class}", class: "{theme_class}",