日记页面
前言
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.astro和src/components/atoms/index.ts
---
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>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日记卡片组件
export { default as MomentCard } from "./MomentCard.astro";
export * from "./types";import type { DiaryItem } from "../../../data/diary";
export interface MomentCardProps {
moment: DiaryItem;
index: number;
minutesAgo: string;
hoursAgo: string;
daysAgo: string;
}---
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
// 日记数据配置
// 用于管理日记页面的数据
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
---
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
export enum LinkPreset {
Diary = 9, // ✨ 新增
}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
// 页面开关配置
pages: {
// ... 其他配置
diary: true, // ✨ 新增 - 开启日记页面
},
// 日记页面配置 ✨ 新增
diary: {
// 默认头像
defaultAvatar: "https://i.postimg.cc/7YLVJqnp/wei-xin-tu-pian-2026-05-07-020150-883.jpg",
},// 关于及其子菜单
links.push({
name: "更多",
url: "/content/",
icon: "material-symbols:info",
children: [
// ... 其他子菜单项
// ✨ 新增:根据配置决定是否添加日记
...(siteConfig.pages.diary ? [LinkPreset.Diary] : []),
// 关于页面
LinkPreset.About,
],
});添加时区属性
文件路径:src/utils/timeFormat.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
export enum I18nKey {
// ... 其他键
// ✨ 日记页面
diary = "diary",
diarySubtitle = "diarySubtitle",
diaryCount = "diaryCount",
diaryMinutesAgo = "diaryMinutesAgo",
diaryHoursAgo = "diaryHoursAgo",
diaryDaysAgo = "diaryDaysAgo",
diaryNoResults = "diaryNoResults",
diaryTips = "diaryTips",
} // 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", // 日记页面
[Key.diary]: "日记",
[Key.diarySubtitle]: "记录生活点滴",
[Key.diaryCount]: "条动态",
[Key.diaryMinutesAgo]: "分钟前",
[Key.diaryHoursAgo]: "小时前",
[Key.diaryDaysAgo]: "天前",
[Key.diaryNoResults]: "暂无动态",
[Key.diaryTips]: "每一个瞬间都值得珍藏", // 日記頁面
[Key.diary]: "日記",
[Key.diarySubtitle]: "記錄生活點滴",
[Key.diaryCount]: "條動態",
[Key.diaryMinutesAgo]: "分鐘前",
[Key.diaryHoursAgo]: "小時前",
[Key.diaryDaysAgo]: "天前",
[Key.diaryNoResults]: "暫無動態",
[Key.diaryTips]: "每一個瞬間都值得珍藏", // 日記ページ
[Key.diary]: "日記",
[Key.diarySubtitle]: "日常の瞬間を記録する",
[Key.diaryCount]: "件",
[Key.diaryMinutesAgo]: "分前",
[Key.diaryHoursAgo]: "時間前",
[Key.diaryDaysAgo]: "日前",
[Key.diaryNoResults]: "投稿がありません",
[Key.diaryTips]: "すべての瞬間は貴重です", // Страница дневника
[Key.diary]: "Дневник",
[Key.diarySubtitle]: "Записываю повседневные моменты",
[Key.diaryCount]: "записей",
[Key.diaryMinutesAgo]: "мин",
[Key.diaryHoursAgo]: "ч",
[Key.diaryDaysAgo]: "д",
[Key.diaryNoResults]: "Записей нет",
[Key.diaryTips]: "Каждый момент ценен",其他配置
默认头像配置
文件路径:src/config/siteConfig.ts
| 配置项 | 类型 | 说明 |
|---|---|---|
defaultAvatar | string | 默认头像URL |
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 中新增:
.bg-gray-100 {
/* 默认头像大小40px */
width: 40px;
height: 40px;
}自定义头像形状
文件路径:src/components/features/diary/MomentCard.astro修改其 class 属性:
- 圆形:
rounded-full - 圆角方形:
rounded-lg(当前使用) - 方形:
rounded-none
<!-- 头像 -->
<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> 中修改:
.diary-images-grid {
grid-template-columns: repeat(3, 1fr); /* 修改3数字即可 */
}❓ 常见问题
1.图片无法加载
解决方案:
- 检查图片 URL 是否可访问
- 如果是小红书等防盗链图片,建议下载到本地或使用图床
- 可以在
siteConfig.imageOptimization.noReferrerDomains中添加域名
2. 时间显示不正确
解决方案:
- 检查
siteConfig.timezone配置
配置教程
有关日记页面配置教程,请参考 日记页面配置教程
