惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

Martin Fowler
Martin Fowler
OSCHINA 社区最新新闻
OSCHINA 社区最新新闻
T
Troy Hunt's Blog
Latest news
Latest news
Vercel News
Vercel News
S
SegmentFault 最新的问题
V
Vulnerabilities – Threatpost
博客园 - Franky
P
Privacy International News Feed
A
Arctic Wolf
T
The Blog of Author Tim Ferriss
S
Schneier on Security
T
The Exploit Database - CXSecurity.com
P
Palo Alto Networks Blog
T
Tor Project blog
Jina AI
Jina AI
GbyAI
GbyAI
The Hacker News
The Hacker News
博客园 - 叶小钗
Google DeepMind News
Google DeepMind News
T
Threatpost
C
CERT Recently Published Vulnerability Notes
P
Proofpoint News Feed
Scott Helme
Scott Helme
WordPress大学
WordPress大学
www.infosecurity-magazine.com
www.infosecurity-magazine.com
Microsoft Azure Blog
Microsoft Azure Blog
CTFtime.org: upcoming CTF events
CTFtime.org: upcoming CTF events
博客园 - 司徒正美
A
About on SuperTechFans
Recorded Future
Recorded Future
爱范儿
爱范儿
L
LangChain Blog
V
V2EX
C
Cybersecurity and Infrastructure Security Agency CISA
The Cloudflare Blog
阮一峰的网络日志
阮一峰的网络日志
K
Kaspersky official blog
Webroot Blog
Webroot Blog
Security Archives - TechRepublic
Security Archives - TechRepublic
D
DataBreaches.Net
宝玉的分享
宝玉的分享
Google Online Security Blog
Google Online Security Blog
C
Cisco Blogs
L
Lohrmann on Cybersecurity
Help Net Security
Help Net Security
AWS News Blog
AWS News Blog
M
MIT News - Artificial intelligence
奇客Solidot–传递最新科技情报
奇客Solidot–传递最新科技情报
G
GRAHAM CLULEY

Hyde Blog

Hyde Blog Hyde Blog Hyde Blog Hyde Blog Hyde Blog Hyde Blog Hyde Blog Hyde Blog Hyde Blog Hyde Blog Hyde Blog Hyde Blog Hyde Blog Hyde Blog Hyde Blog Hyde Blog Hyde Blog Hyde Blog Hyde Blog Hyde Blog Hyde Blog Hyde Blog Hyde Blog Hyde Blog Hyde Blog Hyde Blog Hyde Blog Hyde Blog Hyde Blog Hyde Blog Hyde Blog Hyde Blog Hyde Blog Hyde Blog Hyde Blog Hyde Blog Hyde Blog Hyde Blog Hyde Blog Hyde Blog Hyde Blog Hyde Blog Hyde Blog Hyde Blog Hyde Blog Hyde Blog Hyde Blog Hyde Blog Hyde Blog Hyde Blog Hyde Blog Hyde Blog Hyde Blog Hyde Blog Hyde Blog Hyde Blog Hyde Blog Hyde Blog Hyde Blog Hyde Blog Hyde Blog Hyde Blog Hyde Blog Hyde Blog Hyde Blog Hyde Blog Hyde Blog Hyde Blog Hyde Blog Hyde Blog Hyde Blog Hyde Blog Hyde Blog Hyde Blog Hyde Blog Hyde Blog Hyde Blog Hyde Blog Hyde Blog Hyde Blog Hyde Blog Hyde Blog Hyde Blog Hyde Blog Hyde Blog Hyde Blog Hyde Blog Hyde Blog Hyde Blog Hyde Blog Hyde Blog 关于我 关于我 Hyde Blog Hyde Blog Hyde Blog Hyde Blog Hyde Blog Hyde Blog Hyde Blog
Hyde Blog
Hyde · 2026-05-19 · via Hyde Blog

日记页面 ​

前言 ​

Firefly 主题移植了一个优雅的日记(Diary)页面,用于分享你的日常点滴、心情感悟或生活瞬间。与传统博客文章不同,日记更侧重于简短、即时的记录,通常会搭配图片。

  • 本文档详细介绍如何将日记页面功能移植到 Firefly-Hyde 主题中。

  • 在开始之前,请确保你已经按照文件结构创建好对应的目录和文件。


📁 文件结构 ​

src/
├── components/
│   ├── atoms/
│   │   └── FilterTabs.astro          ✨ 新增 - 筛选标签组件
│   │   └── index.ts                  ✨ 新增 - 组件导出
│   └── features/
│       └── diary/
│           ├── index.ts               ✨ 新增 - 组件导出
│           ├── types.ts               ✨ 新增 - 类型定义
│           └── MomentCard.astro       ✨ 新增 - 日记卡片组件
├── config/
│   └── siteConfig.ts                  ✏️ 修改 - 添加页面开关配置
│   └── navBarConfig.ts               ✏️ 修改 - 导航栏配置
├── constants/
│   └── link-presets.ts               ✏️ 修改 - 添加导航链接
├── data/
│   └── diary.ts                      ✨ 新增 - 日记数据管理
├── i18n/
│   ├── i18nKey.ts                    ✏️ 修改 - 添加翻译键
│   └── languages/
│       ├── en.ts                     ✏️ 修改 - 英文翻译
│       ├── zh_CN.ts                  ✏️ 修改 - 中文翻译
│       ├── zh_TW.ts                  ✏️ 修改 - 繁体翻译
│       ├── ja.ts                     ✏️ 修改 - 日文翻译
│       └── ru.ts                     ✏️ 修改 - 俄文翻译
│   ├── i18nKey.ts                    ✏️ 修改 - 添加翻译键
├── pages/
│   └── diary.astro                   ✨ 新增 - 日记页面
├── types/
│   └── config.ts                      ✏️ 修改 - 添加类型定义
├── utils/
│   └── timeFormat.ts                 ✏️ 修改 - 修复时区属性名

