实现 PaperMod 风格归档页面,支持按年份月份分组

- 新增 ArchivesPage 组件,三级结构:年份 → 月份 → 文章
- Header/NavItem/Footer 改为 pub 共享,NavItem 通过路由动态判断高亮
- 编译 Tailwind CSS 包含归档页面所需 utility classes

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
xfy 2026-05-26 13:31:35 +08:00
parent a15394c935
commit 7599bfbb13
5 changed files with 296 additions and 14 deletions

View File

@ -21,6 +21,7 @@
--color-blue-600: oklch(54.6% 0.245 262.881);
--color-blue-700: oklch(48.8% 0.243 264.376);
--color-gray-50: oklch(98.5% 0.002 247.839);
--color-gray-100: oklch(96.7% 0.003 264.542);
--color-gray-200: oklch(92.8% 0.006 264.531);
--color-gray-300: oklch(87.2% 0.01 258.338);
--color-gray-400: oklch(70.7% 0.022 261.325);
@ -38,10 +39,13 @@
--text-sm--line-height: calc(1.25 / 0.875);
--text-base: 1rem;
--text-base--line-height: calc(1.5 / 1);
--text-lg: 1.125rem;
--text-lg--line-height: calc(1.75 / 1.125);
--text-xl: 1.25rem;
--text-xl--line-height: calc(1.75 / 1.25);
--text-2xl: 1.5rem;
--text-2xl--line-height: calc(2 / 1.5);
--font-weight-normal: 400;
--font-weight-medium: 500;
--font-weight-bold: 700;
--leading-tight: 1.25;
@ -210,6 +214,9 @@
.visible {
visibility: visible;
}
.absolute {
position: absolute;
}
.relative {
position: relative;
}
@ -219,15 +226,33 @@
.sticky {
position: sticky;
}
.inset-0 {
inset: calc(var(--spacing) * 0);
}
.top-0 {
top: calc(var(--spacing) * 0);
}
.z-10 {
z-index: 10;
}
.z-40 {
z-index: 40;
}
.m-0 {
margin: calc(var(--spacing) * 0);
}
.mx-auto {
margin-inline: auto;
}
.my-2\.5 {
margin-block: calc(var(--spacing) * 2.5);
}
.mt-0 {
margin-top: calc(var(--spacing) * 0);
}
.mt-1 {
margin-top: calc(var(--spacing) * 1);
}
.mt-2 {
margin-top: calc(var(--spacing) * 2);
}
@ -243,6 +268,9 @@
.mt-auto {
margin-top: auto;
}
.mb-0 {
margin-bottom: calc(var(--spacing) * 0);
}
.mb-1 {
margin-bottom: calc(var(--spacing) * 1);
}
@ -306,6 +334,9 @@
.flex-1 {
flex: 1;
}
.shrink-0 {
flex-shrink: 0;
}
.translate-y-0 {
--tw-translate-y: calc(var(--spacing) * 0);
translate: var(--tw-translate-x) var(--tw-translate-y);
@ -369,6 +400,9 @@
border-bottom-style: var(--tw-border-style);
border-bottom-width: 1px;
}
.border-gray-100 {
border-color: var(--color-gray-100);
}
.border-gray-200 {
border-color: var(--color-gray-200);
}
@ -426,9 +460,15 @@
.py-1 {
padding-block: calc(var(--spacing) * 1);
}
.py-1\.5 {
padding-block: calc(var(--spacing) * 1.5);
}
.py-2 {
padding-block: calc(var(--spacing) * 2);
}
.py-2\.5 {
padding-block: calc(var(--spacing) * 2.5);
}
.py-4 {
padding-block: calc(var(--spacing) * 4);
}
@ -452,6 +492,10 @@
font-size: var(--text-base);
line-height: var(--tw-leading, var(--text-base--line-height));
}
.text-lg {
font-size: var(--text-lg);
line-height: var(--tw-leading, var(--text-lg--line-height));
}
.text-sm {
font-size: var(--text-sm);
line-height: var(--tw-leading, var(--text-sm--line-height));
@ -482,6 +526,10 @@
--tw-font-weight: var(--font-weight-medium);
font-weight: var(--font-weight-medium);
}
.font-normal {
--tw-font-weight: var(--font-weight-normal);
font-weight: var(--font-weight-normal);
}
.text-blue-600 {
color: var(--color-blue-600);
}
@ -649,11 +697,31 @@
display: flex;
}
}
.md\:w-\[200px\] {
@media (width >= 48rem) {
width: 200px;
}
}
.md\:flex-row {
@media (width >= 48rem) {
flex-direction: row;
}
}
.md\:items-start {
@media (width >= 48rem) {
align-items: flex-start;
}
}
.dark\:border-\[\#333\] {
&:where(.dark, .dark *) {
border-color: #333;
}
}
.dark\:border-\[\#333\]\/50 {
&:where(.dark, .dark *) {
border-color: color-mix(in oklab, #333 50%, transparent);
}
}
.dark\:border-gray-600 {
&:where(.dark, .dark *) {
border-color: var(--color-gray-600);

205
src/pages/archives.rs Normal file
View File

@ -0,0 +1,205 @@
use dioxus::prelude::*;
use crate::pages::home::{Footer, Header};
#[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,
months: Vec<MonthGroup>,
}
#[derive(Clone, PartialEq)]
struct MonthGroup {
month: &'static str,
month_en: &'static str,
posts: Vec<Post>,
}
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();
if parts.len() != 3 {
continue;
}
let year = parts[0];
let month_num = parts[1];
let month_en = match month_num {
"01" => "January",
"02" => "February",
"03" => "March",
"04" => "April",
"05" => "May",
"06" => "June",
"07" => "July",
"08" => "August",
"09" => "September",
"10" => "October",
"11" => "November",
"12" => "December",
_ => month_num,
};
if let Some(yg) = years.last_mut() {
if yg.year == year {
if let Some(mg) = yg.months.last_mut() {
if mg.month_en == month_en {
mg.posts.push(post.clone());
continue;
}
}
yg.months.push(MonthGroup {
month: month_en,
month_en,
posts: vec![post.clone()],
});
continue;
}
}
years.push(YearGroup {
year,
months: vec![MonthGroup {
month: month_en,
month_en,
posts: vec![post.clone()],
}],
});
}
years
}
#[component]
pub fn ArchivesPage() -> Element {
let grouped = group_posts(POSTS);
rsx! {
div { class: "min-h-screen flex flex-col bg-white dark:bg-[#1d1e20] transition-colors duration-300",
Header {}
main { class: "flex-1 w-full max-w-3xl mx-auto px-6 py-6",
header { class: "page-header mb-6",
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()}" }
" 篇文章"
}
}
for year_group in grouped.iter() {
YearSection { year_group: year_group.clone() }
}
}
Footer {}
}
}
}
#[component]
fn YearSection(year_group: YearGroup) -> Element {
let total = year_group.months.iter().map(|m| m.posts.len()).sum::<usize>();
rsx! {
div { class: "archive-year mt-10",
h2 {
class: "archive-year-header text-2xl font-bold text-gray-900 dark:text-[#dadadb] mb-4",
id: "{year_group.year}",
a {
class: "archive-header-link hover:opacity-80 transition-opacity",
href: "#{year_group.year}",
"{year_group.year}"
}
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 }
}
}
}
}
#[component]
fn MonthSection(month_group: MonthGroup, year: &'static str) -> Element {
let count = month_group.posts.len();
rsx! {
div { class: "archive-month flex flex-col md:flex-row md:items-start py-2.5 border-b border-gray-100 dark:border-[#333]/50",
h3 {
class: "archive-month-header text-lg font-medium text-gray-700 dark:text-[#9b9c9d] md:w-[200px] shrink-0 mt-0 mb-0 py-1.5",
id: "{year}-{month_group.month_en}",
a {
class: "archive-header-link hover:opacity-80 transition-opacity",
href: "#{year}-{month_group.month_en}",
"{month_group.month}"
}
sup { class: "archive-count text-sm text-gray-400 dark:text-[#9b9c9d] ml-1", "{count}" }
}
div { class: "archive-posts flex-1",
for post in month_group.posts.iter() {
ArchiveEntry { post: post.clone() }
}
}
}
}
}
#[component]
fn ArchiveEntry(post: Post) -> Element {
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}"
}
a {
class: "entry-link absolute inset-0 z-10",
aria_label: "post link to {post.title}",
href: "/post/{post.slug}",
}
}
}
}

