Skip to content
0

日记页面

前言

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

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 日记卡片组件

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

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

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

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 配置

配置教程

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

最近更新