创建筛选标签组件 ​

文件路径src/components/atoms/FilterTabs.astrosrc/components/atoms/index.ts

FilterTabs.astroindex.ts

astro

---
import { Icon } from "astro-icon/components";

interface FilterTab {
	value: string;
	label: string;
	icon?: string;
	count?: number;
}

interface Props {
	tabs: FilterTab[];
	dataAttr: string;
	activeValue?: string;
	class?: string;
}

const {
	tabs,
	dataAttr,
	activeValue = "all",
	class: className = "",
} = Astro.props;
---

<div class:list={["filter-tabs", className]}>
	{
		tabs.map((tab) => (
			<button
				class:list={[
					"filter-tabs-item",
					{ active: tab.value === activeValue },
				]}
				data-filter-value={tab.value}
				data-filter-attr={dataAttr}
			>
				{tab.icon && <Icon name={tab.icon} class="text-base w-4 h-4" />}
				<span>{tab.label}</span>
				{tab.count !== undefined && (
					<span class="filter-tabs-count">({tab.count})</span>
				)}
			</button>
		))
	}
</div>

<style>
	.filter-tabs {
		display: flex;
		flex-wrap: wrap;
		gap: 0.5rem;
	}

	.filter-tabs-item {
		display: inline-flex;
		align-items: center;
		gap: 0.375rem;
		padding: 0.5rem 1rem;
		border: 1px solid var(--line-divider);
		border-radius: var(--radius-large);
		background: var(--btn-regular-bg);
		color: var(--btn-content);
		font-size: 0.875rem;
		font-weight: 500;
		cursor: pointer;
		transition: all 0.2s ease;
		white-space: nowrap;
	}

	.filter-tabs-item iconify-icon {
		flex-shrink: 0;
		opacity: 0.7;
		transition: opacity 0.2s ease;
	}

	.filter-tabs-item:hover:not(.active) {
		background: var(--btn-regular-bg-hover);
		border-color: var(--primary);
	}

	.filter-tabs-item:hover:not(.active) iconify-icon {
		opacity: 1;
	}

	.filter-tabs-item.active {
		background: var(--primary);
		color: white;
		border-color: var(--primary);
	}

	.filter-tabs-item.active iconify-icon {
		opacity: 1;
	}

	.filter-tabs-count {
		opacity: 0.6;
		font-size: 0.8rem;
	}

	@media (max-width: 768px) {
		.filter-tabs {
			gap: 0.375rem;
		}

		.filter-tabs-item {
			padding: 0.4rem 0.8rem;
			font-size: 0.8rem;
		}

		.filter-tabs-count {
			display: none;
		}
	}
</style>

ts

export { default as FilterTabs } from "./FilterTabs.astro";

创建日记组件 ​

  • 文件路径src/components/features/diary/index.ts 组件导出

  • 文件路径src/components/features/diary/types.ts 类型定义

  • 文件路径src/components/features/diary/MomentCard.astro 日记卡片组件

index.tstypes.tsMomentCard.astro

ts

export { default as MomentCard } from "./MomentCard.astro";
export * from "./types";

ts

import type { DiaryItem } from "../../../data/diary";

export interface MomentCardProps {
	moment: DiaryItem;
	index: number;
	minutesAgo: string;
	hoursAgo: string;
	daysAgo: string;
}

astro

---
import { Icon } from "astro-icon/components";

import { formatRelativeTime } from "../../../utils/timeFormat";
import type { MomentCardProps } from "./types";
import { siteConfig } from "../../../config/siteConfig";

const { moment, index, minutesAgo, hoursAgo, daysAgo } =
	Astro.props as MomentCardProps;

const relativeTime = formatRelativeTime(
	moment.date,
	minutesAgo,
	hoursAgo,
	daysAgo,
);

const avatarUrl = moment.avatar || siteConfig.diary?.defaultAvatar || "https://api.dicebear.com/7.x/avataaars/svg?seed=default";

const images = moment.images || [];

// 图片展示配置
const imageDisplay = moment.imageDisplay || {
	type: 'grid', // 默认使用网格布局
	autoPlay: true, //是否自动轮播
	interval: 5000, //轮播时间
	showIndicator: true,
	showControls: true,
};

const isCarouselMode = imageDisplay.type === 'carousel' && images.length >= 2;

const getVideoSrc = (videoUrl: string): string => {
	if (videoUrl.includes("youtube.com") || videoUrl.includes("youtu.be")) {
		return videoUrl;
	} else if (videoUrl.includes("bilibili.com")) {
		const bvidMatch = videoUrl.match(/BV[\w]+/);
		if (bvidMatch) {
			return `//player.bilibili.com/player.html?bvid=${bvidMatch[0]}&p=1&autoplay=0`;
		}
	}
	return videoUrl;
};
---

<div
	class="moment-card group relative bg-transparent rounded-xl border border-black/10 dark:border-white/10 overflow-hidden transition-all duration-300 hover:shadow-xl hover:-translate-y-1"
	data-tags={moment.tags?.join(",") || ""}