View File

@ -1,5 +1,6 @@
use dioxus::prelude::*;
use crate::router::Route;
use crate::theme::ThemeToggle;
#[derive(Clone, PartialEq)]
@ -74,7 +75,9 @@ pub fn HomePage() -> Element {
}
#[component]
fn Header() -> Element {
pub fn Header() -> Element {
let route = use_route::<Route>();
rsx! {
header { class: "sticky top-0 z-40 w-full border-b border-gray-200 dark:border-[#333] bg-white/80 dark:bg-[#1d1e20]/80 backdrop-blur-sm",
nav { class: "max-w-3xl mx-auto px-6 h-[60px] flex items-center justify-between",
@ -85,11 +88,11 @@ fn Header() -> Element {
}
div { class: "flex items-center gap-2",
ul { class: "hidden md:flex items-center gap-1",
NavItem { href: "/", label: "首页", active: true }
NavItem { href: "/archives", label: "归档" }
NavItem { href: "/tags", label: "标签" }
NavItem { href: "/search", label: "搜索" }
NavItem { href: "/about", label: "关于" }
NavItem { href: "/", label: "首页", route: route.clone() }
NavItem { href: "/archives", label: "归档", route: route.clone() }
NavItem { href: "/tags", label: "标签", route: route.clone() }
NavItem { href: "/search", label: "搜索", route: route.clone() }
NavItem { href: "/about", label: "关于", route: route.clone() }
}
ThemeToggle {}
}
@ -99,9 +102,18 @@ fn Header() -> Element {
}
#[component]
fn NavItem(href: &'static str, label: &'static str, #[props(default = false)] active: bool) -> Element {
pub fn NavItem(href: &'static str, label: &'static str, route: Route) -> Element {
let is_active = match (href, route) {
("/", Route::HomePage {}) => true,
("/archives", Route::ArchivesPage {}) => true,
("/tags", Route::TagsPage {}) => true,
("/search", Route::SearchPage {}) => true,
("/about", Route::AboutPage {}) => true,
_ => false,
};
let base_class = "px-3 py-1 text-base rounded-lg transition-colors";
let class_str = if active {
let class_str = if is_active {
format!("{} font-medium text-gray-900 dark:text-[#dadadb] underline underline-offset-[0.3rem] decoration-2 decoration-gray-900 dark:decoration-[#dadadb]", base_class)
} else {
format!("{} text-gray-600 dark:text-[#9b9c9d] hover:text-gray-900 dark:hover:text-[#dadadb]", base_class)
@ -171,7 +183,7 @@ fn Pagination() -> Element {
}
#[component]
fn Footer() -> Element {
pub fn Footer() -> Element {
let mut visible = use_signal(|| false);
use_effect(move || {

View File

@ -1,4 +1,5 @@
pub mod admin;
pub mod archives;
pub mod home;
pub mod login;
pub mod register;

View File

@ -1,6 +1,7 @@
use dioxus::prelude::*;
use crate::pages::admin::AdminPage;
use crate::pages::archives::ArchivesPage;
use crate::pages::home::HomePage;
use crate::pages::login::LoginPage;
use crate::pages::register::RegisterPage;
@ -43,11 +44,6 @@ pub fn AppRouter() -> Element {
}
}
#[component]
pub fn ArchivesPage() -> Element {
rsx! { "Archives" }
}
#[component]
pub fn TagsPage() -> Element {
rsx! { "Tags" }