feat: migrate frontend to database-driven posts

- Replace hardcoded POSTS with API-driven data in home, archives, tags
- Add post detail page /post/:slug with HTML rendering
- Add admin posts management page with list and soft delete
- Update dashboard with real stats from database
- Add admin navigation for posts management
- Fix PartialEq derives for Post, Tag, PostStats models
- Use use_resource and use_memo for data fetching with proper loading states
This commit is contained in:
xfy 2026-06-01 17:35:27 +08:00
parent df339cb084
commit b6cabe489f
11 changed files with 615 additions and 223 deletions

View File

@ -46,6 +46,11 @@ pub fn AdminLayout() -> Element {
label: "写文章",
is_active: matches!(route, Route::Write {}),
},
NavItemConfig {
href: "/admin/posts",
label: "管理文章",
is_active: matches!(route, Route::Posts {}),
},
NavItemConfig {
href: "/",
label: "前台",

View File

@ -26,7 +26,7 @@ impl PostStatus {
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Post {
pub id: i32,
pub author_id: i32,
@ -42,14 +42,14 @@ pub struct Post {
pub tags: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Tag {
pub id: i32,
pub name: String,
pub post_count: i64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct PostStats {
pub total: i64,
pub drafts: i64,

View File

@ -1,16 +1,36 @@
use dioxus::prelude::*;
use crate::pages::home::{Post, POSTS};
use crate::api::posts::{get_post_stats, list_posts, PostListResponse, PostStatsResponse};
use crate::models::post::Post;
#[component]
pub fn Admin() -> Element {
let stats_res = use_resource(get_post_stats);
let posts_res = use_resource(list_posts);
rsx! {
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: "已发布" }
match &*stats_res.read() {
Some(Ok(PostStatsResponse { stats })) => {
rsx! {
StatCard { value: stats.total.to_string(), label: "文章总数" }
StatCard { value: stats.drafts.to_string(), label: "草稿数" }
StatCard { value: stats.published.to_string(), label: "已发布" }
}
}
_ => {
rsx! {
for _ in 0..3 {
div { class: "rounded-xl bg-white dark:bg-[#2e2e33] border border-gray-200 dark:border-[#333] p-6 text-center space-y-3 animate-pulse",
div { class: "h-9 w-16 mx-auto bg-gray-200 dark:bg-[#2a2a2a] rounded" }
div { class: "h-4 w-20 mx-auto bg-gray-200 dark:bg-[#2a2a2a] rounded" }
}
}
}
}
}
}
// 快捷操作
@ -23,10 +43,9 @@ pub fn Admin() -> Element {
"写文章"
}
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",
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 cursor-pointer",
onclick: move |_| {
#[cfg(target_arch = "wasm32")]
web_sys::window().map(|w| w.alert_with_message("开发中").ok());
dioxus::router::navigator().push("/admin/posts");
},
"管理文章"
}
@ -37,9 +56,27 @@ pub fn Admin() -> Element {
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() }
match &*posts_res.read() {
Some(Ok(PostListResponse { posts })) => {
rsx! {
div { class: "space-y-0",
for post in posts.iter().take(5) {
RecentPostItem { post: post.clone() }
}
}
}
}
_ => {
rsx! {
div { class: "space-y-4 animate-pulse",
for _ in 0..5 {
div { class: "flex justify-between items-center py-3 border-b border-gray-100 dark:border-[#333]",
div { class: "h-4 w-[45%] bg-gray-200 dark:bg-[#2a2a2a] rounded" }
div { class: "h-3 w-20 bg-gray-200 dark:bg-[#2a2a2a] rounded" }
}
}
}
}
}
}
}
@ -63,13 +100,33 @@ fn StatCard(value: String, label: String) -> Element {
#[component]
fn RecentPostItem(post: Post) -> Element {
let date_str = post
.published_at
.map(|d| d.format("%Y-%m-%d").to_string())
.unwrap_or_else(|| post.created_at.format("%Y-%m-%d").to_string());
let status_label = if post.status == crate::models::post::PostStatus::Published {
"已发布"
} else {
"草稿"
};
let status_class = if post.status == crate::models::post::PostStatus::Published {
"text-green-600 dark:text-green-400"
} else {
"text-gray-400 dark:text-[#9b9c9d]"
};
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}"
div { class: "flex items-center gap-3",
span { class: "text-gray-700 dark:text-[#dadadb]",
"{post.title}"
}
span { class: "text-xs {status_class}",
"{status_label}"
}
}
span { class: "text-sm text-gray-400 dark:text-[#9b9c9d]",
"{post.date}"
"{date_str}"
}
}
}

View File

@ -1,5 +1,7 @@
pub mod dashboard;
pub mod posts;
pub mod write;
pub use dashboard::Admin;
pub use write::Write;
pub use posts::Posts;
pub use write::Write;

150
src/pages/admin/posts.rs Normal file
View File

@ -0,0 +1,150 @@
use dioxus::prelude::*;
use crate::api::posts::{delete_post, list_posts, CreatePostResponse, PostListResponse};
use crate::models::post::{Post, PostStatus};
#[component]
pub fn Posts() -> Element {
let mut posts_res = use_resource(list_posts);
let mut deleting = use_signal(|| None::<i32>);
rsx! {
div { class: "space-y-6",
div { class: "flex items-center justify-between",
h1 { class: "text-2xl font-bold text-gray-900 dark:text-[#dadadb]",
"文章管理"
}
button {
class: "px-4 py-2 bg-gray-900 dark:bg-[#dadadb] text-white dark:text-gray-900 rounded-full text-sm font-medium hover:opacity-80 transition-opacity cursor-pointer",
onclick: move |_| {
dioxus::router::navigator().push("/admin/write");
},
"+ 写文章"
}
}
match &*posts_res.read() {
Some(Ok(PostListResponse { posts })) => {
if posts.is_empty() {
rsx! {
div { class: "text-center py-20 text-gray-500 dark:text-[#9b9c9d]",
"暂无文章"
}
}
} else {
rsx! {
div { class: "bg-white dark:bg-[#2e2e33] rounded-xl border border-gray-200 dark:border-[#333] overflow-hidden",
table { class: "w-full text-sm",
thead {
tr { class: "border-b border-gray-200 dark:border-[#333] text-left text-gray-500 dark:text-[#9b9c9d]",
th { class: "px-4 py-3 font-medium", "标题" }
th { class: "px-4 py-3 font-medium w-24", "状态" }
th { class: "px-4 py-3 font-medium w-32", "日期" }
th { class: "px-4 py-3 font-medium w-24 text-right", "操作" }
}
}
tbody {
for post in posts.iter() {
PostRow {
post: post.clone(),
deleting: deleting() == Some(post.id),
on_delete: move |id| {
deleting.set(Some(id));
spawn(async move {
match delete_post(id).await {
Ok(CreatePostResponse { success: true, .. }) => {
posts_res.restart();
}
Ok(CreatePostResponse { success: false, message, .. }) => {
#[cfg(target_arch = "wasm32")]
web_sys::window().map(|w| w.alert_with_message(&message).ok());
}
Err(e) => {
#[cfg(target_arch = "wasm32")]
web_sys::window().map(|w| w.alert_with_message(&format!("删除失败: {}", e)).ok());
}
}
deleting.set(None);
});
}
}
}
}
}
}
}
}
}
Some(Err(e)) => {
rsx! {
div { class: "text-center text-red-500 dark:text-red-400 py-20",
"加载失败: {e}"
}
}
}
None => {
rsx! {
div { class: "bg-white dark:bg-[#2e2e33] rounded-xl border border-gray-200 dark:border-[#333] animate-pulse",
for _ in 0..5 {
div { class: "flex items-center px-4 py-3 border-b border-gray-100 dark:border-[#333] last:border-0",
div { class: "h-4 w-1/3 bg-gray-200 dark:bg-[#2a2a2a] rounded" }
div { class: "ml-auto h-4 w-16 bg-gray-200 dark:bg-[#2a2a2a] rounded" }
}
}
}
}
}
}
}
}
}
#[component]
fn PostRow(post: Post, deleting: bool, on_delete: EventHandler<i32>) -> Element {
let date_str = post
.published_at
.map(|d| d.format("%Y-%m-%d").to_string())
.unwrap_or_else(|| post.created_at.format("%Y-%m-%d").to_string());
let (status_label, status_class) = if post.status == PostStatus::Published {
("已发布", "bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300")
} else {
("草稿", "bg-gray-100 dark:bg-[#333] text-gray-600 dark:text-[#9b9c9d]")
};
rsx! {
tr { class: "border-b border-gray-100 dark:border-[#333] last:border-0 hover:bg-gray-50 dark:hover:bg-[#2a2a2a] transition-colors",
td { class: "px-4 py-3",
a {
class: "text-gray-900 dark:text-[#dadadb] hover:opacity-80 transition-opacity",
href: "/post/{post.slug}",
onclick: move |evt| {
evt.prevent_default();
dioxus::router::navigator().push(format!("/post/{}", post.slug).as_str());
},
"{post.title}"
}
}
td { class: "px-4 py-3",
span { class: "inline-flex items-center px-2 py-0.5 rounded text-xs font-medium {status_class}",
"{status_label}"
}
}
td { class: "px-4 py-3 text-gray-500 dark:text-[#9b9c9d]",
"{date_str}"
}
td { class: "px-4 py-3 text-right",
button {
class: if deleting {
"text-xs text-gray-400 cursor-not-allowed"
} else {
"text-xs text-red-500 hover:text-red-700 dark:hover:text-red-300 transition-colors cursor-pointer"
},
disabled: deleting,
onclick: move |_| on_delete.call(post.id),
if deleting { "删除中..." } else { "删除" }
}
}
}
}
}

View File

@ -1,60 +1,22 @@
use dioxus::prelude::*;
use crate::api::posts::{list_published_posts, PostListResponse};
use crate::components::header::{Header, NavItemConfig};
use crate::components::footer::Footer;
use crate::models::post::Post;
use crate::router::Route;
use crate::theme::ThemeToggle;
#[derive(Clone, PartialEq)]
pub struct Post {
pub title: &'static str,
pub date: &'static str,
pub slug: &'static str,
}
const POSTS: &[Post] = &[
Post {
title: "开始使用 Rust 构建 Web 应用",
date: "2026-05-20",
slug: "rust-web-app",
},
Post {
title: "Tailwind CSS 的设计理念与实践",
date: "2026-05-15",
slug: "tailwind-css",
},
Post {
title: "PostgreSQL 在 Rust 项目中的最佳实践",
date: "2026-05-10",
slug: "postgresql-rust",
},
Post {
title: "暗色模式的设计思考",
date: "2026-05-05",
slug: "dark-mode-design",
},
Post {
title: "博客系统的架构演进",
date: "2026-04-28",
slug: "blog-architecture",
},
Post {
title: "Dioxus 0.7 新特性一览",
date: "2026-04-20",
slug: "dioxus-07",
},
];
#[derive(Clone, PartialEq)]
struct YearGroup {
year: &'static str,
year: String,
months: Vec<MonthGroup>,
}
#[derive(Clone, PartialEq)]
struct MonthGroup {
month: &'static str,
month_en: &'static str,
month: String,
month_en: String,
posts: Vec<Post>,
}
@ -62,11 +24,16 @@ fn group_posts(posts: &[Post]) -> Vec<YearGroup> {
let mut years: Vec<YearGroup> = vec![];
for post in posts {
let parts: Vec<&str> = post.date.split('-').collect();
let date_str = post
.published_at
.map(|d| d.format("%Y-%m-%d").to_string())
.unwrap_or_else(|| post.created_at.format("%Y-%m-%d").to_string());
let parts: Vec<&str> = date_str.split('-').collect();
if parts.len() != 3 {
continue;
}
let year = parts[0];
let year = parts[0].to_string();
let month_num = parts[1];
let month_en = match month_num {
"01" => "January",
@ -93,8 +60,8 @@ fn group_posts(posts: &[Post]) -> Vec<YearGroup> {
}
}
yg.months.push(MonthGroup {
month: month_en,
month_en,
month: month_en.to_string(),
month_en: month_en.to_string(),
posts: vec![post.clone()],
});
continue;
@ -103,8 +70,8 @@ fn group_posts(posts: &[Post]) -> Vec<YearGroup> {
years.push(YearGroup {
year,
months: vec![MonthGroup {
month: month_en,
month_en,
month: month_en.to_string(),
month_en: month_en.to_string(),
posts: vec![post.clone()],
}],
});
@ -116,6 +83,8 @@ fn group_posts(posts: &[Post]) -> Vec<YearGroup> {
#[component]
pub fn Archives() -> Element {
let route = use_route::<Route>();
let posts_res = use_resource(list_published_posts);
let nav_items = vec![
NavItemConfig { href: "/", label: "首页", is_active: matches!(route, Route::Home {}) },
NavItemConfig { href: "/archives", label: "归档", is_active: matches!(route, Route::Archives {}) },
@ -124,8 +93,6 @@ pub fn Archives() -> Element {
NavItemConfig { href: "/about", label: "关于", is_active: matches!(route, Route::About {}) },
];
let grouped = group_posts(POSTS);
rsx! {
div { class: "min-h-screen flex flex-col bg-white dark:bg-[#1d1e20] transition-colors duration-300",
Header { nav_items, right_content: rsx! { ThemeToggle {} } }
@ -134,14 +101,57 @@ pub fn Archives() -> Element {
h1 { class: "text-[34px] font-bold text-gray-900 dark:text-[#dadadb]",
"归档"
}
div { class: "mt-2 text-base text-gray-500 dark:text-[#9b9c9d]",
""
span { class: "font-medium text-gray-700 dark:text-[#dadadb]", "{POSTS.len()}" }
" 篇文章"
match &*posts_res.read() {
Some(Ok(PostListResponse { posts })) => {
rsx! {
div { class: "mt-2 text-base text-gray-500 dark:text-[#9b9c9d]",
""
span { class: "font-medium text-gray-700 dark:text-[#dadadb]", "{posts.len()}" }
" 篇文章"
}
}
}
_ => {
rsx! {
div { class: "mt-2 text-base text-gray-500 dark:text-[#9b9c9d]",
"加载中..."
}
}
}
}
}
for year_group in grouped.iter() {
YearSection { year_group: year_group.clone() }
match &*posts_res.read() {
Some(Ok(PostListResponse { posts })) => {
let grouped = group_posts(posts);
rsx! {
for year_group in grouped.iter() {
YearSection { year_group: year_group.clone() }
}
}
}
Some(Err(e)) => {
rsx! {
div { class: "text-center text-red-500 dark:text-red-400 py-20",
"加载失败: {e}"
}
}
}
None => {
rsx! {
div { class: "space-y-8 animate-pulse",
for _ in 0..2 {
div { class: "space-y-4",
div { class: "h-8 w-20 bg-gray-200 dark:bg-[#2a2a2a] rounded" }
div { class: "space-y-2",
for _ in 0..3 {
div { class: "h-4 w-full bg-gray-200 dark:bg-[#2a2a2a] rounded" }
}
}
}
}
}
}
}
}
}
Footer {}
@ -166,14 +176,14 @@ fn YearSection(year_group: YearGroup) -> Element {
sup { class: "archive-count text-sm text-gray-400 dark:text-[#9b9c9d] ml-1", "{total}" }
}
for month_group in year_group.months.iter() {
MonthSection { month_group: month_group.clone(), year: year_group.year }
MonthSection { month_group: month_group.clone(), year: year_group.year.clone() }
}
}
}
}
#[component]
fn MonthSection(month_group: MonthGroup, year: &'static str) -> Element {
fn MonthSection(month_group: MonthGroup, year: String) -> Element {
let count = month_group.posts.len();
rsx! {
@ -199,13 +209,18 @@ fn MonthSection(month_group: MonthGroup, year: &'static str) -> Element {
#[component]
fn ArchiveEntry(post: Post) -> Element {
let date_str = post
.published_at
.map(|d| d.format("%Y-%m-%d").to_string())
.unwrap_or_else(|| post.created_at.format("%Y-%m-%d").to_string());
rsx! {
div { class: "archive-entry relative py-1.5 my-2.5 group",
h3 { class: "archive-entry-title text-base font-normal text-gray-900 dark:text-[#dadadb] m-0",
"{post.title}"
}
div { class: "archive-meta text-sm text-gray-400 dark:text-[#9b9c9d] mt-1",
"{post.date}"
"{date_str}"
}
a {
class: "entry-link absolute inset-0 z-10",

View File

@ -1,67 +1,17 @@
use dioxus::prelude::*;
use crate::api::posts::{list_published_posts, PostListResponse};
use crate::components::header::{Header, NavItemConfig};
use crate::components::footer::Footer;
use crate::models::post::Post;
use crate::router::Route;
use crate::theme::ThemeToggle;
#[derive(Clone, PartialEq)]
pub struct Post {
pub title: &'static str,
pub summary: &'static str,
pub date: &'static str,
pub tags: &'static [&'static str],
pub slug: &'static str,
}
pub const POSTS: &[Post] = &[
Post {
title: "开始使用 Rust 构建 Web 应用",
summary: "Rust 作为一门系统级编程语言,近年来在 Web 开发领域也展现出了强大的生命力。本文将介绍如何使用 Rust 和 Dioxus 框架构建现代化的全栈 Web 应用,从项目搭建到部署的完整流程。",
date: "2026-05-20",
tags: &["Rust", "Web"],
slug: "rust-web-app",
},
Post {
title: "Tailwind CSS 的设计理念与实践",
summary: "Tailwind CSS 是一种实用优先的 CSS 框架,它改变了我们编写样式的方式。通过原子化的工具类,开发者可以快速构建出美观且一致的界面,而无需在 CSS 文件和 HTML 之间来回切换。",
date: "2026-05-15",
tags: &["CSS", "前端"],
slug: "tailwind-css",
},
Post {
title: "PostgreSQL 在 Rust 项目中的最佳实践",
summary: "数据库是大多数 Web 应用的核心组件。本文探讨如何在 Rust 项目中高效地使用 PostgreSQL包括连接池管理、异步查询、事务处理以及常见的性能优化技巧。",
date: "2026-05-10",
tags: &["数据库", "Rust"],
slug: "postgresql-rust",
},
Post {
title: "暗色模式的设计思考",
summary: "暗色模式不仅仅是颜色的反转,它涉及到一整套设计系统的重新思考。从对比度到语义化颜色,暗色模式需要细致的打磨才能提供舒适的阅读体验。",
date: "2026-05-05",
tags: &["设计", "UI"],
slug: "dark-mode-design",
},
Post {
title: "博客系统的架构演进",
summary: "从一个简单的静态页面到全栈应用,博客系统的架构经历了多次演进。本文记录了 Yggdrasil 博客从设计到实现的思考过程,以及每次迭代背后的决策依据。",
date: "2026-04-28",
tags: &["架构", "博客"],
slug: "blog-architecture",
},
Post {
title: "Dioxus 0.7 新特性一览",
summary: "Dioxus 0.7 带来了许多令人兴奋的改进,包括更好的全栈支持、改进的路由系统和更流畅的开发体验。让我们一起看看这些新特性如何提升开发效率。",
date: "2026-04-20",
tags: &["Rust", "框架"],
slug: "dioxus-07",
},
];
#[component]
pub fn Home() -> Element {
let route = use_route::<Route>();
let posts_res = use_resource(list_published_posts);
let nav_items = vec![
NavItemConfig { href: "/", label: "首页", is_active: matches!(route, Route::Home {}) },
NavItemConfig { href: "/archives", label: "归档", is_active: matches!(route, Route::Archives {}) },
@ -75,8 +25,39 @@ pub fn Home() -> Element {
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() {
PostEntry { post: post.clone() }
match &*posts_res.read() {
Some(Ok(PostListResponse { posts })) => {
rsx! {
for post in posts.iter() {
PostEntry { post: post.clone() }
}
if posts.is_empty() {
div { class: "text-center text-gray-500 dark:text-[#9b9c9d] py-20",
"暂无文章"
}
}
}
}
Some(Err(e)) => {
rsx! {
div { class: "text-center text-red-500 dark:text-red-400 py-20",
"加载失败: {e}"
}
}
}
None => {
rsx! {
div { class: "space-y-6 py-4",
for _ in 0..3 {
div { class: "mb-6 p-6 bg-white dark:bg-[#2e2e33] rounded-lg border border-gray-200 dark:border-[#333] animate-pulse",
div { class: "h-7 w-3/4 bg-gray-200 dark:bg-[#2a2a2a] rounded mb-3" }
div { class: "h-4 w-full bg-gray-200 dark:bg-[#2a2a2a] rounded mb-2" }
div { class: "h-4 w-2/3 bg-gray-200 dark:bg-[#2a2a2a] rounded" }
}
}
}
}
}
}
Pagination {}
}
@ -101,9 +82,12 @@ fn HomeInfo() -> Element {
#[component]
fn PostEntry(post: Post) -> Element {
let tag_items = post.tags.to_vec();
let post_slug = post.slug.clone();
let date_str = post
.published_at
.map(|d| d.format("%Y-%m-%d").to_string())
.unwrap_or_else(|| post.created_at.format("%Y-%m-%d").to_string());
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 {
@ -117,16 +101,26 @@ fn PostEntry(post: Post) -> Element {
"{post.title}"
}
div { class: "mt-2 text-sm text-gray-500 dark:text-[#9b9c9d] leading-relaxed line-clamp-2",
"{post.summary}"
"{post.summary.as_deref().unwrap_or(\"\")}"
}
div { class: "mt-3 flex items-center gap-3 text-[13px] text-gray-400 dark:text-[#9b9c9d]",
span { "{post.date}" }
span { "·" }
for (i, tag) in tag_items.iter().enumerate() {
if i > 0 {
span { "," }
span { "{date_str}" }
if !post.tags.is_empty() {
span { "·" }
for tag in post.tags.clone().into_iter() {
span {
a {
class: "hover:text-gray-600 dark:hover:text-[#dadadb] transition-colors",
href: "/tags/{tag}",
onclick: move |evt| {
evt.prevent_default();
evt.stop_propagation();
dioxus::router::navigator().push(format!("/tags/{}", tag).as_str());
},
"{tag}"
}
}
}
span { "{tag}" }
}
}
}

View File

@ -2,5 +2,6 @@ pub mod admin;
pub mod archives;
pub mod home;
pub mod login;
pub mod post_detail;
pub mod register;
pub mod tags;
pub mod tags;

116
src/pages/post_detail.rs Normal file
View File

@ -0,0 +1,116 @@
use dioxus::prelude::*;
use crate::api::posts::{get_post_by_slug, SinglePostResponse};
use crate::components::header::{Header, NavItemConfig};
use crate::components::footer::Footer;
use crate::router::Route;
use crate::theme::ThemeToggle;
#[component]
pub fn PostDetail(slug: String) -> Element {
let route = use_route::<Route>();
let slug_clone = slug.clone();
let post_res = use_resource(move || get_post_by_slug(slug_clone.clone()));
let nav_items = vec![
NavItemConfig { href: "/", label: "首页", is_active: matches!(route, Route::Home {}) },
NavItemConfig { href: "/archives", label: "归档", is_active: matches!(route, Route::Archives {}) },
NavItemConfig { href: "/tags", label: "标签", is_active: matches!(route, Route::Tags {}) || matches!(route, Route::TagDetail { .. }) },
NavItemConfig { href: "/search", label: "搜索", is_active: matches!(route, Route::Search {}) },
NavItemConfig { href: "/about", label: "关于", is_active: matches!(route, Route::About {}) },
];
rsx! {
div { class: "min-h-screen flex flex-col bg-white dark:bg-[#1d1e20] transition-colors duration-300",
Header { nav_items, right_content: rsx! { ThemeToggle {} } }
main { class: "flex-1 w-full max-w-3xl mx-auto px-6 py-6",
match &*post_res.read() {
Some(Ok(SinglePostResponse { post: Some(post) })) => {
let date_str = post
.published_at
.map(|d| d.format("%Y-%m-%d").to_string())
.unwrap_or_else(|| post.created_at.format("%Y-%m-%d").to_string());
rsx! {
article { class: "py-6",
header { class: "mb-8",
h1 { class: "text-3xl md:text-4xl font-bold text-gray-900 dark:text-[#dadadb] leading-tight",
"{post.title}"
}
div { class: "mt-4 flex items-center gap-3 text-sm text-gray-500 dark:text-[#9b9c9d]",
span { "{date_str}" }
if !post.tags.is_empty() {
span { "·" }
for tag in post.tags.clone().into_iter() {
a {
class: "hover:text-gray-700 dark:hover:text-[#dadadb] transition-colors",
href: "/tags/{tag}",
onclick: move |evt| {
evt.prevent_default();
dioxus::router::navigator().push(format!("/tags/{}", tag).as_str());
},
"{tag}"
}
}
}
}
}
div {
class: "prose dark:prose-invert max-w-none text-gray-800 dark:text-[#c9cacc] leading-relaxed",
dangerous_inner_html: "{post.content_html.as_deref().unwrap_or(\"\")}"
}
div { class: "mt-12 pt-6 border-t border-gray-200 dark:border-[#333]",
button {
class: "text-sm text-gray-500 dark:text-[#9b9c9d] hover:text-gray-700 dark:hover:text-[#dadadb] transition-colors",
onclick: move |_| {
let _ = dioxus::router::navigator().push("/");
},
"← 返回首页"
}
}
}
}
}
Some(Ok(SinglePostResponse { post: None })) => {
rsx! {
div { class: "text-center py-20",
h2 { class: "text-2xl font-bold text-gray-900 dark:text-[#dadadb] mb-4",
"文章不存在"
}
p { class: "text-gray-500 dark:text-[#9b9c9d] mb-6",
"这篇文章可能已被删除或移动。"
}
button {
class: "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 _ = dioxus::router::navigator().push("/");
},
"返回首页"
}
}
}
}
Some(Err(e)) => {
rsx! {
div { class: "text-center text-red-500 dark:text-red-400 py-20",
"加载失败: {e}"
}
}
}
None => {
rsx! {
div { class: "animate-pulse py-6 space-y-4",
div { class: "h-10 w-3/4 bg-gray-200 dark:bg-[#2a2a2a] rounded" }
div { class: "h-4 w-32 bg-gray-200 dark:bg-[#2a2a2a] rounded" }
div { class: "h-4 w-full bg-gray-200 dark:bg-[#2a2a2a] rounded mt-8" }
div { class: "h-4 w-full bg-gray-200 dark:bg-[#2a2a2a] rounded" }
div { class: "h-4 w-2/3 bg-gray-200 dark:bg-[#2a2a2a] rounded" }
}
}
}
}
}
Footer {}
}
}
}

View File

@ -1,47 +1,17 @@
use dioxus::prelude::*;
use crate::api::posts::{get_posts_by_tag, list_tags, PostListResponse, TagListResponse};
use crate::components::header::{Header, NavItemConfig};
use crate::components::footer::Footer;
use crate::pages::home::{Post, POSTS};
use crate::models::post::Post;
use crate::router::Route;
use crate::theme::ThemeToggle;
#[derive(Clone, PartialEq)]
struct TagInfo {
name: &'static str,
count: usize,
}
fn collect_tags() -> Vec<TagInfo> {
use std::collections::HashMap;
let mut counts: HashMap<&'static str, usize> = HashMap::new();
for post in POSTS.iter() {
for tag in post.tags.iter() {
*counts.entry(*tag).or_insert(0) += 1;
}
}
let mut tags: Vec<TagInfo> = counts
.into_iter()
.map(|(name, count)| TagInfo { name, count })
.collect();
tags.sort_by(|a, b| a.name.cmp(b.name));
tags
}
fn posts_for_tag(tag: &str) -> Vec<Post> {
POSTS
.iter()
.filter(|p| p.tags.contains(&tag))
.cloned()
.collect()
}
#[component]
pub fn Tags() -> Element {
let route = use_route::<Route>();
let tags_res = use_resource(list_tags);
let nav_items = vec![
NavItemConfig { href: "/", label: "首页", is_active: matches!(route, Route::Home {}) },
NavItemConfig { href: "/archives", label: "归档", is_active: matches!(route, Route::Archives {}) },
@ -50,8 +20,23 @@ pub fn Tags() -> Element {
NavItemConfig { href: "/about", label: "关于", is_active: matches!(route, Route::About {}) },
];
let tags = collect_tags();
let total_posts = POSTS.len();
let tags_data = use_memo(move || {
match &*tags_res.read() {
Some(Ok(TagListResponse { tags })) => Some(tags.clone()),
_ => None,
}
});
let total_posts = use_memo(move || {
match &*tags_res.read() {
Some(Ok(TagListResponse { tags })) => tags.iter().map(|t| t.post_count).sum::<i64>(),
_ => 0,
}
});
let has_error = use_memo(move || {
matches!(&*tags_res.read(), Some(Err(_)))
});
rsx! {
div { class: "min-h-screen flex flex-col bg-white dark:bg-[#1d1e20] transition-colors duration-300",
@ -61,29 +46,47 @@ pub fn Tags() -> Element {
h1 { class: "text-[34px] font-bold text-gray-900 dark:text-[#dadadb]",
"标签"
}
div { class: "mt-2 text-base text-gray-500 dark:text-[#9b9c9d]",
""
span { class: "font-medium text-gray-700 dark:text-[#dadadb]", "{tags.len()}" }
" 个标签,"
span { class: "font-medium text-gray-700 dark:text-[#dadadb]", "{total_posts}" }
" 篇文章"
if tags_data().is_some() {
div { class: "mt-2 text-base text-gray-500 dark:text-[#9b9c9d]",
""
span { class: "font-medium text-gray-700 dark:text-[#dadadb]", "{tags_data().unwrap().len()}" }
" 个标签,"
span { class: "font-medium text-gray-700 dark:text-[#dadadb]", "{total_posts()}" }
" 篇文章"
}
} else {
div { class: "mt-2 text-base text-gray-500 dark:text-[#9b9c9d]",
"加载中..."
}
}
}
ul { class: "flex flex-wrap gap-4 mt-6",
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/{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}" }
if has_error() {
div { class: "text-center text-red-500 dark:text-red-400 py-20",
"加载失败"
}
} else if tags_data().is_some() {
ul { class: "flex flex-wrap gap-4 mt-6",
for tag in tags_data().unwrap().into_iter() {
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}",
onclick: move |evt| {
evt.prevent_default();
dioxus::router::navigator().push(format!("/tags/{}", tag.name).as_str());
},
"{tag.name}"
sup { class: "ml-1 text-sm text-gray-500 dark:text-[#9b9c9d]", "{tag.post_count}" }
}
}
}
}
} else {
div { class: "flex flex-wrap gap-4 mt-6 animate-pulse",
for _ in 0..8 {
div { class: "h-8 w-16 bg-gray-200 dark:bg-[#2a2a2a] rounded-lg" }
}
}
}
}
Footer {}
@ -94,6 +97,9 @@ pub fn Tags() -> Element {
#[component]
pub fn TagDetail(tag: String) -> Element {
let route = use_route::<Route>();
let tag_clone = tag.clone();
let posts_res = use_resource(move || get_posts_by_tag(tag_clone.clone()));
let nav_items = vec![
NavItemConfig { href: "/", label: "首页", is_active: matches!(route, Route::Home {}) },
NavItemConfig { href: "/archives", label: "归档", is_active: matches!(route, Route::Archives {}) },
@ -102,7 +108,23 @@ pub fn TagDetail(tag: String) -> Element {
NavItemConfig { href: "/about", label: "关于", is_active: matches!(route, Route::About {}) },
];
let posts = posts_for_tag(&tag);
let posts_data = use_memo(move || {
match &*posts_res.read() {
Some(Ok(PostListResponse { posts })) => Some(posts.clone()),
_ => None,
}
});
let post_count = use_memo(move || {
match &*posts_res.read() {
Some(Ok(PostListResponse { posts })) => posts.len(),
_ => 0,
}
});
let has_error = use_memo(move || {
matches!(&*posts_res.read(), Some(Err(_)))
});
rsx! {
div { class: "min-h-screen flex flex-col bg-white dark:bg-[#1d1e20] transition-colors duration-300",
@ -112,14 +134,36 @@ pub fn TagDetail(tag: String) -> Element {
h1 { class: "text-[34px] font-bold text-gray-900 dark:text-[#dadadb]",
"{tag}"
}
div { class: "mt-2 text-base text-gray-500 dark:text-[#9b9c9d]",
""
span { class: "font-medium text-gray-700 dark:text-[#dadadb]", "{posts.len()}" }
" 篇文章"
if post_count() > 0 || has_error() {
div { class: "mt-2 text-base text-gray-500 dark:text-[#9b9c9d]",
""
span { class: "font-medium text-gray-700 dark:text-[#dadadb]", "{post_count()}" }
" 篇文章"
}
} else {
div { class: "mt-2 text-base text-gray-500 dark:text-[#9b9c9d]",
"加载中..."
}
}
}
for post in posts.iter() {
TagPostEntry { post: post.clone() }
if has_error() {
div { class: "text-center text-red-500 dark:text-red-400 py-20",
"加载失败"
}
} else if posts_data().is_some() {
for post in posts_data().unwrap().into_iter() {
TagPostEntry { post }
}
} else {
div { class: "space-y-6 py-4 animate-pulse",
for _ in 0..3 {
div { class: "mb-6 p-6 bg-white dark:bg-[#2e2e33] rounded-lg border border-gray-200 dark:border-[#333]",
div { class: "h-7 w-3/4 bg-gray-200 dark:bg-[#2a2a2a] rounded mb-3" }
div { class: "h-4 w-full bg-gray-200 dark:bg-[#2a2a2a] rounded mb-2" }
div { class: "h-4 w-2/3 bg-gray-200 dark:bg-[#2a2a2a] rounded" }
}
}
}
}
}
Footer {}
@ -129,8 +173,11 @@ pub fn TagDetail(tag: String) -> Element {
#[component]
fn TagPostEntry(post: Post) -> Element {
let tag_items = post.tags.to_vec();
let post_slug = post.slug;
let post_slug = post.slug.clone();
let date_str = post
.published_at
.map(|d| d.format("%Y-%m-%d").to_string())
.unwrap_or_else(|| post.created_at.format("%Y-%m-%d").to_string());
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",
@ -145,24 +192,24 @@ fn TagPostEntry(post: Post) -> Element {
"{post.title}"
}
div { class: "mt-2 text-sm text-gray-500 dark:text-[#9b9c9d] leading-relaxed line-clamp-2",
"{post.summary}"
"{post.summary.as_deref().unwrap_or(\"\")}"
}
div { class: "mt-3 flex items-center gap-3 text-[13px] text-gray-400 dark:text-[#9b9c9d]",
span { "{post.date}" }
span { "·" }
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/{tag_name}",
onclick: move |evt| {
evt.prevent_default();
dioxus::router::navigator().push(format!("/tags/{}", tag_name).as_str());
},
"{tag_name}"
span { "{date_str}" }
if !post.tags.is_empty() {
span { "·" }
for tag in post.tags.clone().into_iter() {
span {
a {
class: "hover:text-gray-600 dark:hover:text-[#dadadb] transition-colors",
href: "/tags/{tag}",
onclick: move |evt| {
evt.prevent_default();
evt.stop_propagation();
dioxus::router::navigator().push(format!("/tags/{}", tag).as_str());
},
"{tag}"
}
}
}
}

View File

@ -3,10 +3,11 @@ use std::sync::Arc;
use crate::components::admin_layout::AdminLayout;
use crate::context::UserContext;
use crate::pages::admin::{Admin, Write};
use crate::pages::admin::{Admin, Posts, Write};
use crate::pages::archives::Archives;
use crate::pages::home::Home;
use crate::pages::login::Login;
use crate::pages::post_detail::PostDetail;
use crate::pages::register::Register;
use crate::pages::tags::{TagDetail, Tags};
use crate::theme::{use_theme_provider, Theme, ThemePreload};
@ -27,6 +28,8 @@ pub enum Route {
Admin {},
#[route("/write")]
Write {},
#[route("/posts")]
Posts {},
#[end_layout]
#[end_nest]
@ -36,6 +39,8 @@ pub enum Route {
Tags {},
#[route("/tags/:tag")]
TagDetail { tag: String },
#[route("/post/:slug")]
PostDetail { slug: String },
#[route("/search")]
Search {},
#[route("/about")]