>
	<div class="p-5">
		<!-- Avatar and Content Row -->
		<div class="flex items-start gap-2">
			<!-- Avatar -->
			<div class="flex-shrink-0">
				<img
					src={avatarUrl}
					alt="avatar"
					class="w-10 h-10 rounded-lg object-cover bg-gray-100 dark:bg-gray-180"
				/>
			</div>
			<!-- Content -->
			<div class="flex-1 min-w-0">
				<p
					class="text-sm md:text-base text-black/90 dark:text-white/90 leading-relaxed whitespace-pre-wrap"
				>
					{moment.content}
				</p>

		<!-- Carousel/Images -->
		{
			images.length > 0 && (
				<div class="diary-images mt-3 mb-3">
					<!-- Carousel Mode: when imageDisplay type is 'carousel' -->
					{isCarouselMode && (
						<div 
							class="carousel-container relative rounded-lg overflow-hidden"
							role="region"
							aria-label="图片轮播"
						>
							<!-- Carousel Track Wrapper for 4:3 aspect ratio -->
							<div class="carousel-track-wrapper">
								<div 
									class="carousel-track"
									data-carousel-track
								>
									{images.map((image, imgIndex) => (
										<div 
											class="carousel-slide"
										>
											<a
												href="javascript:void(0)"
												data-src={image}
												data-fancybox={`diary-${index}-${imgIndex}`}
												class="block w-full h-full flex items-center justify-center"
											>
												<img
													src={image}
													alt={`日记图片 ${imgIndex + 1}`}
													class="max-w-full max-h-full"
													loading="lazy"
													decoding="async"
												/>
											</a>
										</div>
									))}
								</div>
							</div>

							<!-- Previous Button -->
							{imageDisplay.showControls !== false && (
								<button
									class="carousel-prev absolute left-2 top-1/2 -translate-y-1/2 w-8 h-8 bg-black/50 hover:bg-black/70 text-white rounded-full flex items-center justify-center transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-white/50"
									aria-label="上一张图片"
									data-carousel-prev
								>
									<Icon name="material-symbols:chevron-left" is:inline class="w-5 h-5" />
								</button>
							)}

							<!-- Next Button -->
							{imageDisplay.showControls !== false && (
								<button
									class="carousel-next absolute right-2 top-1/2 -translate-y-1/2 w-8 h-8 bg-black/50 hover:bg-black/70 text-white rounded-full flex items-center justify-center transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-white/50"
									aria-label="下一张图片"
									data-carousel-next
								>
									<Icon name="material-symbols:chevron-right" is:inline class="w-5 h-5" />
								</button>
							)}

							<!-- Indicator Dots -->
							{imageDisplay.showIndicator !== false && (
								<div class="carousel-indicators absolute bottom-3 left-1/2 -translate-x-1/2 flex gap-2">
									{images.map((_, imgIndex) => (
										<button
											class="carousel-indicator w-2 h-2 rounded-full bg-white/50 hover:bg-white/80 transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-white/50"
											aria-label={`切换到第 ${imgIndex + 1} 张图片`}
											data-carousel-indicator
											data-index={imgIndex}
										></button>
									))}
								</div>
							)}

							<!-- Current Image Counter -->
							<div class="carousel-counter absolute top-2 right-2 px-2 py-1 bg-black/50 text-white text-xs rounded">
								<span data-carousel-current>1</span> / <span>{images.length}</span>
							</div>
						</div>
					)}

					<!-- Grid Mode: when imageDisplay type is 'grid' or no config -->
					{!isCarouselMode && (
						<div
							class:list={[
								"grid gap-2",
								images.length === 1 && "diary-images-1",
								images.length === 2 && "diary-images-grid",
								images.length === 3 && "diary-images-grid",
								images.length === 4 && "diary-images-grid",
								images.length === 5 && "diary-images-grid",
								images.length === 6 && "diary-images-6",
								images.length >= 7 && "diary-images-grid",
							]}
						>
							{images.map((image, imgIndex) => (
								<div 
									class="relative rounded-lg overflow-hidden cursor-pointer"
									class:list={[
										images.length === 1 && "aspect-video",
										images.length >= 2 && "aspect-square",
									]}
								>
									<a
										href="javascript:void(0)"
										data-src={image}
										data-fancybox={`diary-${index}-${imgIndex}`}
										class="block w-full h-full"
									>
										<img
											src={image}
											alt="diary moment image"
											class="w-full h-full object-cover transition-transform duration-300 hover:scale-105"
											loading="lazy"
											decoding="async"
										/>
									</a>
								</div>
							))}
						</div>
					)}
				</div>
			)
		}

		<!-- Video -->
		{
			moment.video && (
				<div class="diary-video mt-3 mb-3 rounded-lg overflow-hidden">
					<iframe
						width="100%"
						height="100%"
						src={getVideoSrc(moment.video)}
						title={moment.video.includes("bilibili") ? "Bilibili video player" : "YouTube video player"}
						frameborder="0"
						allowfullscreen
						class="w-full h-full"
					></iframe>
				</div>
			)
		}

		<!-- Tags -->
		{
			moment.tags && moment.tags.length > 0 && (
				<div class="flex flex-wrap gap-1.5 mb-3">
					{moment.tags.map((tag: string) => (
						<span class="btn-regular h-6 text-xs px-2 rounded-lg">
							{tag}
						</span>
					))}
				</div>
			)
		}

		<!-- Divider -->
		<hr class="border-t border-black/5 dark:border-white/5 my-3" />

		<!-- Footer -->
		<div
			class="flex items-center justify-between text-xs text-black/50 dark:text-white/50 flex-wrap gap-2"
		>
			<div class="flex flex-col gap-1">
				{
					moment.location && (
						<div class="flex items-center gap-1.5">
							<Icon
								name="material-symbols:location-on"
								class="text-xs w-3.5 h-3.5"
							/>
							{moment.locationUrl ? (
								<a 
									href={moment.locationUrl}
									target="_blank"
									rel="noopener noreferrer"
									class="text-[var(--primary)] hover:underline truncate max-w-[200px]"
								>
									{moment.location}
								</a>
							) : (
								<span class="truncate max-w-[200px]">{moment.location}</span>
							)}
						</div>
					)
				}
				<div class="flex items-center gap-1.5">
					<Icon
						name="material-symbols:schedule"
						class="text-xs w-3.5 h-3.5"
					/>
					<time datetime={moment.date}>{relativeTime}</time>
				</div>
			</div>

			<div class="flex items-center gap-3">
				{
					moment.mood && (
						<span class="flex items-center gap-1">
							{moment.mood}
						</span>
					)
				}
			</div>
		</div>
		</div>
		</div>
	</div>

	<!-- Hover gradient overlay -->
	<div
		class="absolute inset-0 bg-gradient-to-br from-[var(--primary)]/5 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300 pointer-events-none rounded-xl"
	>
	</div>
