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:
parent
df339cb084
commit
b6cabe489f
@ -46,6 +46,11 @@ pub fn AdminLayout() -> Element {
|
|||||||
label: "写文章",
|
label: "写文章",
|
||||||
is_active: matches!(route, Route::Write {}),
|
is_active: matches!(route, Route::Write {}),
|
||||||
},
|
},
|
||||||
|
NavItemConfig {
|
||||||
|
href: "/admin/posts",
|
||||||
|
label: "管理文章",
|
||||||
|
is_active: matches!(route, Route::Posts {}),
|
||||||
|
},
|
||||||
NavItemConfig {
|
NavItemConfig {
|
||||||
href: "/",
|
href: "/",
|
||||||
label: "前台",
|
label: "前台",
|
||||||
|
|||||||
@ -26,7 +26,7 @@ impl PostStatus {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
pub struct Post {
|
pub struct Post {
|
||||||
pub id: i32,
|
pub id: i32,
|
||||||
pub author_id: i32,
|
pub author_id: i32,
|
||||||
@ -42,14 +42,14 @@ pub struct Post {
|
|||||||
pub tags: Vec<String>,
|
pub tags: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
pub struct Tag {
|
pub struct Tag {
|
||||||
pub id: i32,
|
pub id: i32,
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub post_count: i64,
|
pub post_count: i64,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
pub struct PostStats {
|
pub struct PostStats {
|
||||||
pub total: i64,
|
pub total: i64,
|
||||||
pub drafts: i64,
|
pub drafts: i64,
|
||||||
|
|||||||
@ -1,16 +1,36 @@
|
|||||||
use dioxus::prelude::*;
|
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]
|
#[component]
|
||||||
pub fn Admin() -> Element {
|
pub fn Admin() -> Element {
|
||||||
|
let stats_res = use_resource(get_post_stats);
|
||||||
|
let posts_res = use_resource(list_posts);
|
||||||
|
|
||||||
rsx! {
|
rsx! {
|
||||||
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",
|
||||||
StatCard { value: POSTS.len().to_string(), label: "文章总数" }
|
match &*stats_res.read() {
|
||||||
StatCard { value: "0".to_string(), label: "草稿数" }
|
Some(Ok(PostStatsResponse { stats })) => {
|
||||||
StatCard { value: POSTS.len().to_string(), label: "已发布" }
|
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 {
|
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 |_| {
|
onclick: move |_| {
|
||||||
#[cfg(target_arch = "wasm32")]
|
dioxus::router::navigator().push("/admin/posts");
|
||||||
web_sys::window().map(|w| w.alert_with_message("开发中").ok());
|
|
||||||
},
|
},
|
||||||
"管理文章"
|
"管理文章"
|
||||||
}
|
}
|
||||||
@ -37,9 +56,27 @@ pub fn Admin() -> Element {
|
|||||||
h2 { class: "text-xl font-bold text-gray-900 dark:text-[#dadadb] mb-4",
|
h2 { class: "text-xl font-bold text-gray-900 dark:text-[#dadadb] mb-4",
|
||||||
"最近文章"
|
"最近文章"
|
||||||
}
|
}
|
||||||
div { class: "space-y-0",
|
match &*posts_res.read() {
|
||||||
for post in POSTS.iter().take(5) {
|
Some(Ok(PostListResponse { posts })) => {
|
||||||
RecentPostItem { post: post.clone() }
|
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]
|
#[component]
|
||||||
fn RecentPostItem(post: Post) -> Element {
|
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! {
|
rsx! {
|
||||||
div { class: "flex justify-between items-center py-3 border-b border-gray-100 dark:border-[#333]",
|
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]",
|
div { class: "flex items-center gap-3",
|
||||||
"{post.title}"
|
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]",
|
span { class: "text-sm text-gray-400 dark:text-[#9b9c9d]",
|
||||||
"{post.date}"
|
"{date_str}"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
pub mod dashboard;
|
pub mod dashboard;
|
||||||
|
pub mod posts;
|
||||||
pub mod write;
|
pub mod write;
|
||||||
|
|
||||||
pub use dashboard::Admin;
|
pub use dashboard::Admin;
|
||||||
|
pub use posts::Posts;
|
||||||
pub use write::Write;
|
pub use write::Write;
|
||||||
150
src/pages/admin/posts.rs
Normal file
150
src/pages/admin/posts.rs
Normal 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 { "删除" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,60 +1,22 @@
|
|||||||
use dioxus::prelude::*;
|
use dioxus::prelude::*;
|
||||||
|
|
||||||
|
use crate::api::posts::{list_published_posts, PostListResponse};
|
||||||
use crate::components::header::{Header, NavItemConfig};
|
use crate::components::header::{Header, NavItemConfig};
|
||||||
use crate::components::footer::Footer;
|
use crate::components::footer::Footer;
|
||||||
|
use crate::models::post::Post;
|
||||||
use crate::router::Route;
|
use crate::router::Route;
|
||||||
use crate::theme::ThemeToggle;
|
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)]
|
#[derive(Clone, PartialEq)]
|
||||||
struct YearGroup {
|
struct YearGroup {
|
||||||
year: &'static str,
|
year: String,
|
||||||
months: Vec<MonthGroup>,
|
months: Vec<MonthGroup>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, PartialEq)]
|
#[derive(Clone, PartialEq)]
|
||||||
struct MonthGroup {
|
struct MonthGroup {
|
||||||
month: &'static str,
|
month: String,
|
||||||
month_en: &'static str,
|
month_en: String,
|
||||||
posts: Vec<Post>,
|
posts: Vec<Post>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -62,11 +24,16 @@ fn group_posts(posts: &[Post]) -> Vec<YearGroup> {
|
|||||||
let mut years: Vec<YearGroup> = vec![];
|
let mut years: Vec<YearGroup> = vec![];
|
||||||
|
|
||||||
for post in posts {
|
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 {
|
if parts.len() != 3 {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let year = parts[0];
|
let year = parts[0].to_string();
|
||||||
let month_num = parts[1];
|
let month_num = parts[1];
|
||||||
let month_en = match month_num {
|
let month_en = match month_num {
|
||||||
"01" => "January",
|
"01" => "January",
|
||||||
@ -93,8 +60,8 @@ fn group_posts(posts: &[Post]) -> Vec<YearGroup> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
yg.months.push(MonthGroup {
|
yg.months.push(MonthGroup {
|
||||||
month: month_en,
|
month: month_en.to_string(),
|
||||||
month_en,
|
month_en: month_en.to_string(),
|
||||||
posts: vec![post.clone()],
|
posts: vec![post.clone()],
|
||||||
});
|
});
|
||||||
continue;
|
continue;
|
||||||
@ -103,8 +70,8 @@ fn group_posts(posts: &[Post]) -> Vec<YearGroup> {
|
|||||||
years.push(YearGroup {
|
years.push(YearGroup {
|
||||||
year,
|
year,
|
||||||
months: vec![MonthGroup {
|
months: vec![MonthGroup {
|
||||||
month: month_en,
|
month: month_en.to_string(),
|
||||||
month_en,
|
month_en: month_en.to_string(),
|
||||||
posts: vec![post.clone()],
|
posts: vec![post.clone()],
|
||||||
}],
|
}],
|
||||||
});
|
});
|
||||||
@ -116,6 +83,8 @@ fn group_posts(posts: &[Post]) -> Vec<YearGroup> {
|
|||||||
#[component]
|
#[component]
|
||||||
pub fn Archives() -> Element {
|
pub fn Archives() -> Element {
|
||||||
let route = use_route::<Route>();
|
let route = use_route::<Route>();
|
||||||
|
let posts_res = use_resource(list_published_posts);
|
||||||
|
|
||||||
let nav_items = vec![
|
let nav_items = vec![
|
||||||
NavItemConfig { href: "/", label: "首页", is_active: matches!(route, Route::Home {}) },
|
NavItemConfig { href: "/", label: "首页", is_active: matches!(route, Route::Home {}) },
|
||||||
NavItemConfig { href: "/archives", label: "归档", is_active: matches!(route, Route::Archives {}) },
|
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 {}) },
|
NavItemConfig { href: "/about", label: "关于", is_active: matches!(route, Route::About {}) },
|
||||||
];
|
];
|
||||||
|
|
||||||
let grouped = group_posts(POSTS);
|
|
||||||
|
|
||||||
rsx! {
|
rsx! {
|
||||||
div { class: "min-h-screen flex flex-col bg-white dark:bg-[#1d1e20] transition-colors duration-300",
|
div { class: "min-h-screen flex flex-col bg-white dark:bg-[#1d1e20] transition-colors duration-300",
|
||||||
Header { nav_items, right_content: rsx! { ThemeToggle {} } }
|
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]",
|
h1 { class: "text-[34px] font-bold text-gray-900 dark:text-[#dadadb]",
|
||||||
"归档"
|
"归档"
|
||||||
}
|
}
|
||||||
div { class: "mt-2 text-base text-gray-500 dark:text-[#9b9c9d]",
|
match &*posts_res.read() {
|
||||||
"共 "
|
Some(Ok(PostListResponse { posts })) => {
|
||||||
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]",
|
||||||
|
"共 "
|
||||||
|
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() {
|
match &*posts_res.read() {
|
||||||
YearSection { year_group: year_group.clone() }
|
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 {}
|
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}" }
|
sup { class: "archive-count text-sm text-gray-400 dark:text-[#9b9c9d] ml-1", "{total}" }
|
||||||
}
|
}
|
||||||
for month_group in year_group.months.iter() {
|
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]
|
#[component]
|
||||||
fn MonthSection(month_group: MonthGroup, year: &'static str) -> Element {
|
fn MonthSection(month_group: MonthGroup, year: String) -> Element {
|
||||||
let count = month_group.posts.len();
|
let count = month_group.posts.len();
|
||||||
|
|
||||||
rsx! {
|
rsx! {
|
||||||
@ -199,13 +209,18 @@ fn MonthSection(month_group: MonthGroup, year: &'static str) -> Element {
|
|||||||
|
|
||||||
#[component]
|
#[component]
|
||||||
fn ArchiveEntry(post: Post) -> Element {
|
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! {
|
rsx! {
|
||||||
div { class: "archive-entry relative py-1.5 my-2.5 group",
|
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",
|
h3 { class: "archive-entry-title text-base font-normal text-gray-900 dark:text-[#dadadb] m-0",
|
||||||
"{post.title}"
|
"{post.title}"
|
||||||
}
|
}
|
||||||
div { class: "archive-meta text-sm text-gray-400 dark:text-[#9b9c9d] mt-1",
|
div { class: "archive-meta text-sm text-gray-400 dark:text-[#9b9c9d] mt-1",
|
||||||
"{post.date}"
|
"{date_str}"
|
||||||
}
|
}
|
||||||
a {
|
a {
|
||||||
class: "entry-link absolute inset-0 z-10",
|
class: "entry-link absolute inset-0 z-10",
|
||||||
|
|||||||
@ -1,67 +1,17 @@
|
|||||||
use dioxus::prelude::*;
|
use dioxus::prelude::*;
|
||||||
|
|
||||||
|
use crate::api::posts::{list_published_posts, PostListResponse};
|
||||||
use crate::components::header::{Header, NavItemConfig};
|
use crate::components::header::{Header, NavItemConfig};
|
||||||
use crate::components::footer::Footer;
|
use crate::components::footer::Footer;
|
||||||
|
use crate::models::post::Post;
|
||||||
use crate::router::Route;
|
use crate::router::Route;
|
||||||
use crate::theme::ThemeToggle;
|
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]
|
#[component]
|
||||||
pub fn Home() -> Element {
|
pub fn Home() -> Element {
|
||||||
let route = use_route::<Route>();
|
let route = use_route::<Route>();
|
||||||
|
let posts_res = use_resource(list_published_posts);
|
||||||
|
|
||||||
let nav_items = vec![
|
let nav_items = vec![
|
||||||
NavItemConfig { href: "/", label: "首页", is_active: matches!(route, Route::Home {}) },
|
NavItemConfig { href: "/", label: "首页", is_active: matches!(route, Route::Home {}) },
|
||||||
NavItemConfig { href: "/archives", label: "归档", is_active: matches!(route, Route::Archives {}) },
|
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 {} } }
|
Header { nav_items, right_content: rsx! { ThemeToggle {} } }
|
||||||
main { class: "flex-1 w-full max-w-3xl mx-auto px-6 py-6",
|
main { class: "flex-1 w-full max-w-3xl mx-auto px-6 py-6",
|
||||||
HomeInfo {}
|
HomeInfo {}
|
||||||
for post in POSTS.iter() {
|
match &*posts_res.read() {
|
||||||
PostEntry { post: post.clone() }
|
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 {}
|
Pagination {}
|
||||||
}
|
}
|
||||||
@ -101,9 +82,12 @@ fn HomeInfo() -> Element {
|
|||||||
|
|
||||||
#[component]
|
#[component]
|
||||||
fn PostEntry(post: Post) -> Element {
|
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! {
|
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 {
|
a {
|
||||||
@ -117,16 +101,26 @@ fn PostEntry(post: Post) -> Element {
|
|||||||
"{post.title}"
|
"{post.title}"
|
||||||
}
|
}
|
||||||
div { class: "mt-2 text-sm text-gray-500 dark:text-[#9b9c9d] leading-relaxed line-clamp-2",
|
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]",
|
div { class: "mt-3 flex items-center gap-3 text-[13px] text-gray-400 dark:text-[#9b9c9d]",
|
||||||
span { "{post.date}" }
|
span { "{date_str}" }
|
||||||
span { "·" }
|
if !post.tags.is_empty() {
|
||||||
for (i, tag) in tag_items.iter().enumerate() {
|
span { "·" }
|
||||||
if i > 0 {
|
for tag in post.tags.clone().into_iter() {
|
||||||
span { "," }
|
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}" }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,5 +2,6 @@ pub mod admin;
|
|||||||
pub mod archives;
|
pub mod archives;
|
||||||
pub mod home;
|
pub mod home;
|
||||||
pub mod login;
|
pub mod login;
|
||||||
|
pub mod post_detail;
|
||||||
pub mod register;
|
pub mod register;
|
||||||
pub mod tags;
|
pub mod tags;
|
||||||
116
src/pages/post_detail.rs
Normal file
116
src/pages/post_detail.rs
Normal 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 {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,47 +1,17 @@
|
|||||||
use dioxus::prelude::*;
|
use dioxus::prelude::*;
|
||||||
|
|
||||||
|
use crate::api::posts::{get_posts_by_tag, list_tags, PostListResponse, TagListResponse};
|
||||||
use crate::components::header::{Header, NavItemConfig};
|
use crate::components::header::{Header, NavItemConfig};
|
||||||
use crate::components::footer::Footer;
|
use crate::components::footer::Footer;
|
||||||
use crate::pages::home::{Post, POSTS};
|
use crate::models::post::Post;
|
||||||
use crate::router::Route;
|
use crate::router::Route;
|
||||||
use crate::theme::ThemeToggle;
|
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]
|
#[component]
|
||||||
pub fn Tags() -> Element {
|
pub fn Tags() -> Element {
|
||||||
let route = use_route::<Route>();
|
let route = use_route::<Route>();
|
||||||
|
let tags_res = use_resource(list_tags);
|
||||||
|
|
||||||
let nav_items = vec![
|
let nav_items = vec![
|
||||||
NavItemConfig { href: "/", label: "首页", is_active: matches!(route, Route::Home {}) },
|
NavItemConfig { href: "/", label: "首页", is_active: matches!(route, Route::Home {}) },
|
||||||
NavItemConfig { href: "/archives", label: "归档", is_active: matches!(route, Route::Archives {}) },
|
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 {}) },
|
NavItemConfig { href: "/about", label: "关于", is_active: matches!(route, Route::About {}) },
|
||||||
];
|
];
|
||||||
|
|
||||||
let tags = collect_tags();
|
let tags_data = use_memo(move || {
|
||||||
let total_posts = POSTS.len();
|
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! {
|
rsx! {
|
||||||
div { class: "min-h-screen flex flex-col bg-white dark:bg-[#1d1e20] transition-colors duration-300",
|
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]",
|
h1 { class: "text-[34px] font-bold text-gray-900 dark:text-[#dadadb]",
|
||||||
"标签"
|
"标签"
|
||||||
}
|
}
|
||||||
div { class: "mt-2 text-base text-gray-500 dark:text-[#9b9c9d]",
|
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.len()}" }
|
"共 "
|
||||||
" 个标签,"
|
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}" }
|
" 个标签,"
|
||||||
" 篇文章"
|
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",
|
if has_error() {
|
||||||
for (name, count) in tags.into_iter().map(|t| (t.name, t.count)) {
|
div { class: "text-center text-red-500 dark:text-red-400 py-20",
|
||||||
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",
|
} else if tags_data().is_some() {
|
||||||
href: "/tags/{name}",
|
ul { class: "flex flex-wrap gap-4 mt-6",
|
||||||
onclick: move |evt| {
|
for tag in tags_data().unwrap().into_iter() {
|
||||||
evt.prevent_default();
|
li {
|
||||||
dioxus::router::navigator().push(format!("/tags/{}", name).as_str());
|
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",
|
||||||
"{name}"
|
href: "/tags/{tag.name}",
|
||||||
sup { class: "ml-1 text-sm text-gray-500 dark:text-[#9b9c9d]", "{count}" }
|
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 {}
|
Footer {}
|
||||||
@ -94,6 +97,9 @@ pub fn Tags() -> Element {
|
|||||||
#[component]
|
#[component]
|
||||||
pub fn TagDetail(tag: String) -> Element {
|
pub fn TagDetail(tag: String) -> Element {
|
||||||
let route = use_route::<Route>();
|
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![
|
let nav_items = vec![
|
||||||
NavItemConfig { href: "/", label: "首页", is_active: matches!(route, Route::Home {}) },
|
NavItemConfig { href: "/", label: "首页", is_active: matches!(route, Route::Home {}) },
|
||||||
NavItemConfig { href: "/archives", label: "归档", is_active: matches!(route, Route::Archives {}) },
|
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 {}) },
|
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! {
|
rsx! {
|
||||||
div { class: "min-h-screen flex flex-col bg-white dark:bg-[#1d1e20] transition-colors duration-300",
|
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]",
|
h1 { class: "text-[34px] font-bold text-gray-900 dark:text-[#dadadb]",
|
||||||
"{tag}"
|
"{tag}"
|
||||||
}
|
}
|
||||||
div { class: "mt-2 text-base text-gray-500 dark:text-[#9b9c9d]",
|
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]", "{posts.len()}" }
|
"共 "
|
||||||
" 篇文章"
|
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() {
|
if has_error() {
|
||||||
TagPostEntry { post: post.clone() }
|
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 {}
|
Footer {}
|
||||||
@ -129,8 +173,11 @@ pub fn TagDetail(tag: String) -> Element {
|
|||||||
|
|
||||||
#[component]
|
#[component]
|
||||||
fn TagPostEntry(post: Post) -> Element {
|
fn TagPostEntry(post: Post) -> Element {
|
||||||
let tag_items = post.tags.to_vec();
|
let post_slug = post.slug.clone();
|
||||||
let post_slug = post.slug;
|
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! {
|
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",
|
||||||
@ -145,24 +192,24 @@ fn TagPostEntry(post: Post) -> Element {
|
|||||||
"{post.title}"
|
"{post.title}"
|
||||||
}
|
}
|
||||||
div { class: "mt-2 text-sm text-gray-500 dark:text-[#9b9c9d] leading-relaxed line-clamp-2",
|
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]",
|
div { class: "mt-3 flex items-center gap-3 text-[13px] text-gray-400 dark:text-[#9b9c9d]",
|
||||||
span { "{post.date}" }
|
span { "{date_str}" }
|
||||||
span { "·" }
|
if !post.tags.is_empty() {
|
||||||
for (i, tag_name) in tag_items.into_iter().enumerate() {
|
span { "·" }
|
||||||
if i > 0 {
|
for tag in post.tags.clone().into_iter() {
|
||||||
span { "," }
|
span {
|
||||||
}
|
a {
|
||||||
span {
|
class: "hover:text-gray-600 dark:hover:text-[#dadadb] transition-colors",
|
||||||
a {
|
href: "/tags/{tag}",
|
||||||
class: "hover:text-gray-600 dark:hover:text-[#dadadb] transition-colors",
|
onclick: move |evt| {
|
||||||
href: "/tags/{tag_name}",
|
evt.prevent_default();
|
||||||
onclick: move |evt| {
|
evt.stop_propagation();
|
||||||
evt.prevent_default();
|
dioxus::router::navigator().push(format!("/tags/{}", tag).as_str());
|
||||||
dioxus::router::navigator().push(format!("/tags/{}", tag_name).as_str());
|
},
|
||||||
},
|
"{tag}"
|
||||||
"{tag_name}"
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,10 +3,11 @@ use std::sync::Arc;
|
|||||||
|
|
||||||
use crate::components::admin_layout::AdminLayout;
|
use crate::components::admin_layout::AdminLayout;
|
||||||
use crate::context::UserContext;
|
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::archives::Archives;
|
||||||
use crate::pages::home::Home;
|
use crate::pages::home::Home;
|
||||||
use crate::pages::login::Login;
|
use crate::pages::login::Login;
|
||||||
|
use crate::pages::post_detail::PostDetail;
|
||||||
use crate::pages::register::Register;
|
use crate::pages::register::Register;
|
||||||
use crate::pages::tags::{TagDetail, Tags};
|
use crate::pages::tags::{TagDetail, Tags};
|
||||||
use crate::theme::{use_theme_provider, Theme, ThemePreload};
|
use crate::theme::{use_theme_provider, Theme, ThemePreload};
|
||||||
@ -27,6 +28,8 @@ pub enum Route {
|
|||||||
Admin {},
|
Admin {},
|
||||||
#[route("/write")]
|
#[route("/write")]
|
||||||
Write {},
|
Write {},
|
||||||
|
#[route("/posts")]
|
||||||
|
Posts {},
|
||||||
#[end_layout]
|
#[end_layout]
|
||||||
#[end_nest]
|
#[end_nest]
|
||||||
|
|
||||||
@ -36,6 +39,8 @@ pub enum Route {
|
|||||||
Tags {},
|
Tags {},
|
||||||
#[route("/tags/:tag")]
|
#[route("/tags/:tag")]
|
||||||
TagDetail { tag: String },
|
TagDetail { tag: String },
|
||||||
|
#[route("/post/:slug")]
|
||||||
|
PostDetail { slug: String },
|
||||||
#[route("/search")]
|
#[route("/search")]
|
||||||
Search {},
|
Search {},
|
||||||
#[route("/about")]
|
#[route("/about")]
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user