From b6cabe489f2bd7b59a9e395de39b6fb8ff03d7e5 Mon Sep 17 00:00:00 2001 From: xfy Date: Mon, 1 Jun 2026 17:35:27 +0800 Subject: [PATCH] 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 --- src/components/admin_layout.rs | 5 + src/models/post.rs | 6 +- src/pages/admin/dashboard.rs | 83 ++++++++++--- src/pages/admin/mod.rs | 4 +- src/pages/admin/posts.rs | 150 ++++++++++++++++++++++++ src/pages/archives.rs | 135 ++++++++++++---------- src/pages/home.rs | 124 ++++++++++---------- src/pages/mod.rs | 3 +- src/pages/post_detail.rs | 116 +++++++++++++++++++ src/pages/tags.rs | 205 ++++++++++++++++++++------------- src/router.rs | 7 +- 11 files changed, 615 insertions(+), 223 deletions(-) create mode 100644 src/pages/admin/posts.rs create mode 100644 src/pages/post_detail.rs diff --git a/src/components/admin_layout.rs b/src/components/admin_layout.rs index 5b1e705..b205b9e 100644 --- a/src/components/admin_layout.rs +++ b/src/components/admin_layout.rs @@ -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: "前台", diff --git a/src/models/post.rs b/src/models/post.rs index 87ba72d..6d37c09 100644 --- a/src/models/post.rs +++ b/src/models/post.rs @@ -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, } -#[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, diff --git a/src/pages/admin/dashboard.rs b/src/pages/admin/dashboard.rs index 7f6d43d..c271cef 100644 --- a/src/pages/admin/dashboard.rs +++ b/src/pages/admin/dashboard.rs @@ -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}" } } } diff --git a/src/pages/admin/mod.rs b/src/pages/admin/mod.rs index a907d2d..7fb0366 100644 --- a/src/pages/admin/mod.rs +++ b/src/pages/admin/mod.rs @@ -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; \ No newline at end of file diff --git a/src/pages/admin/posts.rs b/src/pages/admin/posts.rs new file mode 100644 index 0000000..ec0b80d --- /dev/null +++ b/src/pages/admin/posts.rs @@ -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::); + + 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) -> 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 { "删除" } + } + } + } + } +} diff --git a/src/pages/archives.rs b/src/pages/archives.rs index 1d55f62..dede645 100644 --- a/src/pages/archives.rs +++ b/src/pages/archives.rs @@ -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, } #[derive(Clone, PartialEq)] struct MonthGroup { - month: &'static str, - month_en: &'static str, + month: String, + month_en: String, posts: Vec, } @@ -62,11 +24,16 @@ fn group_posts(posts: &[Post]) -> Vec { let mut years: Vec = 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 { } } 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 { 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 { #[component] pub fn Archives() -> Element { let route = use_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", diff --git a/src/pages/home.rs b/src/pages/home.rs index e3bc281..89135a5 100644 --- a/src/pages/home.rs +++ b/src/pages/home.rs @@ -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::(); + 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}" } } } } diff --git a/src/pages/mod.rs b/src/pages/mod.rs index cc8c1a8..78fe87a 100644 --- a/src/pages/mod.rs +++ b/src/pages/mod.rs @@ -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; \ No newline at end of file diff --git a/src/pages/post_detail.rs b/src/pages/post_detail.rs new file mode 100644 index 0000000..bad7ebc --- /dev/null +++ b/src/pages/post_detail.rs @@ -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::(); + 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 {} + } + } +} diff --git a/src/pages/tags.rs b/src/pages/tags.rs index fc63aec..06c8f51 100644 --- a/src/pages/tags.rs +++ b/src/pages/tags.rs @@ -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 { - 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 = 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 { - POSTS - .iter() - .filter(|p| p.tags.contains(&tag)) - .cloned() - .collect() -} - #[component] pub fn Tags() -> Element { let route = use_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::(), + _ => 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::(); + 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}" + } } } } diff --git a/src/router.rs b/src/router.rs index adddd91..0db76cc 100644 --- a/src/router.rs +++ b/src/router.rs @@ -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")]