</div>

<style>
	.moment-card {
		animation: fadeInUp 0.5s ease-out forwards;
		opacity: 0;
	}

	.moment-card.filtered-out {
		display: none;
	}

	@keyframes fadeInUp {
		from {
			opacity: 0;
			transform: translateY(20px);
		}
		to {
			opacity: 1;
			transform: translateY(0);
		}
	}

	.moment-card:nth-child(1) {
		animation-delay: 0.03s;
	}
	.moment-card:nth-child(2) {
		animation-delay: 0.06s;
	}
	.moment-card:nth-child(3) {
		animation-delay: 0.09s;
	}
	.moment-card:nth-child(4) {
		animation-delay: 0.12s;
	}
	.moment-card:nth-child(5) {
		animation-delay: 0.15s;
	}
	.moment-card:nth-child(6) {
		animation-delay: 0.18s;
	}
	.moment-card:nth-child(7) {
		animation-delay: 0.21s;
	}
	.moment-card:nth-child(8) {
		animation-delay: 0.24s;
	}
	.moment-card:nth-child(9) {
		animation-delay: 0.27s;
	}
	.moment-card:nth-child(10) {
		animation-delay: 0.3s;
	}
	.moment-card:nth-child(11) {
		animation-delay: 0.33s;
	}
	.moment-card:nth-child(12) {
		animation-delay: 0.36s;
	}

	/* 1 image: full width, video aspect ratio */
	.diary-images-1 {
		grid-template-columns: 1fr;
		width: 100%;
	}

	/* Grid layout: 3-column grid for moments (2-5, 7+ images) */
	.diary-images-grid {
		grid-template-columns: repeat(3, 1fr);
	}

	/* 6 images: 3-column grid, 2 rows */
	.diary-images-6 {
		grid-template-columns: repeat(3, 1fr);
	}

	/* 8 images: 4-column grid, 2 rows */
	.diary-images-8 {
		grid-template-columns: repeat(4, 1fr);
	}

	/* 9+ images: 3-column grid (3×3 layout) */
	.diary-images-9plus {
		grid-template-columns: repeat(3, 1fr);
		gap: 0.5rem;
	}

	/* Responsive video container */
	.diary-video {
		position: relative;
		width: 100%;
		height: 0;
		padding-bottom: 56.25%; /* 16:9 aspect ratio */
	}

	.diary-video iframe {
		position: absolute;
		top: 0;
		left: 0;
		width: 100%;
		height: 100%;
	}

	@media (max-width: 768px) {
		.diary-images-grid {
			grid-template-columns: repeat(3, 1fr);
		}
		
		.diary-images-6 {
			grid-template-columns: repeat(3, 1fr);
		}
		
		.diary-images-8 {
			grid-template-columns: repeat(4, 1fr);
		}
		
		.diary-images-9plus {
			grid-template-columns: repeat(3, 1fr);
			gap: 0.25rem;
		}
		
		.diary-images .grid-cols-3 {
			grid-template-columns: repeat(3, 1fr);
		}
		
		.diary-images .grid-cols-4 {
			grid-template-columns: repeat(4, 1fr);
		}

		/* Mobile video aspect ratio adjustment */
		.diary-video {
			padding-bottom: 56.25%; /* Keep 16:9 for better viewing */
		}
	}
	
	@media (max-width: 480px) {
		.diary-images-grid {
			grid-template-columns: repeat(3, 1fr);
		}
		
		.diary-images-6 {
			grid-template-columns: repeat(3, 1fr);
		}
		
		.diary-images-8 {
			grid-template-columns: repeat(4, 1fr);
		}
		
		.diary-images-9plus {
			grid-template-columns: repeat(3, 1fr);
			gap: 0.125rem;
		}
		
		.diary-images .grid-cols-3 {
			grid-template-columns: repeat(3, 1fr);
		}
		
		.diary-images .grid-cols-4 {
			grid-template-columns: repeat(4, 1fr);
		}
		
		/* Optimize video height for small mobile screens */
		.diary-video {
			padding-bottom: 62.5%; /* Slightly taller for mobile */
		}
	}

	/* Carousel styles */
	.carousel-container {
		max-width: 100%;
		max-width: 800px;
		margin-left: auto;
		margin-right: auto;
		touch-action: pan-y;
	}

	.carousel-track-wrapper {
		position: relative;
		width: 100%;
		height: 0;
		padding-bottom: 56.25%; /* 16:9 aspect ratio (9/16 = 0.5625) */
		overflow: hidden;
	}

	.carousel-track {
		position: absolute;
		top: 0;
		left: 0;
		width: 100%;
		height: 100%;
		display: flex;
		transition: transform 0.5s ease-out;
	}

	.carousel-slide {
		flex-shrink: 0;
		width: 100%;
		height: 100%;
		display: flex;
		align-items: center;
		justify-content: center;
		background-color: #1a1a1a;
	}

	.carousel-slide img {
		width: 100%;
		height: 100%;
		object-fit: cover;
		object-position: center center;
	}

	.carousel-indicator.active {
		background-color: white;
		transform: scale(1.2);
	}

	.carousel-prev,
	.carousel-next {
		opacity: 0;
		transition: opacity 0.2s ease;
	}

	.carousel-container:hover .carousel-prev,
	.carousel-container:hover .carousel-next {
		opacity: 1;
	}

	@media (hover: none) {
		.carousel-prev,
		.carousel-next {
			opacity: 1;
		}
	}
