修复 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::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::<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::<Route> {}
}
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]", "加载中..." }

View File

@ -15,6 +15,10 @@ pub fn Header(nav_items: Vec<NavItemConfig>, 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}"
}
}
}
}

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 auth;
mod components;
mod context;
mod db;
mod models;
mod pages;

View File

@ -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() }
}
}
}

View File

@ -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());
}
},
"保存草稿"
}
}
}

View File

@ -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());
},
}
}
}

View File

@ -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", "»" }
}

View File

@ -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"); },
"还没有账号?去注册"
}
}

View File

@ -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"); },
"去登录"
}
}

View File

@ -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}"
}
}
}

View File

@ -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::<Arc<crate::models::user::User>>);
let checked = use_signal(|| false);
use_context_provider(|| UserContext { user, checked });
rsx! {
div {
class: "{theme_class}",