</style>

<script is:inline>
	// 轮播图交互逻辑
	const initCarousels = () => {
		// 获取所有轮播容器
		const carouselContainers = document.querySelectorAll('.carousel-container');
		
		carouselContainers.forEach((container, containerIndex) => {
			const track = container.querySelector('[data-carousel-track]');
			const prevBtn = container.querySelector('[data-carousel-prev]');
			const nextBtn = container.querySelector('[data-carousel-next]');
			const indicators = container.querySelectorAll('[data-carousel-indicator]');
			const currentSpan = container.querySelector('[data-carousel-current]');
			
			if (!track || !prevBtn || !nextBtn) return;
			
			const slides = track.querySelectorAll('.carousel-slide');
			const slideCount = slides.length;
			let currentIndex = 0;
			let autoPlayInterval = null;
			
			// 获取轮播配置(从数据属性读取)
			const interval = 4000; // 默认4秒
			const autoPlay = true; // 默认开启自动播放
			
			// 更新轮播位置
			const updateCarousel = (index) => {
				// 确保索引在有效范围内(无限循环)
				if (index < 0) {
					currentIndex = slideCount - 1;
				} else if (index >= slideCount) {
					currentIndex = 0;
				} else {
					currentIndex = index;
				}
				
				// 更新轨道位置
				const translateX = -currentIndex * 100;
				track.style.transform = `translateX(${translateX}%)`;
				
				// 更新指示器
				indicators.forEach((indicator, i) => {
					indicator.classList.toggle('active', i === currentIndex);
				});
				
				// 更新计数器
				if (currentSpan) {
					currentSpan.textContent = currentIndex + 1;
				}
			};
			
			// 上一张
			const goToPrev = () => {
				updateCarousel(currentIndex - 1);
				resetAutoPlay();
			};
			
			// 下一张
			const goToNext = () => {
				updateCarousel(currentIndex + 1);
				resetAutoPlay();
			};
			
			// 跳转到指定索引
			const goToIndex = (index) => {
				updateCarousel(index);
				resetAutoPlay();
			};
			
			// 重置自动播放定时器
			const resetAutoPlay = () => {
				if (autoPlayInterval) {
					clearInterval(autoPlayInterval);
				}
				startAutoPlay();
			};
			
			// 开始自动播放
			const startAutoPlay = () => {
				if (autoPlay && slideCount > 1) {
					autoPlayInterval = setInterval(() => {
						goToNext();
					}, interval);
				}
			};
			
			// 暂停自动播放
			const pauseAutoPlay = () => {
				if (autoPlayInterval) {
					clearInterval(autoPlayInterval);
					autoPlayInterval = null;
				}
			};
			
			// 触摸滑动支持
			let touchStartX = 0;
			let touchEndX = 0;
			const touchThreshold = 50; // 滑动阈值(像素)
			
			const handleTouchStart = (e) => {
				touchStartX = e.touches[0].clientX;
				pauseAutoPlay();
			};
			
			const handleTouchMove = (e) => {
				touchEndX = e.touches[0].clientX;
			};
			
			const handleTouchEnd = () => {
				const diff = touchStartX - touchEndX;
				if (Math.abs(diff) > touchThreshold) {
					if (diff > 0) {
						goToNext();
					} else {
						goToPrev();
					}
				}
				startAutoPlay();
			};
			
			// 绑定事件
			prevBtn.addEventListener('click', goToPrev);
			nextBtn.addEventListener('click', goToNext);
			
			indicators.forEach((indicator, i) => {
				indicator.addEventListener('click', () => goToIndex(i));
			});
			
			// 鼠标悬停暂停
			container.addEventListener('mouseenter', pauseAutoPlay);
			container.addEventListener('mouseleave', startAutoPlay);
			
			// 触摸事件
			container.addEventListener('touchstart', handleTouchStart, { passive: true });
			container.addEventListener('touchmove', handleTouchMove, { passive: true });
			container.addEventListener('touchend', handleTouchEnd);
			
			// 初始化
			updateCarousel(0);
			startAutoPlay();
		});
	};

	// 如果 DOM 已经加载完成,立即执行初始化
	if (document.readyState === 'loading') {
		document.addEventListener('DOMContentLoaded', initCarousels);
	} else {
		initCarousels();
	}
</script>

创建日记数据管理 ​

文件路径src/data/diary.ts

ts

// 日记数据配置
// 用于管理日记页面的数据

export interface DiaryItem {
	id: number;
	content: string;
	date: string;
	images?: string[];
	video?: string;
	location?: string;
	locationUrl?: string;
	mood?: string;
	tags?: string[];
	avatar?: string;
	// 图片展示配置
	imageDisplay?: {
		type: 'carousel' | 'grid'; // 显示类型:轮播图或网格布局
		autoPlay?: boolean;         // 是否自动播放(仅carousel模式),默认 true
		interval?: number;          // 自动播放间隔(毫秒),默认 4000ms
		showIndicator?: boolean;    // 是否显示位置指示器(仅carousel模式),默认 true
		showControls?: boolean;     // 是否显示控制按钮(仅carousel模式),默认 true
	};
}

// 示例日记数据
const diaryData: DiaryItem[] = [
	{
		id: 1,
		content:
			"📍𝘾𝙝𝙪𝙖𝙣𝙓𝙞丨川西\n勇敢的人先享受高反再享受世界🗺️✨🤣",
		date: "2026-05-01T10:30:00Z",
		location: "阿坝藏族羌族自治州·四姑娘山景区",
		locationUrl: "https://j.map.baidu.com/cf/2M",
		images: ["https://i.postimg.cc/Z54VY6DF/1040g2sg31fatmlv6me7g5ndqintg8sfbhhno2so-nd-dft-wlteh-webp-3.webp", 
				"https://i.postimg.cc/52bn98k8/1040g2sg31fatmlv6me805ndqintg8sfbee0hv3o-nd-dft-wlteh-webp-3.webp",
				"https://i.postimg.cc/zG80DTPy/1040g2sg31fatmlv6me905ndqintg8sfbdnvlebo-nd-dft-wlteh-webp-3.webp",
				"https://i.postimg.cc/rwMQy5Yy/1040g2sg31fatmlv6me9g5ndqintg8sfbkfu6ja0-nd-dft-wlteh-webp-3.webp",
				"https://i.postimg.cc/3xYnr2bw/1040g2sg31fatmlv6meb05ndqintg8sfbe4ho350-nd-dft-wlteh-webp-3.webp",
				"https://i.postimg.cc/zG80DTPG/1040g3qg31vmkbstgjq0g4ark0mecm6c2ogerg5o-nd-dft-wlteh-webp-3.webp",
				"https://i.postimg.cc/kXxTdTwB/1040g3qg31vmkbstgjq6g4ark0mecm6c2hceerd8-nd-dft-wlteh-webp-3.webp",
				"https://i.postimg.cc/g2mNc3BL/1040g3qg31vmkgeuuia104ark0mecm6c2ensa8n8-nd-dft-wlteh-webp-3.webp",
				"https://i.postimg.cc/dt85K5ny/1040g3qg31vmkgeuuia304ark0mecm6c27chnl9g-nd-dft-wlteh-webp-3.webp",
		],
		tags: ["川西", "高反", "世界"],
		mood: "😊",
		imageDisplay: {
			type: 'grid', // 'carousel' 轮播模式 | 'grid' 网格布局模式
			autoPlay: true,
			interval: 4000,
			showIndicator: true,
			showControls: true,
		},
	},
	{
		id: 1,
		content:
			"轮播示例",
		date: "2026-05-01T10:30:00Z",
		// location: "阿坝藏族羌族自治州·四姑娘山景区",
		locationUrl: "https://j.map.baidu.com/cf/2M",
		images: ["https://tc.alcy.cc/tc/20260429/91e113df15bffb3f8bdb26815a657eb2.webp", 
				"https://tc.alcy.cc/tc/20260429/f24f72bb6ddd659014616eb988b17385.webp",
				"https://tc.alcy.cc/tc/20260429/64fd71741c204cf10b3f39c6a2c22216.webp",
				"https://tc.alcy.cc/tc/20260429/3203d4425f7c3c8704ecc63d59fad1be.webp",
		],
		tags: ["轮播示例"],
		mood: "😊",
		imageDisplay: {
			type: 'carousel', // 'carousel' 轮播模式 | 'grid' 网格布局模式
			autoPlay: true,
			interval: 4000,
			showIndicator: true,
			showControls: true,
		},
	},
	{
		id: 2,
		content:
			"YouTube",
		date: "2026-05-01T10:30:00Z",
		// location: "YouTube示例视频",
		// locationUrl: "https://j.map.baidu.com/cf/2M",
		images: [],
		video: "https://www.youtube.com/embed/5gIf0_xpFPI?si=N1WTorLKL0uwLsU_",

		tags: ["YouTube"],
		mood: "😊",
	},
	{
		id: 2,
		content:
			"Bilibili",
		date: "2026-05-01T10:30:00Z",
		// location: "Bilibili示例视频",
		locationUrl: "https://j.map.baidu.com/cf/2M",
		images: [],
		video: "https://www.bilibili.com/video/BV1uzRjBAEjL?t=3.6",
		tags: ["Bilibili"],
		mood: "😊",
	}
];

// 获取日记列表(按时间倒序)
export const getDiaryList = (limit?: number) => {
	const sortedData = [...diaryData].sort(
		(a, b) => new Date(b.date).getTime() - new Date(a.date).getTime(),
	);

	if (limit && limit > 0) {
		return sortedData.slice(0, limit);
	}

	return sortedData;
};

// 获取所有标签
export const getAllTags = () => {
	const tags = new Set<string>();
	diaryData.forEach((item) => {
		if (item.tags) {
			item.tags.forEach((tag) => tags.add(tag));
		}
	});
	return Array.from(tags).sort();
};

创建日记页面组件 ​

文件路径src/pages/diary.astro

astro

---
import { FilterTabs } from "../components/atoms";
import { MomentCard } from "@components/features/diary";
import MainGridLayout from "@layouts/MainGridLayout.astro";
import { Icon } from "astro-icon/components";

import { siteConfig } from "../config";
import { getAllTags, getDiaryList } from "../data/diary";
import I18nKey from "../i18n/i18nKey";
import { i18n } from "../i18n/translation";

if (!siteConfig.pages.diary) {
	return Astro.redirect("/404/");
}

const moments = getDiaryList();
const allTags = getAllTags();

const filterTabs = [
	{
		value: "all",
		label: i18n(I18nKey.diary),
		icon: "material-symbols:apps",
		count: moments.length,
	},
	...allTags.map((tag) => ({
		value: tag,
		label: tag,
		count: moments.filter((m) => m.tags && m.tags.includes(tag)).length,
	})),
];

const minutesAgo = i18n(I18nKey.diaryMinutesAgo);
const hoursAgo = i18n(I18nKey.diaryHoursAgo);
const daysAgo = i18n(I18nKey.diaryDaysAgo);

const title = i18n(I18nKey.diary);
const subtitle = i18n(I18nKey.diarySubtitle);
---

<MainGridLayout title={title} description={subtitle}>
	<script is:inline src="/js/filter-tabs-handler.js"></script>

	<div
		class="flex w-full rounded-[var(--radius-large)] overflow-hidden relative min-h-32"
	>
		<div class="card-base z-10 relative w-full overflow-hidden">
			<!-- Gradient Banner -->
			<div class="px-6 sm:px-9 py-6">
				<div
					class="rounded-xl bg-gradient-to-r from-[var(--primary)] to-[var(--primary-dark,var(--primary))] p-5 sm:p-6"
				>
					<div class="flex items-center justify-between gap-4">
						<div class="min-w-0">
							<h1
								class="text-2xl sm:text-3xl font-bold text-white mb-1 drop-shadow-sm truncate"
							>
								{title}
							</h1>
							<p
								class="text-sm sm:text-base text-white/80 truncate"
							>
								{subtitle}
							</p>
						</div>
						<div class="shrink-0 text-center">
							<span
								class="block text-2xl sm:text-3xl font-bold text-white drop-shadow-sm"
							>
								{moments.length}
							</span>
							<span
								class="block text-xs sm:text-sm text-white/70"
							>
								{i18n(I18nKey.diaryCount)}
							</span>
						</div>
					</div>
				</div>
			</div>

			<!-- Content -->
			<div class="px-6 sm:px-9 pt-2 pb-6">
				{
					moments.length > 0 && allTags.length > 0 && (
						<div class="mb-8">
							<FilterTabs tabs={filterTabs} dataAttr="tags" />
						</div>
					)
				}

				<div id="diary-list" class="space-y-4">
					{
						moments.map((moment, index) => (
							<MomentCard
								moment={moment}
								index={index}
								minutesAgo={minutesAgo}
								hoursAgo={hoursAgo}
								daysAgo={daysAgo}
							/>
						))
					}
				</div>

				<div id="no-results" class="hidden text-center py-16">
					<Icon
						name="material-symbols:edit-note"
						class="text-6xl text-black/15 dark:text-white/15 mb-4"
					/>
					<p class="text-black/40 dark:text-white/40 text-lg">
						{i18n(I18nKey.diaryNoResults)}
					</p>
				</div>

				{
					moments.length === 0 && (
						<div class="text-center py-16">
							<Icon
								name="material-symbols:edit-note"
								class="text-6xl text-black/15 dark:text-white/15 mb-4"
							/>
							<h3 class="text-lg font-medium text-black/90 dark:text-white/90 mb-2">
								{i18n(I18nKey.diaryNoResults)}
							</h3>
						</div>
					)
				}

				<div
					class="text-center mt-8 text-black/50 dark:text-white/50 text-sm italic"
				>
					{i18n(I18nKey.diaryTips)}
				</div>
			</div>
		</div>
	</div>
</MainGridLayout>

更新类型配置链接 ​

  • types 类型的 LinkPreset 中添加类型定义,文件路径src/types/config.ts

  • constants 类型的 link-presets 添加到导航链接,文件路径src/constants/link-presets.ts

config.tslink-presets.ts

ts

export enum LinkPreset {
	Diary = 9, // ✨ 新增
}

ts

export const LinkPresets: { [key in LinkPreset]: NavBarLink } = {
	[LinkPreset.Diary]: {
		name: i18n(I18nKey.diary),
		url: "/diary/",
		icon: "material-symbols:book",
	},
}

页面开关导航栏配置 ​

  • SiteConfig 类型中添加页面开关配置,文件路径src/config/siteConfig.ts

  • navBarConfig 类型中更新导航栏配置,文件路径src/config/navBarConfig.ts

siteConfig.tsnavBarConfig.ts

ts

// 页面开关配置
pages: {
    // ... 其他配置
    diary: true,  // ✨ 新增 - 开启日记页面
},

// 日记页面配置 ✨ 新增
diary: {
    // 默认头像
    defaultAvatar: "https://i.postimg.cc/7YLVJqnp/wei-xin-tu-pian-2026-05-07-020150-883.jpg",
},

ts

// 关于及其子菜单
links.push({
    name: "更多",
    url: "/content/",
    icon: "material-symbols:info",
    children: [
        // ... 其他子菜单项

        // ✨ 新增:根据配置决定是否添加日记
        ...(siteConfig.pages.diary ? [LinkPreset.Diary] : []),

        // 关于页面
        LinkPreset.About,
    ],
});

添加时区属性 ​

文件路径src/utils/timeFormat.ts

timeFormat.ts

ts

import { siteConfig } from "../config";

/**
 * Format relative time for diary moments
 * @param dateString ISO date string
 * @param minutesAgo text for minutes
 * @param hoursAgo text for hours
 * @param daysAgo text for days
 */
export function formatRelativeTime(
	dateString: string,
	minutesAgo: string,
	hoursAgo: string,
	daysAgo: string,
): string {
	let timeGap = 8; // Default UTC+8
	const timezone = siteConfig.timezone;
	if (timezone) {
		const match = timezone.match(/([+-]\d{2}):?(\d{2})?$/);
		if (match) {
			timeGap = parseInt(match[1], 10);
		} else if (timezone === "Asia/Shanghai") {
			timeGap = 8;
		} else if (timezone === "UTC") {
			timeGap = 0;
		}
	}

	const now = new Date();
	const utc = now.getTime() + now.getTimezoneOffset() * 60 * 1000;
	const localNow = utc + timeGap * 60 * 60 * 1000;
	const date = new Date(dateString);
	const diffInMinutes = Math.floor((localNow - date.getTime()) / (1000 * 60));

	if (diffInMinutes < 60) {
		return `${diffInMinutes}${minutesAgo}`;
	}
	if (diffInMinutes < 1440) {
		const hours = Math.floor(diffInMinutes / 60);
		return `${hours}${hoursAgo}`;
	}
	const days = Math.floor(diffInMinutes / 1440);
	return `${days}${daysAgo}`;
}

添加国际化翻译 ​

  • 添加翻译键,文件路径src/i18n/i18nKey.ts

  • 添加英文翻译,文件路径src/i18n/languages/en.ts

  • 添加中文翻译,文件路径src/i18n/languages/zh_CN.ts

  • 添加繁体中文翻译,文件路径src/i18n/languages/zh_TW.ts

  • 添加日文翻译,文件路径src/i18n/languages/ja.ts

  • 添加俄文翻译,文件路径src/i18n/languages/ru.ts

i18nKey.tsen.tszh_CN.tszh_TW.tsja.tsru.ts

ts

export enum I18nKey {
    // ... 其他键

    // ✨ 日记页面
    diary = "diary",
	diarySubtitle = "diarySubtitle",
	diaryCount = "diaryCount",
	diaryMinutesAgo = "diaryMinutesAgo",
	diaryHoursAgo = "diaryHoursAgo",
	diaryDaysAgo = "diaryDaysAgo",
	diaryNoResults = "diaryNoResults",
	diaryTips = "diaryTips",
}

ts

	// Diary Page
	[Key.diary]: "Diary",
	[Key.diarySubtitle]: "Recording daily moments",
	[Key.diaryCount]: "Moments",
	[Key.diaryMinutesAgo]: "m",
	[Key.diaryHoursAgo]: "h",
	[Key.diaryDaysAgo]: "d",
	[Key.diaryNoResults]: "No moments found",
	[Key.diaryTips]: "Every moment is precious",

ts

	// 日记页面
	[Key.diary]: "日记",
	[Key.diarySubtitle]: "记录生活点滴",
	[Key.diaryCount]: "条动态",
	[Key.diaryMinutesAgo]: "分钟前",
	[Key.diaryHoursAgo]: "小时前",
	[Key.diaryDaysAgo]: "天前",
	[Key.diaryNoResults]: "暂无动态",
	[Key.diaryTips]: "每一个瞬间都值得珍藏",

ts

	// 日記頁面
	[Key.diary]: "日記",
	[Key.diarySubtitle]: "記錄生活點滴",
	[Key.diaryCount]: "條動態",
	[Key.diaryMinutesAgo]: "分鐘前",
	[Key.diaryHoursAgo]: "小時前",
	[Key.diaryDaysAgo]: "天前",
	[Key.diaryNoResults]: "暫無動態",
	[Key.diaryTips]: "每一個瞬間都值得珍藏",

ts

	// 日記ページ
	[Key.diary]: "日記",
	[Key.diarySubtitle]: "日常の瞬間を記録する",
	[Key.diaryCount]: "件",
	[Key.diaryMinutesAgo]: "分前",
	[Key.diaryHoursAgo]: "時間前",
	[Key.diaryDaysAgo]: "日前",
	[Key.diaryNoResults]: "投稿がありません",
	[Key.diaryTips]: "すべての瞬間は貴重です",

ts

	// Страница дневника
	[Key.diary]: "Дневник",
	[Key.diarySubtitle]: "Записываю повседневные моменты",
	[Key.diaryCount]: "записей",
	[Key.diaryMinutesAgo]: "мин",
	[Key.diaryHoursAgo]: "ч",
	[Key.diaryDaysAgo]: "д",
	[Key.diaryNoResults]: "Записей нет",
	[Key.diaryTips]: "Каждый момент ценен",

其他配置 ​

默认头像配置 ​

文件路径src/config/siteConfig.ts

配置项类型说明
defaultAvatarstring默认头像URL

ts

diary: {
    defaultAvatar: "https://i.postimg.cc/7YLVJqnp/wei-xin-tu-pian-2026-05-07-020150-883.jpg",
}

📱 响应式布局 ​

日记页面图片网格响应式规则:

图片数量布局说明
1张单列大图最大宽度 400-500px
2张双列平均分配,最大宽度 500-560px
3张1大+2小第一张占两行高,最大宽度 500-560px
4+张3×3网格固定三列,最大宽度 600-700px

🎨 样式定制 ​

自定义头像大小 ​

MomentCard.astro新增

css

.bg-gray-100 {
    /* 默认头像大小40px */
    width: 40px; 
    height: 40px;
	}

自定义头像形状 ​

文件路径src/components/features/diary/MomentCard.astro修改其 class 属性:

  • 圆形:rounded-full
  • 圆角方形:rounded-lg(当前使用)
  • 方形:rounded-none

html

<!-- 头像 -->
<div class="flex-shrink-0">
    <img
        src={avatarUrl}
        alt="avatar"
        class="w-10 h-10 rounded-lg object-cover bg-gray-100 dark:bg-gray-800"
    />
</div>

修改图片网格列数 ​

MomentCard.astro<style> 中修改:

css

.diary-images-grid {
    grid-template-columns: repeat(3, 1fr);  /* 修改3数字即可 */
}


❓ 常见问题 ​

1.图片无法加载 ​

解决方案

  • 检查图片 URL 是否可访问
  • 如果是小红书等防盗链图片,建议下载到本地或使用图床
  • 可以在 siteConfig.imageOptimization.noReferrerDomains 中添加域名

2. 时间显示不正确 ​

解决方案

  • 检查 siteConfig.timezone 配置

配置教程 ​

有关日记页面配置教程,请参考 日记页面配置教程