Initial commit

This commit is contained in:
2026-02-02 22:47:52 +03:00
committed by GitHub
commit f53016aeda
239 changed files with 84360 additions and 0 deletions

74
src/pages/404.astro Normal file
View File

@@ -0,0 +1,74 @@
---
import { Icon } from "astro-icon/components";
import I18nKey from "@i18n/i18nKey";
import { i18n } from "@i18n/translation";
import GridLayout from "@layouts/grid.astro";
---
<GridLayout title={i18n(I18nKey.notFound)} description={i18n(I18nKey.notFoundDescription)}>
<div class="flex w-full rounded-(--radius-large) overflow-hidden relative min-h-96">
<div class="card-base z-10 px-9 py-12 relative w-full flex flex-col items-center justify-center text-center">
<!-- 404 大号数字 -->
<div class="text-8xl md:text-9xl font-bold text-(--primary) opacity-20 mb-4">
{i18n(I18nKey.notFound)}
</div>
<!-- 404 图标 -->
<div class="mb-6">
<Icon name="material-symbols:error-outline" class="text-6xl text-(--primary)" />
</div>
<!-- 标题 -->
<h1 class="text-3xl md:text-4xl font-bold mb-4 text-90">
{i18n(I18nKey.notFoundTitle)}
</h1>
<!-- 描述 -->
<p class="text-lg text-75 mb-8 max-w-md">
{i18n(I18nKey.notFoundDescription)}
</p>
<!-- 返回首页按钮 -->
<a
href="/"
class="inline-flex items-center gap-2 px-6 py-3 bg-(--primary) text-white rounded-(--radius-large) hover:bg-(--primary-dark) transition-colors duration-300 font-medium"
>
<Icon name="material-symbols:home" class="text-xl" />
{i18n(I18nKey.backToHome)}
</a>
<!-- 装饰性元素 -->
<div class="absolute top-4 left-4 opacity-10">
<Icon name="material-symbols:sentiment-sad" class="text-4xl text-(--primary)" />
</div>
<div class="absolute bottom-4 right-4 opacity-10">
<Icon name="material-symbols:search-off" class="text-4xl text-(--primary)" />
</div>
</div>
</div>
</GridLayout>
<style>
/* 添加一些动画效果 */
.card-base {
animation: fadeInUp 0.6s ease-out;
}
@keyframes fadeInUp {
from {
opacity: 0;
translate: 0 30px;
}
to {
opacity: 1;
translate: 0 0;
}
}
/* 按钮悬停效果 */
a:hover {
translate: 0 -2px;
box-shadow: 0 4px 12px rgba(var(--primary-rgb), 0.3);
}
</style>

112
src/pages/[...menu].astro Normal file
View File

@@ -0,0 +1,112 @@
---
import { Icon } from "astro-icon/components";
import { type NavbarLink } from "@/types/config";
import { navbarConfig } from "@/config";
import { LinkPresets } from "@constants/link-presets";
import { url } from "@utils/url";
import GridLayout from "@layouts/grid.astro";
import BackwardButton from "@components/backwardButton.astro";
export async function getStaticPaths() {
return navbarConfig.links
.filter(link => typeof link !== "number" && link.children && link.children.length > 0)
.map(link => {
const navLink = link as NavbarLink;
// 提取 slug去掉前后的斜杠
const slug = navLink.url.replace(/^\/|\/$/g, '');
return {
params: { menu: slug },
props: { link: navLink }
};
});
}
interface Props {
link: NavbarLink;
}
const { link } = Astro.props;
// 获取子链接,并将 LinkPreset 转换为完整的 NavbarLink 对象
const childrenLinks = link.children || [];
const processedLinks = childrenLinks.map(child => {
if (typeof child === "number") {
return LinkPresets[child];
}
return child;
});
const pageTitle = link.name;
const pageDescription = link.description || "";
---
<GridLayout title={pageTitle} description={pageDescription}>
<div class="flex w-full rounded-(--radius-large) overflow-hidden relative min-h-32">
<div class="card-base z-10 px-9 py-6 relative w-full">
<BackwardButton currentPath={Astro.url.pathname} />
<!-- 标题 -->
<header class="mb-8">
<h1 class="text-3xl font-bold text-neutral-900 dark:text-neutral-100 mb-3">
{pageTitle}
</h1>
{pageDescription && (
<p class="text-neutral-600 dark:text-neutral-400">
{pageDescription}
</p>
)}
</header>
<!-- 链接网格 -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{processedLinks.map(child => (
<a
href={child.external ? child.url : url(child.url)}
target={child.external ? "_blank" : null}
class="menu-card group flex flex-col items-center justify-center p-8 rounded-xl border transition-all duration-300"
>
<div class="icon-wrapper w-16 h-16 flex items-center justify-center rounded-full mb-4 transition-all duration-300">
{child.icon && <Icon name={child.icon} class="text-3xl" />}
</div>
<h2 class="text-xl font-bold text-neutral-900 dark:text-neutral-100 group-hover:text-(--primary) transition-colors duration-300 text-center">
{child.name}
</h2>
{child.description && (
<p class="text-sm text-neutral-600 dark:text-neutral-400 text-center mt-2 line-clamp-2">
{child.description}
</p>
)}
</a>
))}
</div>
</div>
</div>
</GridLayout>
<style>
.menu-card {
cursor: pointer;
background-color: none;
border-color: var(--line-divider);
}
.icon-wrapper {
background-color: color-mix(in oklch, var(--primary), transparent 85%);
color: var(--primary);
}
.menu-card:hover {
background-color: color-mix(in oklch, var(--primary), transparent 95%);
border-color: var(--primary);
box-shadow: 0 0 20px color-mix(in oklch, var(--primary), transparent 80%);
translate: 0 -4px;
}
.menu-card:hover .icon-wrapper {
background-color: var(--primary);
color: white;
box-shadow: 0 0 15px color-mix(in oklch, var(--primary), transparent 50%);
}
</style>

29
src/pages/[...page].astro Normal file
View File

@@ -0,0 +1,29 @@
---
export const prerender = true;
import type { GetStaticPaths } from "astro";
import { PAGE_SIZE } from "@constants/constants";
import { getSortedPosts } from "@utils/content";
import Pagination from "@components/pagination.astro";
import PostPage from "@components/postPage.astro";
import GridLayout from "@layouts/grid.astro";
export const getStaticPaths = (async ({ paginate }) => {
const allBlogPosts = await getSortedPosts();
return paginate(allBlogPosts, { pageSize: PAGE_SIZE });
}) satisfies GetStaticPaths;
// https://github.com/withastro/astro/issues/6507#issuecomment-1489916992
const { page } = Astro.props;
const len = page.data.length;
---
<GridLayout>
<PostPage page={page}></PostPage>
<Pagination class="mx-auto onload-animation-up" page={page} style={`animation-delay: ${(len)*50}ms`}></Pagination>
</GridLayout>

35
src/pages/about.astro Normal file
View File

@@ -0,0 +1,35 @@
---
export const prerender = true;
import { getEntry, render } from "astro:content";
import { LinkPresets } from "@constants/link-presets";
import { LinkPreset } from "@/types/config";
import Markdown from "@components/common/markdown.astro";
import GridLayout from "@layouts/grid.astro";
import BackwardButton from "@components/backwardButton.astro";
const pageTitle = LinkPresets[LinkPreset.About].name;
const pageDescription = LinkPresets[LinkPreset.About].description;
const aboutPost = await getEntry("spec", "about");
if (!aboutPost) {
throw new Error("About page content not found");
}
const { Content } = await render(aboutPost);
---
<GridLayout title={pageTitle} description={pageDescription}>
<div class="flex w-full rounded-(--radius-large) overflow-hidden relative min-h-32">
<div class="card-base z-10 px-9 py-6 relative w-full ">
<BackwardButton currentPath={Astro.url.pathname} />
<Markdown class="mt-2">
<Content />
</Markdown>
</div>
</div>
</GridLayout>

131
src/pages/albums.astro Normal file
View File

@@ -0,0 +1,131 @@
---
import { LinkPresets } from "@constants/link-presets";
import { LinkPreset } from "@/types/config";
import { sortedAlbums } from "@utils/albums";
import { i18n } from "@i18n/translation";
import I18nKey from "@i18n/i18nKey";
import GridLayout from "@layouts/grid.astro";
import BackwardButton from "@components/backwardButton.astro";
const pageTitle = LinkPresets[LinkPreset.Albums].name;
const pageDescription = LinkPresets[LinkPreset.Albums].description;
// 获取所有相册
const albumsData = sortedAlbums.filter(album => album.visible);
---
<GridLayout title={pageTitle} description={pageDescription}>
<div class="flex w-full rounded-(--radius-large) overflow-hidden relative min-h-32">
<div class="card-base z-10 px-9 py-6 relative w-full">
<BackwardButton currentPath={Astro.url.pathname} />
<!-- 页面标题 -->
<header class="mb-8">
<h1 class="text-3xl font-bold text-neutral-900 dark:text-neutral-100 mb-3">
{pageTitle}
</h1>
<p class="text-neutral-600 dark:text-neutral-400">
{pageDescription}
</p>
</header>
<!-- 相册网格 -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{albumsData.map(album => (
<article
class="album-card group bg-white dark:bg-neutral-900 rounded-lg overflow-hidden shadow-sm hover:shadow-md transition-all duration-300 border border-neutral-200 dark:border-neutral-800"
>
<a href={`/albums/${album.id}/`} class="block">
<!-- 封面图片 -->
<div class="aspect-4/3 overflow-hidden bg-neutral-100 dark:bg-neutral-700">
<img
src={album.cover}
alt={album.title}
class="w-full h-full object-cover group-hover:scale-105 transition duration-300"
loading="lazy"
/>
</div>
<!-- 相册信息 -->
<div class="p-4">
<h3 class="font-bold text-lg text-neutral-900 dark:text-neutral-100 mb-2 group-hover:text-(--primary) transition-colors">
{album.title}
</h3>
{album.description && (
<p class="text-neutral-600 dark:text-neutral-400 text-sm mb-3 line-clamp-2">
{album.description}
</p>
)}
<!-- 元数据 -->
<div class="flex items-center justify-between text-xs text-neutral-500 dark:text-neutral-500">
<div class="flex items-center gap-4">
<span>{album.photos.length} {album.photos.length > 1 ? i18n(I18nKey.albumsPhotosCount) : i18n(I18nKey.albumsPhotoCount)}</span>
{album.location && (
<span class="flex items-center gap-1">
<svg class="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" clip-rule="evenodd"></path>
</svg>
{album.location}
</span>
)}
</div>
<time>{new Date(album.date).toLocaleDateString('zh-CN')}</time>
</div>
<!-- 标签 -->
{album.tags && album.tags.length > 0 && (
<div class="flex flex-wrap gap-1 mt-3">
{album.tags.slice(0, 3).map(tag => (
<span class="btn-regular h-6 text-xs px-2 rounded-lg">
{tag}
</span>
))}
{album.tags.length > 3 && (
<span class="btn-regular h-6 text-xs px-2 rounded-lg">
+{album.tags.length - 3}
</span>
)}
</div>
)}
</div>
</a>
</article>
))}
</div>
<!-- 空状态 -->
{albumsData.length === 0 && (
<div class="text-center py-12">
<div class="text-neutral-400 dark:text-neutral-600 mb-4">
<svg class="w-16 h-16 mx-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"></path>
</svg>
</div>
<h3 class="text-lg font-medium text-neutral-900 dark:text-neutral-100 mb-2">
{i18n(I18nKey.albumsEmpty)}
</h3>
<p class="text-neutral-600 dark:text-neutral-400">
{i18n(I18nKey.albumsEmptyDesc)}
</p>
</div>
)}
</div>
</div>
</GridLayout>
<style>
.album-card:hover {
translate: 0 -2px;
}
@keyframes fadeInUp {
from {
opacity: 0;
translate: 0 20px;
}
to {
opacity: 1;
translate: 0 0;
}
}
.album-card {
animation: fadeInUp 0.3s ease-out;
}
</style>

View File

@@ -0,0 +1,139 @@
---
import type { AlbumGroup } from "@utils/albums";
import { sortedAlbums } from "@utils/albums";
import { i18n } from "@i18n/translation";
import I18nKey from "@i18n/i18nKey";
import GridLayout from "@layouts/grid.astro";
import BackwardButton from "@components/backwardButton.astro";
export const getStaticPaths = async () => {
return sortedAlbums.filter(album => album.visible).map((album) => ({
params: { id: album.id },
props: { album },
}));
};
interface Props {
album: AlbumGroup;
}
const { album } = Astro.props;
if (!album) {
return Astro.redirect("/404");
}
---
<GridLayout title={album.title} description={album.description || album.title}>
<div class="flex w-full rounded-(--radius-large) overflow-hidden relative min-h-32">
<div class="card-base z-10 px-9 py-6 relative w-full">
<BackwardButton href="/albums/" text={i18n(I18nKey.albumsBackToList)} />
<!-- 相册标题信息 -->
<header class="mb-8">
<h1 class="text-3xl font-bold text-neutral-900 dark:text-neutral-100 mb-3">
{album.title}
</h1>
{album.description && (
<p class="text-neutral-600 dark:text-neutral-400 mb-4">
{album.description}
</p>
)}
<!-- 相册元数据 -->
<div class="flex flex-wrap items-center gap-4 text-sm text-neutral-500 dark:text-neutral-500">
<span>{album.photos.length} {album.photos.length > 1 ? i18n(I18nKey.albumsPhotosCount) : i18n(I18nKey.albumsPhotoCount)}</span>
<time>{new Date(album.date).toLocaleDateString('zh-CN')}</time>
{album.location && (
<span class="flex items-center gap-1">
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" clip-rule="evenodd"></path>
</svg>
{album.location}
</span>
)}
</div>
<!-- 标签 -->
{album.tags && album.tags.length > 0 && (
<div class="flex flex-wrap gap-2 mt-4">
{album.tags.map(tag => (
<span class="btn-regular h-8 text-sm px-3 rounded-lg">
{tag}
</span>
))}
</div>
)}
</header>
<!-- 照片网格 -->
<div
class={`photo-gallery moment-images ${album.layout === 'masonry' ? 'masonry-layout' : 'grid-layout'}`}
data-layout={album.layout || 'grid'}
data-columns={album.columns || 3}
>
{album.photos.map((photo, index) => (
<figure class="photo-item group relative overflow-hidden rounded-lg">
<a
href={photo.src}
data-fancybox="gallery"
data-caption={photo.alt}
data-no-swup
class="block"
>
<img
src={photo.src}
alt={photo.alt}
class="photo-image w-full h-full object-cover group-hover:scale-105 transition-transform duration-300 cursor-pointer"
loading="lazy"
/>
</a>
</figure>
))}
</div>
</div>
</div>
</GridLayout>
<style>
.grid-layout {
display: grid;
gap: 1rem;
}
.grid-layout[data-columns="2"] {
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
}
.grid-layout[data-columns="3"] {
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
}
.grid-layout[data-columns="4"] {
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
}
.masonry-layout {
columns: 2;
column-gap: 1rem;
}
@media (min-width: 768px) {
.masonry-layout {
columns: 3;
}
}
@media (min-width: 1280px) {
.masonry-layout[data-columns="4"] {
columns: 4;
}
}
.masonry-layout .photo-item {
break-inside: avoid;
margin-bottom: 1rem;
}
.grid-layout .photo-container {
aspect-ratio: 1;
}
</style>

360
src/pages/anime.astro Normal file
View File

@@ -0,0 +1,360 @@
---
export const prerender = true;
import { LinkPresets } from "@constants/link-presets";
import { LinkPreset } from "@/types/config";
import { siteConfig } from "@/config";
import I18nKey from "@i18n/i18nKey";
import { i18n } from "@i18n/translation";
import ImageWrapper from "@components/common/imageWrapper.astro";
import GridLayout from "@layouts/grid.astro";
import BackwardButton from "@components/backwardButton.astro";
const pageTitle = LinkPresets[LinkPreset.Anime].name;
const pageDescription = LinkPresets[LinkPreset.Anime].description;
// Bangumi API配置
const BANGUMI_USER_ID = siteConfig.bangumi?.userId || "";
const BANGUMI_API_BASE = "https://api.bgm.tv";
// 检查是否配置了有效的 Bangumi 用户 ID
const isBangumiConfigured =
BANGUMI_USER_ID &&
BANGUMI_USER_ID !== "your-bangumi-id" &&
BANGUMI_USER_ID !== "your-user-id";
// 获取单个条目相关人员信息
async function fetchSubjectPersons(subjectId: number) {
if (!isBangumiConfigured) return [];
try {
const response = await fetch(
`${BANGUMI_API_BASE}/v0/subjects/${subjectId}/persons`,
);
const data = await response.json();
return Array.isArray(data) ? data : [];
} catch (error) {
console.error(`Error fetching subject ${subjectId} persons:`, error);
return [];
}
}
// 获取Bangumi收藏列表
async function fetchBangumiCollection(
userId: string,
subjectType: number,
type: number,
) {
if (!isBangumiConfigured) return null;
try {
let allData: any[] = [];
let offset = 0;
const limit = 50; // 每页获取的数量
let hasMore = true;
// 循环获取所有数据
while (hasMore) {
const response = await fetch(
`${BANGUMI_API_BASE}/v0/users/${userId}/collections?subject_type=${subjectType}&type=${type}&limit=${limit}&offset=${offset}`,
);
if (!response.ok) {
throw new Error(`Bangumi API error: ${response.status}`);
}
const data = await response.json();
// 添加当前页数据到总数据中
if (data.data && data.data.length > 0) {
allData = [...allData, ...data.data];
}
if (!data.data || data.data.length < limit) {
hasMore = false;
} else {
offset += limit;
}
// 防止请求过于频繁
await new Promise((resolve) => setTimeout(resolve, 100));
}
return { data: allData };
} catch (error) {
console.error("Error fetching Bangumi data:", error);
return null;
}
}
// 获取Bangumi数据转换为页面所需格式
async function processBangumiData(data: any, status: string) {
if (!data || !data.data) return [];
// 为每个条目获取详细信息
const detailedItems = await Promise.all(
data.data.map(async (item: any) => {
// 获取相关人员信息
const subjectPersons = await fetchSubjectPersons(item.subject_id);
// 获取年份信息
const year = item.subject?.date || "Unknown";
// 获取评分
const rating = item.rate ? Number.parseFloat(item.rate.toFixed(1)) : 0;
// 获取进度信息
const progress = item.ep_status || 0;
const totalEpisodes = item.subject?.eps || progress;
// 从相关人员中获取制作方信息
let studio = "Unknown";
if (Array.isArray(subjectPersons)) {
// 定义筛选优先级顺序
const priorities = ["动画制作", "製作", "制作"];
for (const relation of priorities) {
const match = subjectPersons.find(
(person) => person.relation === relation,
);
if (match?.name) {
studio = match.name;
break;
}
}
}
return {
title: item.subject?.name_cn || item.subject?.name || "Unknown Title",
status: status,
rating: rating,
cover: item.subject?.images?.medium || "/assets/anime/default.webp",
description: (
item.subject?.short_summary ||
item.subject?.name_cn ||
""
).trimStart(),
episodes: `${totalEpisodes} episodes`,
year: year,
genre: item.subject?.tags
? item.subject.tags.slice(0, 3).map((tag: any) => tag.name)
: ["Unknown"],
studio: studio,
link: `https://bgm.tv/subject/${item.subject.id}` || "#",
progress: progress,
totalEpisodes: totalEpisodes,
startDate: item.subject?.date || "",
endDate: item.subject?.date || "",
};
}),
);
return detailedItems;
}
// 获取Bangumi番剧列表
const watchingData = await fetchBangumiCollection(BANGUMI_USER_ID, 2, 3);
const completedData = await fetchBangumiCollection(BANGUMI_USER_ID, 2, 2);
const watchingList = watchingData
? await processBangumiData(watchingData, "watching")
: [];
const completedList = completedData
? await processBangumiData(completedData, "completed")
: [];
const animeList = [...watchingList, ...completedList];
// 获取状态的翻译文本和样式
function getStatusInfo(status: string) {
switch (status) {
case "watching":
return {
text: i18n(I18nKey.animeStatusWatching),
class:
"bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300",
icon: "▶",
};
case "completed":
return {
text: i18n(I18nKey.animeStatusCompleted),
class:
"bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300",
icon: "✓",
};
case "planned":
return {
text: i18n(I18nKey.animeStatusPlanned),
class:
"bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300",
icon: "⏰",
};
default:
return {
text: status,
class: "bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300",
icon: "?",
};
}
}
// 计算统计数据
const stats = {
total: animeList.length,
watching: animeList.filter((anime) => anime.status === "watching").length,
completed: animeList.filter((anime) => anime.status === "completed").length,
avgRating: (() => {
const ratedAnime = animeList.filter((anime) => anime.rating > 0);
if (ratedAnime.length === 0) return "0.0";
return (
ratedAnime.reduce((sum, anime) => sum + anime.rating, 0) /
ratedAnime.length
).toFixed(1);
})(),
};
---
<GridLayout title={pageTitle} description={pageDescription}>
<div class="flex w-full rounded-(--radius-large) overflow-hidden relative min-h-32">
<div class="card-base z-10 px-9 py-6 relative w-full">
<BackwardButton currentPath={Astro.url.pathname} />
<!-- 页面标题 -->
<div class="relative w-full mb-8">
<div class="mb-6">
<h1 class="text-4xl font-bold text-black/90 dark:text-white/90 mb-2">{i18n(I18nKey.animeTitle)}</h1>
<p class="text-black/75 dark:text-white/75">{pageDescription}</p>
</div>
<!-- 统计卡片 -->
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
<div class="bg-linear-to-br from-blue-50 to-blue-100 dark:from-blue-900/20 dark:to-blue-800/20 rounded-lg p-4">
<div class="flex items-center gap-3">
<div class="text-2xl">📊</div>
<div>
<div class="text-2xl font-bold text-blue-600 dark:text-blue-400">{stats.total}</div>
<div class="text-sm text-blue-600/70 dark:text-blue-400/70">{i18n(I18nKey.animeTotal)}</div>
</div>
</div>
</div>
<div class="bg-linear-to-br from-green-50 to-green-100 dark:from-green-900/20 dark:to-green-800/20 rounded-lg p-4">
<div class="flex items-center gap-3">
<div class="text-2xl">▶️</div>
<div>
<div class="text-2xl font-bold text-green-600 dark:text-green-400">{stats.watching}</div>
<div class="text-sm text-green-600/70 dark:text-green-400/70">{i18n(I18nKey.animeWatching)}</div>
</div>
</div>
</div>
<div class="bg-linear-to-br from-purple-50 to-purple-100 dark:from-purple-900/20 dark:to-purple-800/20 rounded-lg p-4">
<div class="flex items-center gap-3">
<div class="text-2xl">✅</div>
<div>
<div class="text-2xl font-bold text-purple-600 dark:text-purple-400">{stats.completed}</div>
<div class="text-sm text-purple-600/70 dark:text-purple-400/70">{i18n(I18nKey.animeCompleted)}</div>
</div>
</div>
</div>
<div class="bg-linear-to-br from-orange-50 to-orange-100 dark:from-orange-900/20 dark:to-orange-800/20 rounded-lg p-4">
<div class="flex items-center gap-3">
<div class="text-2xl">⭐</div>
<div>
<div class="text-2xl font-bold text-orange-600 dark:text-orange-400">{stats.avgRating}</div>
<div class="text-sm text-orange-600/70 dark:text-orange-400/70">{i18n(I18nKey.animeAvgRating)}</div>
</div>
</div>
</div>
</div>
</div>
<!-- 动漫列表 -->
<div class="mb-8">
<h2 class="text-2xl font-bold text-black/90 dark:text-white/90 mb-4">
{i18n(I18nKey.animeList)}
</h2>
{!isBangumiConfigured ? (
<div class="text-center py-12">
<div class="text-5xl mb-4">😢</div>
<h3 class="text-xl font-medium text-black/80 dark:text-white/80 mb-2">
{i18n(I18nKey.animeEmpty)}
</h3>
<p class="text-black/60 dark:text-white/60">
Please configure your Bangumi account in the config file.
</p>
</div>
) : animeList.length > 0 ? (
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4 md:gap-6">
{animeList.map(anime => {
const statusInfo = getStatusInfo(anime.status);
const progressPercent = anime.totalEpisodes > 0 ? (anime.progress / anime.totalEpisodes) * 100 : 0;
return (
<div class="group relative bg-(--card-bg) border border-(--line-divider) rounded-(--radius-large) overflow-hidden transition-all duration-300 hover:shadow-lg hover:scale-[1.02]">
<!-- 封面区域 - 竖屏比例 -->
<div class="relative aspect-2/3 overflow-hidden">
<a href={anime.link} target="_blank" rel="noopener noreferrer" class="block w-full h-full">
<ImageWrapper
src={anime.cover}
alt={anime.title}
class="w-full h-full object-cover transition-transform duration-300 group-hover:scale-110"
loading="lazy"
/>
<div class="absolute inset-0 bg-linear-to-t from-black/60 via-transparent to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300">
<div class="absolute inset-0 flex items-center justify-center">
<div class="w-12 h-12 rounded-full bg-white/90 flex items-center justify-center">
<svg class="w-6 h-6 text-gray-800 ml-1" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z"/>
</svg>
</div>
</div>
</div>
</a>
<!-- 状态标签 -->
<div class={`absolute top-2 left-2 px-2 py-1 rounded-md text-xs font-medium ${statusInfo.class}`}>
<span class="mr-1">{statusInfo.icon}</span>
<span>{statusInfo.text}</span>
</div>
<!-- 评分 -->
<div class="absolute top-2 right-2 bg-black/70 text-white px-2 py-1 rounded-md text-xs font-medium flex items-center gap-1">
<svg class="w-3 h-3 text-yellow-400" fill="currentColor" viewBox="0 0 20 20">
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z"/>
</svg>
<span>{anime.rating}</span>
</div>
<!-- 进度条 - 在封面底部 -->
{anime.status === 'watching' && (
<div class="absolute bottom-0 left-0 right-0 bg-linear-to-t from-black/80 to-transparent p-2">
<div class="w-full bg-white/20 rounded-full h-1.5 mb-1">
<div class="bg-linear-to-r from-emerald-400 to-teal-400 h-1.5 rounded-full transition-all duration-300" style={`width: ${progressPercent}%`}></div>
</div>
<div class="text-white text-xs font-medium">
{anime.progress}/{anime.totalEpisodes} ({Math.round(progressPercent)}%)
</div>
</div>
)}
</div>
<!-- 内容区域 - 紧凑设计 -->
<div class="p-3">
<h3 class="text-sm font-bold text-black/90 dark:text-white/90 mb-1 line-clamp-2 leading-tight">{anime.title}</h3>
<p class="text-black/60 dark:text-white/60 text-xs mb-2 line-clamp-2">{anime.description}</p>
<!-- 详细信息 - 更紧凑 -->
<div class="space-y-1 text-xs">
<div class="flex justify-between">
<span class="text-black/50 dark:text-white/50">{i18n(I18nKey.animeYear)}</span>
<span class="text-black/70 dark:text-white/70">{anime.year}</span>
</div>
<div class="flex justify-between">
<span class="text-black/50 dark:text-white/50">{i18n(I18nKey.animeStudio)}</span>
<span class="text-black/70 dark:text-white/70 truncate ml-2">{anime.studio}</span>
</div>
<div class="flex flex-wrap gap-1 mt-2">
{anime.genre.map(g => (
<span class="px-1.5 py-0.5 bg-(--btn-regular-bg) text-black/70 dark:text-white/70 rounded-sm text-xs">{g}</span>
))}
</div>
</div>
</div>
</div>
);
})}
</div>
) : (
<div class="text-center py-12">
<div class="text-5xl mb-4">😢</div>
<h3 class="text-xl font-medium text-black/80 dark:text-white/80 mb-2">
{i18n(I18nKey.animeEmpty)}
</h3>
<p class="text-black/60 dark:text-white/60">
{i18n(I18nKey.animeEmptyBangumi)}
</p>
</div>
)}
</div>
</div>
</div>
</GridLayout>

35
src/pages/archive.astro Normal file
View File

@@ -0,0 +1,35 @@
---
import { LinkPresets } from "@constants/link-presets";
import { LinkPreset } from "@/types/config";
import { getSortedPostsList } from "@utils/content";
import ArchivePanel from "@components/archivePanel.svelte";
import GridLayout from "@layouts/grid.astro";
import BackwardButton from "@components/backwardButton.astro";
const pageTitle = LinkPresets[LinkPreset.Archive].name;
const pageDescription = LinkPresets[LinkPreset.Archive].description;
const sortedPostsList = await getSortedPostsList();
---
<GridLayout title={pageTitle} description={pageDescription}>
<div class="flex w-full rounded-(--radius-large) overflow-hidden relative min-h-32">
<div class="card-base z-10 px-9 py-6 relative w-full ">
<BackwardButton currentPath={Astro.url.pathname} />
<!-- 标题 -->
<header class="mb-8">
<h1 class="text-3xl font-bold text-neutral-900 dark:text-neutral-100 mb-3">
{pageTitle}
</h1>
{pageDescription && (
<p class="text-neutral-600 dark:text-neutral-400">
{pageDescription}
</p>
)}
</header>
<!-- 归档面板 -->
<ArchivePanel sortedPosts={sortedPostsList} client:load></ArchivePanel>
</div>
</div>
</GridLayout>

134
src/pages/atom.astro Normal file
View File

@@ -0,0 +1,134 @@
---
import { Icon } from "astro-icon/components";
import { getSortedPosts } from "@utils/content";
import { formatDateToYYYYMMDD } from "@utils/date";
import { i18n } from "@i18n/translation";
import I18nKey from "@i18n/i18nKey";
import GridLayout from "@layouts/grid.astro";
const posts = (await getSortedPosts()).filter((post) => !post.data.encrypted);
const recentPosts = posts.slice(0, 6);
---
<GridLayout title={i18n(I18nKey.atom)} description={i18n(I18nKey.atomDescription)}>
<div class="onload-animation-up">
<!-- Atom 标题和介绍 -->
<div class="card-base rounded-(--radius-large) p-8 mb-6">
<div class="text-center">
<div class="inline-flex items-center justify-center w-16 h-16 bg-(--primary) rounded-2xl mb-4">
<Icon name="material-symbols:rss-feed" class="text-white text-3xl" />
</div>
<h1 class="text-3xl font-bold text-(--primary) mb-3">{i18n(I18nKey.atom)}</h1>
<p class="text-75 max-w-2xl mx-auto">
{i18n(I18nKey.atomSubtitle)}
</p>
</div>
</div>
<!-- Atom 链接复制区域 -->
<div class="card-base rounded-(--radius-large) p-6 mb-6">
<div class="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-4">
<div class="flex items-center">
<div class="w-12 h-12 bg-(--primary) rounded-xl flex items-center justify-center mr-4">
<Icon name="material-symbols:link" class="text-white text-xl" />
</div>
<div>
<h3 class="font-semibold text-90 mb-1">{i18n(I18nKey.atomLink)}</h3>
<p class="text-sm text-75">{i18n(I18nKey.atomCopyToReader)}</p>
</div>
</div>
<div class="flex flex-col sm:flex-row gap-3">
<code class="bg-(--card-bg) px-3 py-2 rounded-lg text-sm font-mono text-75 border border-(--line-divider) break-all">
{Astro.site}atom.xml
</code>
<button
id="copy-atom-btn"
class="px-4 py-2 bg-(--primary) text-white rounded-lg hover:opacity-80 transition-all duration-200 font-medium text-sm whitespace-nowrap"
data-url={`${Astro.site}atom.xml`}
>
{i18n(I18nKey.atomCopyLink)}
</button>
</div>
</div>
</div>
<!-- 最新文章预览 -->
<div class="card-base rounded-(--radius-large) p-6 mb-6">
<h2 class="text-xl font-bold text-90 mb-4 flex items-center">
<Icon name="material-symbols:article" class="mr-2 text-(--primary)" />
{i18n(I18nKey.atomLatestPosts)}
</h2>
<div class="space-y-4">
{recentPosts.map((post) => (
<article class="bg-(--card-bg) rounded-xl p-4 border border-(--line-divider) hover:border-(--primary) transition-all duration-300">
<h3 class="text-lg font-semibold text-90 mb-2 hover:text-(--primary) transition-colors">
<a href={`/posts/${post.id}/`} class="hover:underline">
{post.data.title}
</a>
</h3>
{post.data.description && (
<p class="text-75 mb-3 line-clamp-2">
{post.data.description}
</p>
)}
<div class="flex items-center gap-4 text-sm text-60">
<time datetime={post.data.published.toISOString()} class="text-75">
{formatDateToYYYYMMDD(post.data.published)}
</time>
</div>
</article>
))}
</div>
</div>
<!-- Atom 说明 -->
<div class="card-base rounded-(--radius-large) p-6">
<h2 class="text-xl font-bold text-90 mb-4 flex items-center">
<Icon name="material-symbols:help-outline" class="mr-2 text-(--primary)" />
{i18n(I18nKey.atomWhatIsAtom)}
</h2>
<div class="text-75 space-y-3">
<p>
{i18n(I18nKey.atomWhatIsAtomDescription)}
</p>
<ul class="list-disc list-inside space-y-1 ml-4">
<li>{i18n(I18nKey.atomBenefit1)}</li>
<li>{i18n(I18nKey.atomBenefit2)}</li>
<li>{i18n(I18nKey.atomBenefit3)}</li>
<li>{i18n(I18nKey.atomBenefit4)}</li>
</ul>
</div>
</div>
</div>
<script>
// Copy button functionality
const init = () => {
const copyBtn = document.getElementById('copy-atom-btn');
if (copyBtn) {
copyBtn.addEventListener('click', async function() {
const url = this.getAttribute('data-url');
if (!url) return;
try {
await navigator.clipboard.writeText(url);
const originalText = this.textContent || "Copy Link";
this.textContent = "Link Copied";
setTimeout(() => {
this.textContent = originalText;
}, 2000);
} catch (err) {
console.error('复制失败:', err);
const originalText = this.textContent || "Copy Link";
this.textContent = "Copy Failed";
setTimeout(() => {
this.textContent = originalText;
}, 2000);
}
});
}
}
init();
document.addEventListener("astro:after-swap", init);
</script>
</GridLayout>

134
src/pages/atom.xml.ts Normal file
View File

@@ -0,0 +1,134 @@
import { getImage } from "astro:assets";
import { parse as htmlParser } from "node-html-parser";
import type { APIContext, ImageMetadata } from "astro";
import MarkdownIt from "markdown-it";
import sanitizeHtml from "sanitize-html";
import { siteConfig, profileConfig } from "@/config";
import { getSortedPosts } from "@utils/content";
import { getFileDirFromPath, getPostUrl } from "@utils/url";
const markdownParser = new MarkdownIt();
// get dynamic import of images as a map collection
const imagesGlob = import.meta.glob<{ default: ImageMetadata }>(
"/src/content/**/*.{jpeg,jpg,png,gif,webp}", // include posts and assets
);
export async function GET(context: APIContext) {
if (!context.site) {
throw Error("site not set");
}
// Use the same ordering as site listing (pinned first, then by published desc)
// 过滤掉加密文章和草稿文章
const posts = (await getSortedPosts()).filter((post) => !post.data.encrypted && post.data.draft !== true);
// 创建Atom feed头部
let atomFeed = `<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
<title>${siteConfig.title}</title>
<subtitle>${siteConfig.subtitle || "No description"}</subtitle>
<link href="${context.site}" rel="alternate" type="text/html"/>
<link href="${new URL("atom.xml", context.site)}" rel="self" type="application/atom+xml"/>
<id>${context.site}</id>
<updated>${new Date().toISOString()}</updated>
<language>${siteConfig.lang}</language>`;
for (const post of posts) {
// convert markdown to html string, ensure post.body is a string
const body = markdownParser.render(String(post.body ?? ""));
// convert html string to DOM-like structure
const html = htmlParser.parse(body);
// hold all img tags in variable images
const images = html.querySelectorAll("img");
for (const img of images) {
const src = img.getAttribute("src");
if (!src) continue;
// Handle content-relative images and convert them to built _astro paths
if (
src.startsWith("./") ||
src.startsWith("../") ||
(!src.startsWith("http") && !src.startsWith("/"))
) {
let importPath: string | null = null;
// derive base directory from real file path to preserve casing
const contentDirRaw = post.filePath
? getFileDirFromPath(post.filePath)
: "src/content/posts";
const contentDir = contentDirRaw.startsWith("src/")
? contentDirRaw
: `src/${contentDirRaw}`;
if (src.startsWith("./")) {
// Path relative to the post file directory
const prefixRemoved = src.slice(2);
importPath = `/${contentDir}/${prefixRemoved}`;
} else if (src.startsWith("../")) {
// Path like ../assets/images/xxx -> relative to /src/content/
const cleaned = src.replace(/^\.\.\//, "");
importPath = `/src/content/${cleaned}`;
} else {
// direct filename (no ./ prefix) - assume it's in the same directory as the post
importPath = `/${contentDir}/${src}`;
}
// import the image module dynamically
const imageMod = await imagesGlob[importPath]?.()?.then(
(res) => res.default,
);
if (imageMod) {
// optimize the image and get the final src URL
const optimizedImg = await getImage({ src: imageMod });
img.setAttribute("src", new URL(optimizedImg.src, context.site).href);
} else {
// log the failed import path
console.log(
`Failed to load image: ${importPath} for post: ${post.id}`,
);
}
} else if (src.startsWith("/")) {
// images starting with `/` are in public dir
img.setAttribute("src", new URL(src, context.site).href);
}
}
// 添加Atom条目
const postUrl = new URL(getPostUrl(post), context.site).href;
const content = sanitizeHtml(html.toString(), {
allowedTags: sanitizeHtml.defaults.allowedTags.concat(["img"]),
});
atomFeed += `
<entry>
<title>${post.data.title}</title>
<link href="${postUrl}" rel="alternate" type="text/html"/>
<id>${postUrl}</id>
<published>${post.data.published.toISOString()}</published>
<updated>${post.data.updated?.toISOString() || post.data.published.toISOString()}</updated>
<summary>${post.data.description || ""}</summary>
<content type="html"><![CDATA[${content}]]></content>
<author>
<name>${profileConfig.name}</name>
</author>`;
// 添加分类标签
if (post.data.category) {
atomFeed += `
<category term="${post.data.category}"></category>`;
}
// 添加标签
atomFeed += `
</entry>`;
}
// 关闭Atom feed
atomFeed += `
</feed>`;
return new Response(atomFeed, {
headers: {
"Content-Type": "application/atom+xml; charset=utf-8",
},
});
}

260
src/pages/diary.astro Normal file
View File

@@ -0,0 +1,260 @@
---
import { LinkPresets } from "@constants/link-presets";
import { LinkPreset } from "@/types/config";
import { siteConfig } from "@/config";
import { sortedMoments } from "@/utils/diary";
import { i18n } from "@i18n/translation";
import I18nKey from "@i18n/i18nKey";
import GridLayout from "@layouts/grid.astro";
import BackwardButton from "@components/backwardButton.astro";
const pageTitle = LinkPresets[LinkPreset.Diary].name;
const pageDescription = LinkPresets[LinkPreset.Diary].description;
// 时间格式化函数
function formatTime(dateString: string): string {
var TG = 8;
if (siteConfig.timeZone >= -12 && siteConfig.timeZone <= 12) TG = siteConfig.timeZone;
const timeGap = TG;
const now = new Date();
const date = new Date(dateString);
const diffInMinutes = Math.floor(
(now.getTime() + timeGap*60*60*1000 - date.getTime()) / (1000 * 60),
);
if (diffInMinutes < 60) {
return `${diffInMinutes}${i18n(I18nKey.diaryMinutesAgo)}`;
}
if (diffInMinutes < 1440) {
// 24小时
const hours = Math.floor(diffInMinutes / 60);
return `${hours}${i18n(I18nKey.diaryHoursAgo)}`;
}
const days = Math.floor(diffInMinutes / 1440);
return `${days}${i18n(I18nKey.diaryDaysAgo)}`;
}
---
<GridLayout title={pageTitle} description={pageDescription}>
<div class="flex w-full rounded-(--radius-large) overflow-hidden relative min-h-32">
<div class="card-base z-10 px-4 py-4 md:px-6 md:py-5 relative w-full">
<BackwardButton currentPath={Astro.url.pathname} />
<div class="relative max-w-4xl">
<!-- 页面头部 -->
<div class="moments-header mb-6">
<div class="header-content">
<div class="header-info">
<h1 class="moments-title text-xl md:text-2xl lg:text-3xl font-bold text-90 mb-1">{pageTitle}</h1>
<p class="moments-subtitle text-sm md:text-base lg:text-lg text-75">{pageDescription}</p>
</div>
<div class="header-stats">
<div class="stat-item text-center">
<span class="stat-number text-lg md:text-xl lg:text-2xl font-bold text-(--primary)">{sortedMoments.length}</span>
<span class="stat-label text-xs md:text-sm lg:text-base text-75">{i18n(I18nKey.diaryCount)}</span>
</div>
</div>
</div>
</div>
<!-- 短文列表 -->
<div class="moments-timeline">
<div class="timeline-list space-y-4">
{sortedMoments.map(moment => (
<div class="moment-item card-base p-4 md:p-6 lg:p-8 hover:shadow-lg transition-all">
<div class="moment-content">
<p class="moment-text text-sm md:text-base lg:text-lg text-90 leading-relaxed mb-3 md:mb-4">{moment.content}</p>
{moment.images && moment.images.length > 0 && (
<div class="moment-images grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-2 md:gap-3 lg:gap-4 mb-3 md:mb-4">
{moment.images.map((image, index) => (
<div class="image-item relative rounded-md overflow-hidden aspect-square cursor-pointer hover:scale-105 transition">
<img
src={image}
alt={i18n(I18nKey.diaryImage)}
class="w-full h-full object-cover"
loading="lazy"
/>
</div>
))}
</div>
)}
</div>
<hr class="moment-divider border-t border-(--line-divider) my-3 md:my-4" />
<div class="moment-footer flex justify-between items-center">
<div class="moment-time flex items-center gap-1.5 text-75 text-xs md:text-sm lg:text-base">
<i class="time-icon text-xs md:text-sm">🕐</i>
<time datetime={moment.date}>
{formatTime(moment.date)}
</time>
</div>
</div>
</div>
))}
</div>
</div>
<!-- 底部提示 -->
<div class="moments-tips text-center mt-6 md:mt-8 lg:mt-10 text-75 text-xs md:text-sm lg:text-base italic">
{i18n(I18nKey.diaryTips)}
</div>
</div>
</div>
</div>
</GridLayout>
<style>
.card-base {
background: var(--card-bg);
border: 1px solid var(--line-divider);
transition: all 0.3s ease;
}
.moments-header {
padding: 1rem;
border-radius: 8px;
background: linear-gradient(135deg, var(--primary) 0%, var(--primary-dark, var(--primary)) 100%);
color: white;
position: relative;
overflow: hidden;
}
.header-content {
display: flex;
justify-content: space-between;
align-items: center;
position: relative;
z-index: 1;
}
.moments-title {
color: white;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
}
.moments-subtitle {
color: rgba(255, 255, 255, 0.9);
}
.stat-number {
color: white;
}
.stat-label {
color: rgba(255, 255, 255, 0.8);
}
.image-item img {
transition: scale 0.3s ease;
}
.image-item:hover img {
scale: 1.05;
}
.action-btn {
background: none;
border: none;
cursor: pointer;
color: var(--text-muted);
}
.action-btn:hover {
color: var(--primary);
}
/* 让内容中的换行符生效(\n -> 换行) */
.moment-text {
white-space: pre-line;
}
/* 响应式设计 */
/* 桌面端 (大于1280px) */
@media (min-width: 1280px) {
.moments-header {
padding: 1.5rem;
}
.moment-item {
padding: 2rem;
}
.moment-images {
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
max-width: 600px;
gap: 1rem;
}
.moment-text {
font-size: 1.1rem;
line-height: 1.8;
}
}
/* 平板竖屏 (768px - 1279px) - 优化显示 */
@media (min-width: 768px) and (max-width: 1279px) {
.moments-header {
padding: 1.25rem;
}
.header-content {
display: flex;
justify-content: space-between;
align-items: center;
}
.moment-item {
padding: 1.5rem;
}
.moment-images {
grid-template-columns: repeat(3, 1fr);
gap: 0.75rem;
max-width: 500px;
}
.moment-text {
font-size: 1rem;
line-height: 1.7;
}
.moment-footer {
margin-top: 1rem;
}
}
/* 手机端 (小于768px) */
@media (max-width: 768px) {
.moments-header {
padding: 0.75rem;
}
.header-content {
flex-direction: column;
text-align: center;
gap: 0.75rem;
}
.moment-images {
grid-template-columns: repeat(2, 1fr);
}
.moment-footer {
flex-direction: column;
align-items: flex-start;
gap: 0.5rem;
}
}
/* 优化小屏幕显示 */
@media (max-width: 512px) {
.card-base {
margin: 0 -0.5rem;
}
.moment-item {
border-radius: 8px;
}
.moments-header {
border-radius: 6px;
}
}
</style>

114
src/pages/friends.astro Normal file
View File

@@ -0,0 +1,114 @@
---
import { getEntry, render } from "astro:content";
import { LinkPresets } from "@constants/link-presets";
import { LinkPreset } from "@/types/config";
import { friendsData } from "@utils/friends";
import Markdown from "@components/common/markdown.astro";
import GridLayout from "@layouts/grid.astro";
import BackwardButton from "@components/backwardButton.astro";
const pageTitle = LinkPresets[LinkPreset.Friends].name;
const pageDescription = LinkPresets[LinkPreset.Friends].description;
const friendsPost = await getEntry("spec", "friends");
if (!friendsPost) {
throw new Error("friends page content not found");
}
const { Content } = await render(friendsPost);
const items = friendsData;
function shuffleArray(array) {
const newArray = [...array];
for (let i = newArray.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[newArray[i], newArray[j]] = [newArray[j], newArray[i]];
}
return newArray;
}
const shuffledItems = shuffleArray(items);
---
<GridLayout title={pageTitle} description={pageDescription}>
<div class="flex w-full rounded-(--radius-large) overflow-hidden relative min-h-32">
<div class="card-base z-10 px-9 py-6 relative w-full ">
<BackwardButton currentPath={Astro.url.pathname} />
<!-- 标题 -->
<header class="mb-8">
<h1 class="text-3xl font-bold text-neutral-900 dark:text-neutral-100 mb-3">
{pageTitle}
</h1>
{pageDescription && (
<p class="text-neutral-600 dark:text-neutral-400">
{pageDescription}
</p>
)}
</header>
<!-- 链接网格 -->
<div class="grid grid-cols-1 sm:grid-cols-2 gap-6 my-4">
{shuffledItems.map((item) => (
<a href={item.siteurl} target="_blank" rel="noopener noreferrer" class="menu-card group flex flex-row items-center p-4 rounded-xl border transition-all duration-300 gap-4">
<div class="icon-wrapper w-20 h-20 shrink-0 flex items-center justify-center rounded-full transition-all duration-300 overflow-hidden bg-zinc-200 dark:bg-zinc-900">
<img src={item.imgurl} alt={item.title} class="w-full h-full object-cover transition duration-300 group-hover:scale-110" />
</div>
<div class="flex flex-col justify-center overflow-hidden">
<h2 class="text-lg font-bold text-neutral-900 dark:text-neutral-100 group-hover:text-(--primary) transition-colors duration-300 truncate">
{item.title}
</h2>
<p class="text-sm text-neutral-600 dark:text-neutral-400 line-clamp-1 mt-1">
{item.desc}
</p>
{item.tags && item.tags.length > 0 && (
<div class="flex flex-wrap gap-1.5 mt-2">
{item.tags.slice(0, 2).map((tag) => (
<span class="text-[10px] px-2 py-0.5 rounded-full bg-neutral-100 dark:bg-neutral-800 text-neutral-500 dark:text-neutral-400 border border-neutral-200 dark:border-neutral-700">
{tag}
</span>
))}
{item.tags.length > 2 && (
<span class="text-[10px] px-2 py-0.5 text-neutral-400">
+{item.tags.length - 2}
</span>
)}
</div>
)}
</div>
</a>
))}
</div>
<Markdown class="mt-2">
<Content />
</Markdown>
</div>
</div>
</GridLayout>
<style>
.menu-card {
cursor: pointer;
background-color: none;
border-color: var(--line-divider);
}
.icon-wrapper {
background-color: color-mix(in oklch, var(--primary), transparent 85%);
color: var(--primary);
}
.menu-card:hover {
background-color: color-mix(in oklch, var(--primary), transparent 95%);
border-color: var(--primary);
box-shadow: 0 0 20px color-mix(in oklch, var(--primary), transparent 80%);
translate: 0 -4px;
}
.menu-card:hover .icon-wrapper {
background-color: var(--primary);
color: white;
box-shadow: 0 0 15px color-mix(in oklch, var(--primary), transparent 50%);
}
</style>

View File

@@ -0,0 +1,342 @@
import { getCollection } from "astro:content";
import type { APIContext, GetStaticPaths } from "astro";
import type { CollectionEntry } from "astro:content";
import * as fs from "node:fs";
import satori from "satori";
import sharp from "sharp";
import { profileConfig, siteConfig } from "@/config";
import { defaultFavicons } from "@constants/icon";
type Weight = 100 | 200 | 300 | 400 | 500 | 600 | 700 | 800 | 900;
type FontStyle = "normal" | "italic";
interface FontOptions {
data: Buffer | ArrayBuffer;
name: string;
weight?: Weight;
style?: FontStyle;
lang?: string;
}
export const prerender = true;
export const getStaticPaths: GetStaticPaths = async () => {
if (!siteConfig.generateOgImages) {
return [];
}
const allPosts = await getCollection("posts");
const publishedPosts = allPosts.filter((post) => !post.data.draft);
return publishedPosts.map((post) => ({
params: { slug: post.id },
props: { post },
}));
};
let fontCache: { regular: Buffer | null; bold: Buffer | null } | null = null;
async function fetchNotoSansSCFonts() {
if (fontCache) {
return fontCache;
}
try {
const cssResp = await fetch(
"https://fonts.googleapis.com/css2?family=Noto+Sans+SC:wght@400;700&display=swap",
);
if (!cssResp.ok) throw new Error("Failed to fetch Google Fonts CSS");
const cssText = await cssResp.text();
const getUrlForWeight = (weight: number) => {
const blockRe = new RegExp(
`@font-face\\s*{[^}]*font-weight:\\s*${weight}[^}]*}`,
"g",
);
const match = cssText.match(blockRe);
if (!match || match.length === 0) return null;
const urlMatch = match[0].match(/url\((https:[^)]+)\)/);
return urlMatch ? urlMatch[1] : null;
};
const regularUrl = getUrlForWeight(400);
const boldUrl = getUrlForWeight(700);
if (!regularUrl || !boldUrl) {
console.warn(
"Could not find font urls in Google Fonts CSS; falling back to no fonts.",
);
fontCache = { regular: null, bold: null };
return fontCache;
}
const [rResp, bResp] = await Promise.all([
fetch(regularUrl),
fetch(boldUrl),
]);
if (!rResp.ok || !bResp.ok) {
console.warn(
"Failed to download font files from Google; falling back to no fonts.",
);
fontCache = { regular: null, bold: null };
return fontCache;
}
const rBuf = Buffer.from(await rResp.arrayBuffer());
const bBuf = Buffer.from(await bResp.arrayBuffer());
fontCache = { regular: rBuf, bold: bBuf };
return fontCache;
} catch (err) {
console.warn("Error fetching fonts:", err);
fontCache = { regular: null, bold: null };
return fontCache;
}
}
export async function GET({
props,
}: APIContext<{ post: CollectionEntry<"posts"> }>) {
const { post } = props;
// Try to fetch fonts from Google Fonts (woff2) at runtime.
const { regular: fontRegular, bold: fontBold } = await fetchNotoSansSCFonts();
// Avatar + icon: still read from disk (small assets)
let avatarPath = `./public${profileConfig.avatar}`;
const avatarBuffer = fs.readFileSync(avatarPath);
const avatarBase64 = `data:image/png;base64,${avatarBuffer.toString("base64")}`;
let iconPath = `./public${defaultFavicons[0].src}`;
if (siteConfig.favicon.length > 0) {
iconPath = `./public${siteConfig.favicon[0].src}`;
}
const iconBuffer = fs.readFileSync(iconPath);
const iconBase64 = `data:image/png;base64,${iconBuffer.toString("base64")}`;
const hue = siteConfig.themeColor.hue;
const primaryColor = `hsl(${hue}, 90%, 65%)`;
const textColor = "hsl(0, 0%, 95%)";
const subtleTextColor = `hsl(${hue}, 10%, 75%)`;
const backgroundColor = `hsl(${hue}, 15%, 12%)`;
const pubDate = post.data.published.toLocaleDateString("en-US", {
year: "numeric",
month: "short",
day: "numeric",
});
const description = post.data.description;
const template = {
type: "div",
props: {
style: {
height: "100%",
width: "100%",
display: "flex",
flexDirection: "column",
backgroundColor: backgroundColor,
fontFamily:
'"Noto Sans SC", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif',
padding: "60px",
},
children: [
{
type: "div",
props: {
style: {
width: "100%",
display: "flex",
alignItems: "center",
gap: "20px",
},
children: [
{
type: "img",
props: {
src: iconBase64,
width: 48,
height: 48,
style: { borderRadius: "10px" },
},
},
{
type: "div",
props: {
style: {
fontSize: "36px",
fontWeight: 600,
color: subtleTextColor,
},
children: siteConfig.title,
},
},
],
},
},
{
type: "div",
props: {
style: {
display: "flex",
flexDirection: "column",
justifyContent: "center",
flexGrow: 1,
gap: "20px",
},
children: [
{
type: "div",
props: {
style: {
display: "flex",
alignItems: "flex-start",
},
children: [
{
type: "div",
props: {
style: {
width: "10px",
height: "68px",
backgroundColor: primaryColor,
borderRadius: "6px",
marginTop: "14px",
},
},
},
{
type: "div",
props: {
style: {
fontSize: "72px",
fontWeight: 700,
lineHeight: 1.2,
color: textColor,
marginLeft: "25px",
display: "-webkit-box",
overflow: "hidden",
textOverflow: "ellipsis",
lineClamp: 3,
WebkitLineClamp: 3,
WebkitBoxOrient: "vertical",
},
children: post.data.title,
},
},
],
},
},
description && {
type: "div",
props: {
style: {
fontSize: "32px",
lineHeight: 1.5,
color: subtleTextColor,
paddingLeft: "35px",
display: "-webkit-box",
overflow: "hidden",
textOverflow: "ellipsis",
lineClamp: 2,
WebkitLineClamp: 2,
WebkitBoxOrient: "vertical",
},
children: description,
},
},
],
},
},
{
type: "div",
props: {
style: {
display: "flex",
justifyContent: "space-between",
alignItems: "center",
width: "100%",
},
children: [
{
type: "div",
props: {
style: {
display: "flex",
alignItems: "center",
gap: "20px",
},
children: [
{
type: "img",
props: {
src: avatarBase64,
width: 60,
height: 60,
style: { borderRadius: "50%" },
},
},
{
type: "div",
props: {
style: {
fontSize: "28px",
fontWeight: 600,
color: textColor,
},
children: profileConfig.name,
},
},
],
},
},
{
type: "div",
props: {
style: { fontSize: "28px", color: subtleTextColor },
children: pubDate,
},
},
],
},
},
],
},
};
const fonts: FontOptions[] = [];
if (fontRegular) {
fonts.push({
name: "Noto Sans SC",
data: fontRegular,
weight: 400,
style: "normal",
});
}
if (fontBold) {
fonts.push({
name: "Noto Sans SC",
data: fontBold,
weight: 700,
style: "normal",
});
}
const svg = await satori(template, {
width: 1200,
height: 630,
fonts,
});
const png = await sharp(Buffer.from(svg)).png().toBuffer();
return new Response(new Uint8Array(png), {
headers: {
"Content-Type": "image/png",
"Cache-Control": "public, max-age=31536000, immutable",
},
});
}

View File

@@ -0,0 +1,358 @@
---
import { Icon } from "astro-icon/components";
import { render } from "astro:content";
import type { CollectionEntry } from "astro:content";
import dayjs from "dayjs";
import utc from "dayjs/plugin/utc";
import { siteConfig, profileConfig, postConfig } from "@/config";
import { getSortedPosts } from "@utils/content";
import { formatDateToYYYYMMDD } from "@utils/date";
import { getFileDirFromPath, getPostUrl, getPostUrlBySlug, removeFileExtension} from "@utils/url";
import { i18n } from "@i18n/translation";
import I18nKey from "@i18n/i18nKey";
import License from "@components/post/license.astro";
import Markdown from "@components/common/markdown.astro";
import ImageWrapper from "@components/common/imageWrapper.astro";
import Comment from "@components/post/comment.astro";
import PasswordProtection from "@components/common/passwordProtection.astro";
import PostMetadata from "@components/post/postMeta.astro";
import GridLayout from "@layouts/grid.astro";
export async function getStaticPaths() {
const blogEntries = await getSortedPosts();
const paths: {
params: { slug: string };
props: { entry: CollectionEntry<"posts"> };
}[] = [];
for (const entry of blogEntries) {
// 将 id 转换为 slug移除扩展名以匹配路由参数
const slug = removeFileExtension(entry.id);
// 为每篇文章创建默认的 slug 路径
paths.push({
params: { slug },
props: { entry },
});
// 如果文章有自定义固定链接,也创建对应的路径
if (entry.data.routeName) {
// 移除开头的斜杠和结尾的斜杠
// 同时移除可能的 "posts/" 前缀,避免重复
let routeName = entry.data.routeName
.replace(/^\/+/, "")
.replace(/\/+$/, "");
if (routeName.startsWith("posts/")) {
routeName = routeName.replace(/^posts\//, "");
}
paths.push({
params: { slug: routeName },
props: { entry },
});
}
}
return paths;
}
const { entry } = Astro.props;
const { Content, headings } = await render(entry);
const { remarkPluginFrontmatter } = await render(entry);
// 处理加密逻辑
let isEncrypted = !!(entry.data.encrypted && entry.data.password);
dayjs.extend(utc);
const lastModified = dayjs(entry.data.updated || entry.data.published)
.utc()
.format("YYYY-MM-DDTHH:mm:ss");
const jsonLd = {
"@context": "https://schema.org",
"@type": "BlogPosting",
headline: entry.data.title,
description: entry.data.description || entry.data.title,
keywords: entry.data.tags,
author: {
"@type": "Person",
name: profileConfig.name,
url: Astro.site,
},
datePublished: formatDateToYYYYMMDD(entry.data.published),
inLanguage: entry.data.lang
? entry.data.lang.replace("_", "-")
: siteConfig.lang.replace("_", "-"),
// TODO include cover image here
};
---
<GridLayout
banner={entry.data.cover}
title={entry.data.title}
description={entry.data.description}
lang={entry.data.lang}
setOGTypeArticle={true}
postSlug={entry.id}
headings={headings}
>
<script
is:inline
slot="head"
type="application/ld+json"
set:html={JSON.stringify(jsonLd)}
/>
<div
class="flex w-full rounded-(--radius-large) overflow-hidden relative mb-4"
>
<div
id="post-container"
class:list={[
"card-base z-10 px-6 md:px-9 pt-6 pb-4 relative w-full ",
{},
]}
>
<!-- word count and reading time -->
<div
class="flex flex-row text-black/30 dark:text-white/30 gap-5 mb-3 transition onload-animation-up"
>
<div class="flex flex-row items-center">
<div
class="transition h-6 w-6 rounded-md bg-black/5 dark:bg-white/10 text-black/50 dark:text-white/50 flex items-center justify-center mr-2"
>
<Icon name="material-symbols:notes-rounded" />
</div>
<div class="text-sm">
{remarkPluginFrontmatter.words}
{" " + i18n(I18nKey.wordsCount)}
</div>
</div>
<div class="flex flex-row items-center">
<div
class="transition h-6 w-6 rounded-md bg-black/5 dark:bg-white/10 text-black/50 dark:text-white/50 flex items-center justify-center mr-2"
>
<Icon
name="material-symbols:schedule-outline-rounded"
/>
</div>
<div class="text-sm">
{remarkPluginFrontmatter.minutes}
{
" " +
i18n(
remarkPluginFrontmatter.minutes === 1
? I18nKey.minuteCount
: I18nKey.minutesCount,
)
}
</div>
</div>
</div>
<!-- title -->
<div class="relative onload-animation-up">
<div
data-pagefind-body
data-pagefind-weight="10"
data-pagefind-meta="title"
class="transition w-full block font-bold mb-3
text-3xl md:text-[2.25rem]/[2.75rem]
text-black/90 dark:text-white/90
md:before:w-1 before:h-5 before:rounded-md before:bg-(--primary)
before:absolute before:top-3 before:-left-4.5"
>
{entry.data.title}
</div>
</div>
<!-- metadata -->
<div class="onload-animation-up">
<PostMetadata
className="mb-5"
published={entry.data.published}
updated={entry.data.updated}
tags={entry.data.tags}
category={entry.data.category}
postUrl={getPostUrl(entry)}
/>
{
(!entry.data.cover || (entry.data.cover && !postConfig.showCoverInContent)) && (
<div class="mt-4 border-(--line-divider) border-dashed border-b mb-5" />
)
}
</div>
<!-- always show cover as long as it has one -->
{
entry.data.cover && postConfig.showCoverInContent && (
<>
<div class="mt-4" />
<ImageWrapper
id="post-cover"
src={entry.data.cover}
basePath={getFileDirFromPath(entry.filePath || '')}
class="mb-8 rounded-xl banner-container onload-animation-up"
loading="lazy"
/>
</>
)
}
<PasswordProtection
isEncrypted={isEncrypted}
password={entry.data.password}
>
<Markdown class="mb-6 markdown-content onload-animation-up">
<Content />
</Markdown>
{
postConfig.license.enable && (
<License
title={entry.data.title}
id={entry.id}
pubDate={entry.data.published}
author={entry.data.author}
sourceLink={entry.data.sourceLink}
licenseName={entry.data.licenseName}
licenseUrl={entry.data.licenseUrl}
postUrl={getPostUrl(entry)}
class="mb-6 rounded-xl license-container onload-animation-up"
/>
)
}
</PasswordProtection>
</div>
</div>
<!-- 评论 -->
<Comment post={entry} />
{postConfig.showLastModified && (
<>
<div
id="last-modified"
data-last-modified={lastModified}
data-prefix={i18n(I18nKey.lastModifiedPrefix)}
data-year={i18n(I18nKey.year)}
data-month={i18n(I18nKey.month)}
data-day={i18n(I18nKey.day)}
data-hour={i18n(I18nKey.hour)}
data-minute={i18n(I18nKey.minute)}
data-second={i18n(I18nKey.second)}
style="display: none;"
></div>
<div class="card-base p-6 mb-4">
<script is:inline>
function runtime() {
const lastModifiedElement = document.getElementById('last-modified');
if (!lastModifiedElement) return;
const startDate = new Date(lastModifiedElement.dataset.lastModified);
const currentDate = new Date();
const diff = currentDate - startDate;
const seconds = Math.floor(diff / 1000);
const days = Math.floor(seconds / 86400);
const hours = Math.floor((seconds % 86400) / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const secs = seconds % 60;
const years = Math.floor(days / 365);
const months = Math.floor((days % 365) / 30);
const remainingDays = days % 30;
const prefix = lastModifiedElement.dataset.prefix;
const yearKey = lastModifiedElement.dataset.year;
const monthKey = lastModifiedElement.dataset.month;
const dayKey = lastModifiedElement.dataset.day;
const hourKey = lastModifiedElement.dataset.hour;
const minuteKey = lastModifiedElement.dataset.minute;
const secondKey = lastModifiedElement.dataset.second;
let runtimeString = prefix + ' ';
if (years > 0) {
runtimeString += `${years} ${yearKey} `;
}
if (months > 0) {
runtimeString += `${months} ${monthKey} `;
}
if (remainingDays > 0) {
runtimeString += `${remainingDays} ${dayKey} `;
}
runtimeString += `${hours} ${hourKey} `;
if (minutes < 10) {
runtimeString += `0${minutes} ${minuteKey} `;
} else {
runtimeString += `${minutes} ${minuteKey} `;
}
if (secs < 10) {
runtimeString += `0${secs} ${secondKey}`;
} else {
runtimeString += `${secs} ${secondKey}`;
}
document.getElementById("modifiedtime").innerHTML = runtimeString;
}
setInterval(runtime, 1000);
</script>
<div class="card-base p-1 mb-0.1">
<div class="flex items-center gap-2">
<div class="transition h-9 w-9 rounded-lg overflow-hidden relative flex items-center justify-center mr-0">
<Icon
name="material-symbols:history-rounded"
class="text-4xl text-(--primary) transition-transform group-hover:translate-x-0.5 bg-(--enter-btn-bg) p-2 rounded-md"
/>
</div>
<div class="flex flex-col gap-0.1">
<div id="modifiedtime" class="text-[1.0rem] leading-tight text-black/75 dark:text-white/75"></div>
<p class="text-[0.8rem] leading-tight text-black/75 dark:text-white/75">
{i18n(I18nKey.lastModifiedOutdated)}
</p>
</div>
</div>
</div>
</div>
</>
)}
<div
class="flex flex-col md:flex-row justify-between mb-4 gap-4 overflow-hidden w-full"
>
<a
href={entry.data.nextSlug
? getPostUrlBySlug(entry.data.nextSlug)
: "#"}
class:list={[
"w-full font-bold overflow-hidden active:scale-95",
{ "pointer-events-none": !entry.data.nextSlug },
]}
>
{
entry.data.nextSlug && (
<div class="btn-card rounded-2xl w-full h-15 max-w-full px-4 flex items-center justify-start! gap-4">
<Icon
name="material-symbols:chevron-left-rounded"
class="text-[2rem] text-(--primary)"
/>
<div class="overflow-hidden transition text-ellipsis whitespace-nowrap max-w-[calc(100%-3rem)] text-black/75 dark:text-white/75">
{entry.data.nextTitle}
</div>
</div>
)
}
</a>
<a
href={entry.data.prevSlug
? getPostUrlBySlug(entry.data.prevSlug)
: "#"}
class:list={[
"w-full font-bold overflow-hidden active:scale-95",
{ "pointer-events-none": !entry.data.prevSlug },
]}
>
{
entry.data.prevSlug && (
<div class="btn-card rounded-2xl w-full h-15 max-w-full px-4 flex items-center justify-end! gap-4">
<div class="overflow-hidden transition text-ellipsis whitespace-nowrap max-w-[calc(100%-3rem)] text-black/75 dark:text-white/75">
{entry.data.prevTitle}
</div>
<Icon
name="material-symbols:chevron-right-rounded"
class="text-[2rem] text-(--primary)"
/>
</div>
)
}
</a>
</div>
</GridLayout>

146
src/pages/projects.astro Normal file
View File

@@ -0,0 +1,146 @@
---
export const prerender = true;
import { LinkPresets } from "@constants/link-presets";
import { LinkPreset } from "@/types/config";
import {
projectsData,
getProjectStats,
getProjectsByCategory,
getFeaturedProjects,
getAllTechStack,
} from "@utils/projects";
import { UNCATEGORIZED } from "@constants/constants";
import { i18n } from "@i18n/translation";
import I18nKey from "@i18n/i18nKey";
import ProjectCard from "@components/data/projectCard.astro";
import GridLayout from "@layouts/grid.astro";
import BackwardButton from "@components/backwardButton.astro";
const title = LinkPresets[LinkPreset.Projects].name;
const subtitle = LinkPresets[LinkPreset.Projects].description;
// 获取项目统计信息
const stats = getProjectStats();
const featuredProjects = getFeaturedProjects();
const allTechStack = getAllTechStack();
// 获取所有分类
const categories = [
...new Set(projectsData.map((project) => project.category)),
];
// 按分类获取项目
const projectsByCategory = categories.reduce(
(acc, category) => {
acc[category] = getProjectsByCategory(category);
return acc;
},
{} as Record<string, typeof projectsData>,
);
// 获取分类文本的国际化翻译
const getCategoryText = (category: string) => {
switch (category) {
case "web":
return i18n(I18nKey.projectsWeb);
case "mobile":
return i18n(I18nKey.projectsMobile);
case "desktop":
return i18n(I18nKey.projectsDesktop);
case "other":
return i18n(I18nKey.projectsOther);
case UNCATEGORIZED:
return i18n(I18nKey.uncategorized);
default:
return category;
}
};
---
<GridLayout title={title} description={subtitle}>
<div class="flex w-full rounded-(--radius-large) overflow-hidden relative min-h-32">
<div class="card-base z-10 px-9 py-6 relative w-full">
<BackwardButton currentPath={Astro.url.pathname} />
<!-- 页面标题 -->
<div class="flex flex-col items-start justify-center mb-8">
<h1 class="text-4xl font-bold text-black/90 dark:text-white/90 mb-2">
{i18n(I18nKey.projects)}
</h1>
<p class="text-lg text-black/60 dark:text-white/60">
{i18n(I18nKey.projectsSubtitle)}
</p>
</div>
<!-- 统计信息 -->
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 mb-8">
<div class="bg-linear-to-br from-blue-50 to-blue-100 dark:from-blue-900/20 dark:to-blue-800/20 rounded-lg p-4">
<div class="text-2xl font-bold text-blue-600 dark:text-blue-400">{stats.total}</div>
<div class="text-sm text-blue-600/70 dark:text-blue-400/70">{i18n(I18nKey.projectsTotal)}</div>
</div>
<div class="bg-linear-to-br from-green-50 to-green-100 dark:from-green-900/20 dark:to-green-800/20 rounded-lg p-4">
<div class="text-2xl font-bold text-green-600 dark:text-green-400">{stats.byStatus.completed}</div>
<div class="text-sm text-green-600/70 dark:text-green-400/70">{i18n(I18nKey.projectsCompleted)}</div>
</div>
<div class="bg-linear-to-br from-yellow-50 to-yellow-100 dark:from-yellow-900/20 dark:to-yellow-800/20 rounded-lg p-4">
<div class="text-2xl font-bold text-yellow-600 dark:text-yellow-400">{stats.byStatus.inProgress}</div>
<div class="text-sm text-yellow-600/70 dark:text-yellow-400/70">{i18n(I18nKey.projectsInProgress)}</div>
</div>
<div class="bg-linear-to-br from-purple-50 to-purple-100 dark:from-purple-900/20 dark:to-purple-800/20 rounded-lg p-4">
<div class="text-2xl font-bold text-purple-600 dark:text-purple-400">{allTechStack.length}</div>
<div class="text-sm text-purple-600/70 dark:text-purple-400/70">{i18n(I18nKey.projectsTechStack)}</div>
</div>
</div>
<!-- 特色项目 -->
{featuredProjects.length > 0 && (
<div class="mb-8">
<h2 class="text-2xl font-bold text-black/90 dark:text-white/90 mb-4">
{i18n(I18nKey.projectsFeatured)}
</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
{featuredProjects.map((project) => (
<ProjectCard project={project} size="large" showImage={true} maxTechStack={4} />
))}
</div>
</div>
)}
<!-- 按分类展示项目 -->
<div class="space-y-8">
{categories.map((category) => {
const categoryProjects = projectsByCategory[category];
if (categoryProjects.length === 0) return null;
return (
<div>
<h2 class="text-2xl font-bold text-black/90 dark:text-white/90 mb-4">
{getCategoryText(category)}
<span class="text-lg font-normal text-black/60 dark:text-white/60 ml-2">
({categoryProjects.length})
</span>
</h2>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{categoryProjects.map((project) => (
<ProjectCard project={project} size="medium" showImage={true} maxTechStack={3} />
))}
</div>
</div>
);
})}
</div>
<!-- 技术栈统计 -->
<div class="mt-12 pt-8 border-t border-black/10 dark:border-white/10">
<h2 class="text-2xl font-bold text-black/90 dark:text-white/90 mb-4">
{i18n(I18nKey.projectsTechStack)}
</h2>
<div class="flex flex-wrap gap-2">
{allTechStack.map((tech) => (
<span class="px-3 py-1 text-sm bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300 rounded-full">
{tech}
</span>
))}
</div>
</div>
</div>
</div>
</GridLayout>

17
src/pages/robots.txt.ts Normal file
View File

@@ -0,0 +1,17 @@
import type { APIRoute } from "astro";
const robotsTxt = `
User-agent: *
Disallow: /_astro/
Sitemap: ${new URL("sitemap-index.xml", import.meta.env.SITE).href}
`.trim();
export const GET: APIRoute = () => {
return new Response(robotsTxt, {
headers: {
"Content-Type": "text/plain; charset=utf-8",
},
});
};

137
src/pages/rss.astro Normal file
View File

@@ -0,0 +1,137 @@
---
import { Icon } from "astro-icon/components";
import { getSortedPosts } from "@utils/content";
import { formatDateToYYYYMMDD } from "@utils/date";
import { i18n } from "@i18n/translation";
import I18nKey from "@i18n/i18nKey";
import GridLayout from "@layouts/grid.astro";
const posts = (await getSortedPosts()).filter((post) => !post.data.encrypted);
const recentPosts = posts.slice(0, 6);
---
<GridLayout title={i18n(I18nKey.rss)} description={i18n(I18nKey.rssDescription)}>
<div class="onload-animation-up">
<!-- RSS 标题和介绍 -->
<div class="card-base rounded-(--radius-large) p-8 mb-6">
<div class="text-center">
<div class="inline-flex items-center justify-center w-16 h-16 bg-(--primary) rounded-2xl mb-4">
<Icon name="material-symbols:rss-feed" class="text-white text-3xl" />
</div>
<h1 class="text-3xl font-bold text-(--primary) mb-3">{i18n(I18nKey.rss)}</h1>
<p class="text-75 max-w-2xl mx-auto">
{i18n(I18nKey.rssSubtitle)}
</p>
</div>
</div>
<!-- RSS 链接复制区域 -->
<div class="card-base rounded-(--radius-large) p-6 mb-6">
<div class="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-4">
<div class="flex items-center">
<div class="w-12 h-12 bg-(--primary) rounded-xl flex items-center justify-center mr-4">
<Icon name="material-symbols:link" class="text-white text-xl" />
</div>
<div>
<h3 class="font-semibold text-90 mb-1">{i18n(I18nKey.rssLink)}</h3>
<p class="text-sm text-75">{i18n(I18nKey.rssCopyToReader)}</p>
</div>
</div>
<div class="flex flex-col sm:flex-row gap-3">
<code class="bg-(--card-bg) px-3 py-2 rounded-lg text-sm font-mono text-75 border border-(--line-divider) break-all">
{Astro.site}rss.xml
</code>
<button
id="copy-rss-btn"
class="px-4 py-2 bg-(--primary) text-white rounded-lg hover:opacity-80 transition-all duration-300 font-medium text-sm whitespace-nowrap"
data-url={`${Astro.site}rss.xml`}
>
{i18n(I18nKey.rssCopyLink)}
</button>
</div>
</div>
</div>
<!-- 最新文章预览 -->
<div class="card-base rounded-(--radius-large) p-6 mb-6">
<h2 class="text-xl font-bold text-90 mb-4 flex items-center">
<Icon name="material-symbols:article" class="mr-2 text-(--primary)" />
{i18n(I18nKey.rssLatestPosts)}
</h2>
<div class="space-y-4">
{recentPosts.map((post) => (
<article class="bg-(--card-bg) rounded-xl p-4 border border-(--line-divider) hover:border-(--primary) transition-all duration-300">
<h3 class="text-lg font-semibold text-90 mb-2 hover:text-(--primary) transition-colors">
<a href={`/posts/${post.id}/`} class="hover:underline">
{post.data.title}
</a>
</h3>
{post.data.description && (
<p class="text-75 mb-3 line-clamp-2">
{post.data.description}
</p>
)}
<div class="flex items-center gap-4 text-sm text-60">
<time datetime={post.data.published.toISOString()} class="text-75">
{formatDateToYYYYMMDD(post.data.published)}
</time>
</div>
</article>
))}
</div>
</div>
<!-- RSS 说明 -->
<div class="card-base rounded-(--radius-large) p-6">
<h2 class="text-xl font-bold text-90 mb-4 flex items-center">
<Icon name="material-symbols:help-outline" class="mr-2 text-(--primary)" />
{i18n(I18nKey.rssWhatIsRSS)}
</h2>
<div class="text-75 space-y-3">
<p>
{i18n(I18nKey.rssWhatIsRSSDescription)}
</p>
<ul class="list-disc list-inside space-y-1 ml-4">
<li>{i18n(I18nKey.rssBenefit1)}</li>
<li>{i18n(I18nKey.rssBenefit2)}</li>
<li>{i18n(I18nKey.rssBenefit3)}</li>
<li>{i18n(I18nKey.rssBenefit4)}</li>
</ul>
<p class="text-sm">
{i18n(I18nKey.rssHowToUse)}
</p>
</div>
</div>
</div>
<script>
// Copy button functionality
const init = () => {
const copyBtn = document.getElementById('copy-rss-btn');
if (copyBtn) {
copyBtn.addEventListener('click', async function() {
const url = this.getAttribute('data-url');
if (!url) return;
try {
await navigator.clipboard.writeText(url);
const originalText = this.textContent || "Copy Link";
this.textContent = "Link Copied";
setTimeout(() => {
this.textContent = originalText;
}, 2000);
} catch (err) {
console.error('复制失败:', err);
const originalText = this.textContent || "Copy Link";
this.textContent = "Copy Failed";
setTimeout(() => {
this.textContent = originalText;
}, 2000);
}
});
}
}
init();
document.addEventListener("astro:after-swap", init);
</script>
</GridLayout>

105
src/pages/rss.xml.ts Normal file
View File

@@ -0,0 +1,105 @@
import { getImage } from "astro:assets";
import { parse as htmlParser } from "node-html-parser";
import type { APIContext, ImageMetadata } from "astro";
import type { RSSFeedItem } from "@astrojs/rss";
import rss from "@astrojs/rss";
import MarkdownIt from "markdown-it";
import sanitizeHtml from "sanitize-html";
import { siteConfig } from "@/config";
import { getSortedPosts } from "@utils/content";
import { getFileDirFromPath, getPostUrl } from "@utils/url";
const markdownParser = new MarkdownIt();
// get dynamic import of images as a map collection
const imagesGlob = import.meta.glob<{ default: ImageMetadata }>(
"/src/content/**/*.{jpeg,jpg,png,gif,webp}", // include posts and assets
);
export async function GET(context: APIContext) {
if (!context.site) {
throw Error("site not set");
}
// Use the same ordering as site listing (pinned first, then by published desc)
const posts = (await getSortedPosts()).filter((post) => !post.data.encrypted);
const feed: RSSFeedItem[] = [];
for (const post of posts) {
// convert markdown to html string, ensure post.body is a string
const body = markdownParser.render(String(post.body ?? ""));
// convert html string to DOM-like structure
const html = htmlParser.parse(body);
// hold all img tags in variable images
const images = html.querySelectorAll("img");
// process each image tag to correct src paths
for (const img of images) {
const src = img.getAttribute("src");
if (!src) continue;
// Handle content-relative images and convert them to built _astro paths
if (
src.startsWith("./") ||
src.startsWith("../") ||
(!src.startsWith("http") && !src.startsWith("/"))
) {
let importPath: string | null = null;
// derive base directory from real file path to preserve casing
const contentDirRaw = post.filePath
? getFileDirFromPath(post.filePath)
: "src/content/posts";
const contentDir = contentDirRaw.startsWith("src/")
? contentDirRaw
: `src/${contentDirRaw}`;
if (src.startsWith("./")) {
// Path relative to the post file directory
const prefixRemoved = src.slice(2);
importPath = `/${contentDir}/${prefixRemoved}`;
} else if (src.startsWith("../")) {
// Path like ../assets/images/xxx -> relative to /src/content/
const cleaned = src.replace(/^\.\.\//, "");
importPath = `/src/content/${cleaned}`;
} else {
// direct filename (no ./ prefix) - assume it's in the same directory as the post
importPath = `/${contentDir}/${src}`;
}
// import the image module dynamically
const imageMod = await imagesGlob[importPath]?.()?.then(
(res) => res.default,
);
if (imageMod) {
const optimizedImg = await getImage({ src: imageMod });
img.setAttribute("src", new URL(optimizedImg.src, context.site).href);
} else {
// log the failed import path
console.log(
`Failed to load image: ${importPath} for post: ${post.id}`,
);
}
} else if (src.startsWith("/")) {
// images starting with `/` are in public dir
img.setAttribute("src", new URL(src, context.site).href);
}
}
feed.push({
title: post.data.title,
description: post.data.description,
pubDate: post.data.published,
link: getPostUrl(post),
// sanitize the new html string with corrected image paths
content: sanitizeHtml(html.toString(), {
allowedTags: sanitizeHtml.defaults.allowedTags.concat(["img"]),
}),
});
}
return rss({
title: siteConfig.title,
description: siteConfig.subtitle || "No description",
site: context.site,
items: feed,
customData: `<language>${siteConfig.lang}</language>`,
});
}

253
src/pages/skills.astro Normal file
View File

@@ -0,0 +1,253 @@
---
export const prerender = true;
import { LinkPresets } from "@constants/link-presets";
import { LinkPreset } from "@/types/config";
import { UNCATEGORIZED } from "@constants/constants";
import {
getAdvancedSkills,
getSkillStats,
getSkillsByCategory,
getTotalExperience,
skillsData,
} from "@utils/skills";
import { i18n } from "@i18n/translation";
import I18nKey from "@i18n/i18nKey";
import IconifyLoader from "@components/common/iconifyLoader.astro";
import SkillCard from "@components/data/skillCard.astro";
import GridLayout from "@layouts/grid.astro";
import BackwardButton from "@components/backwardButton.astro";
const title = LinkPresets[LinkPreset.Skills].name;
const subtitle = LinkPresets[LinkPreset.Skills].description;
// 获取技能统计信息
const stats = getSkillStats();
const advancedSkills = getAdvancedSkills();
const totalExperience = getTotalExperience();
// 获取所有分类
const categories = [...new Set(skillsData.map((skill) => skill.category))];
// 按分类获取技能
const skillsByCategory = categories.reduce(
(acc, category) => {
acc[category] = getSkillsByCategory(category);
return acc;
},
{} as Record<string, typeof skillsData>,
);
// 获取分类的翻译文本
const getCategoryText = (category: string) => {
switch (category) {
case "ai":
return i18n(I18nKey.skillsAI);
case "server":
return i18n(I18nKey.skillsBackend);
case "client":
return i18n(I18nKey.skillsClient);
case "web":
return i18n(I18nKey.skillsFrontend);
case "database":
return i18n(I18nKey.skillsDatabase);
case "engines":
return i18n(I18nKey.skillsEngines);
case "tools":
return i18n(I18nKey.skillsTools);
case "others":
return i18n(I18nKey.skillsOthers);
case UNCATEGORIZED:
return i18n(I18nKey.uncategorized);
default:
return category;
}
};
// 收集所有技能图标用于预加载
const allIcons = skillsData.map((skill) => skill.icon).filter(Boolean);
---
<GridLayout title={title} description={subtitle}>
<!-- 图标加载器,预加载所有技能图标 -->
<IconifyLoader preloadIcons={allIcons} />
<div class="flex w-full rounded-(--radius-large) overflow-hidden relative min-h-32">
<div class="card-base z-10 px-9 py-6 relative w-full">
<BackwardButton currentPath={Astro.url.pathname} />
<!-- 页面标题 -->
<div class="flex flex-col items-start justify-center mb-8">
<h1 class="text-4xl font-bold text-black/90 dark:text-white/90 mb-2">
{i18n(I18nKey.skills)}
</h1>
<p class="text-lg text-black/60 dark:text-white/60">
{i18n(I18nKey.skillsSubtitle)}
</p>
</div>
<!-- 统计信息 -->
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 mb-8">
<div class="bg-linear-to-br from-blue-50 to-blue-100 dark:from-blue-900/20 dark:to-blue-800/20 rounded-lg p-4">
<div class="text-2xl font-bold text-blue-600 dark:text-blue-400">{stats.total}</div>
<div class="text-sm text-blue-600/70 dark:text-blue-400/70">{i18n(I18nKey.skillsTotal)}</div>
</div>
<div class="bg-linear-to-br from-red-50 to-red-100 dark:from-red-900/20 dark:to-red-800/20 rounded-lg p-4">
<div class="text-2xl font-bold text-red-600 dark:text-red-400">{stats.byLevel.expert}</div>
<div class="text-sm text-red-600/70 dark:text-red-400/70">{i18n(I18nKey.skillsExpert)}</div>
</div>
<div class="bg-linear-to-br from-orange-50 to-orange-100 dark:from-orange-900/20 dark:to-orange-800/20 rounded-lg p-4">
<div class="text-2xl font-bold text-orange-600 dark:text-orange-400">{stats.byLevel.advanced}</div>
<div class="text-sm text-orange-600/70 dark:text-orange-400/70">{i18n(I18nKey.skillsAdvanced)}</div>
</div>
<div class="bg-linear-to-br from-purple-50 to-purple-100 dark:from-purple-900/20 dark:to-purple-800/20 rounded-lg p-4">
<div class="text-2xl font-bold text-purple-600 dark:text-purple-400">
{totalExperience.years}{totalExperience.months > 0 ? `.${totalExperience.months}` : ''}
</div>
<div class="text-sm text-purple-600/70 dark:text-purple-400/70">{i18n(I18nKey.skillExperience)}</div>
</div>
</div>
<!-- 专业技能 -->
{advancedSkills.length > 0 && (
<div class="mb-8">
<h2 class="text-2xl font-bold text-black/90 dark:text-white/90 mb-4">
{i18n(I18nKey.skillsAdvancedTitle)}
</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
{advancedSkills.map((skill) => (
<SkillCard skill={skill} size="large" showProgress={true} showIcon={true} />
))}
</div>
</div>
)}
<!-- 按分类展示技能 -->
<div class="space-y-8">
{categories.map((category) => {
const categorySkills = skillsByCategory[category];
if (categorySkills.length === 0) return null;
return (
<div>
<h2 class="text-2xl font-bold text-black/90 dark:text-white/90 mb-4">
{getCategoryText(category)}
<span class="text-lg font-normal text-black/60 dark:text-white/60 ml-2">
({categorySkills.length})
</span>
</h2>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{categorySkills.map((skill) => (
<SkillCard skill={skill} size="medium" showProgress={true} showIcon={true} />
))}
</div>
</div>
);
})}
</div>
<!-- 技能分布图表 -->
<div class="mt-12 pt-8 border-t border-black/10 dark:border-white/10">
<h2 class="text-2xl font-bold text-black/90 dark:text-white/90 mb-6">
{i18n(I18nKey.skillsDistribution)}
</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-8">
<!-- 按等级分布 -->
<div>
<h3 class="text-lg font-semibold text-black/80 dark:text-white/80 mb-4">
{i18n(I18nKey.skillsByLevel)}
</h3>
<div class="space-y-3">
{Object.entries(stats.byLevel).map(([level, count]) => {
const percentage = Math.round((count / stats.total) * 100);
return (
<div class="flex items-center gap-3">
<div class="w-20 text-sm text-black/70 dark:text-white/70">
{i18n(level === 'expert' ? I18nKey.skillsExpert :
level === 'advanced' ? I18nKey.skillsAdvanced :
level === 'intermediate' ? I18nKey.skillsIntermediate :
I18nKey.skillsBeginner)}
</div>
<div class="flex-1 bg-gray-200 dark:bg-gray-700 rounded-full h-2">
<div
class={`h-2 rounded-full transition-all duration-600 ${
level === 'expert' ? 'bg-red-500' :
level === 'advanced' ? 'bg-orange-500' :
level === 'intermediate' ? 'bg-yellow-500' :
'bg-green-500'
}`}
style={`width: ${percentage}%`}
></div>
</div>
<div class="w-12 text-sm text-black/70 dark:text-white/70 text-right">
{count}
</div>
</div>
);
})}
</div>
</div>
<!-- 按分类分布 -->
<div>
<h3 class="text-lg font-semibold text-black/80 dark:text-white/80 mb-4">
{i18n(I18nKey.skillsByCategory)}
</h3>
<div class="space-y-3">
{Object.entries(stats.byCategory).map(([category, count]) => {
const percentage = Math.round((count / stats.total) * 100);
return (
<div class="flex items-center gap-3">
<div class="w-20 text-sm text-black/70 dark:text-white/70 truncate">
{getCategoryText(category)}
</div>
<div class="flex-1 bg-gray-200 dark:bg-gray-700 rounded-full h-2">
<div
class="h-2 rounded-full bg-blue-500 transition-all duration-500"
style={`width: ${percentage}%`}
></div>
</div>
<div class="w-12 text-sm text-black/70 dark:text-white/70 text-right">
{count}
</div>
</div>
);
})}
</div>
</div>
</div>
</div>
</div>
</div>
</GridLayout>
<script>
// 确保图标库已加载,并处理页面导航
document.addEventListener('DOMContentLoaded', () => {
// 如果图标加载器存在,确保图标已加载
if (window.__iconifyLoader) {
window.__iconifyLoader.load().then(() => {
// 图标加载完成后,触发技能卡片的重新渲染
const skillCards = document.querySelectorAll('.skill-card');
skillCards.forEach(card => {
card.dispatchEvent(new CustomEvent('iconify-ready'));
});
}).catch(error => {
console.error('Failed to load icons on skills page:', error);
});
}
});
// 处理页面导航时的图标重新加载
if (typeof window !== 'undefined') {
// 监听页面显示事件(包括前进/后退导航)
window.addEventListener('pageshow', (event) => {
if (event.persisted && window.__iconifyLoader) {
// 页面从缓存恢复,重新检查图标状态
setTimeout(() => {
window.__iconifyLoader.load().catch(console.error);
}, 100);
}
});
}
</script>

219
src/pages/timeline.astro Normal file
View File

@@ -0,0 +1,219 @@
---
export const prerender = true;
import { LinkPresets } from "@constants/link-presets";
import { LinkPreset } from "@/types/config";
import {
timelineData,
getTimelineStats,
getTimelineByType,
getCurrentItems,
getTotalWorkExperience,
} from "@utils/timeline";
import { i18n } from "@i18n/translation";
import I18nKey from "@i18n/i18nKey";
import IconifyLoader from "@components/common/iconifyLoader.astro";
import TimelineItem from "@components/data/timelineItem.astro";
import GridLayout from "@layouts/grid.astro";
import BackwardButton from "@components/backwardButton.astro";
const title = LinkPresets[LinkPreset.Timeline].name;
const subtitle = LinkPresets[LinkPreset.Timeline].description;
// 收集所有时间线图标用于预加载
const allIcons = timelineData
.map((item) => item.icon || getTypeIcon(item.type))
.filter(Boolean);
// 获取时间线统计信息
const stats = getTimelineStats();
const currentItems = getCurrentItems();
const workExperience = getTotalWorkExperience();
// 获取所有时间线项目(按时间倒序)
const allTimelineItems = getTimelineByType();
// 类型图标映射
const getTypeIcon = (type: string) => {
switch (type) {
case "education":
return "material-symbols:school";
case "work":
return "material-symbols:work";
case "project":
return "material-symbols:code";
case "achievement":
return "material-symbols:emoji-events";
default:
return "material-symbols:event";
}
};
---
<GridLayout title={title} description={subtitle}>
<!-- 图标加载器,预加载所有时间线图标 -->
<IconifyLoader preloadIcons={allIcons} />
<div class="flex w-full rounded-(--radius-large) overflow-hidden relative min-h-32">
<div class="card-base z-10 px-9 py-6 relative w-full">
<BackwardButton currentPath={Astro.url.pathname} />
<!-- 页面标题 -->
<div class="flex flex-col items-start justify-center mb-8">
<h1 class="text-4xl font-bold text-black/90 dark:text-white/90 mb-2">
{i18n(I18nKey.timeline)}
</h1>
<p class="text-lg text-black/60 dark:text-white/60">
{i18n(I18nKey.timelineSubtitle)}
</p>
</div>
<!-- 统计信息 -->
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 mb-8">
<div class="bg-linear-to-br from-blue-50 to-blue-100 dark:from-blue-900/20 dark:to-blue-800/20 rounded-lg p-4">
<div class="text-2xl font-bold text-blue-600 dark:text-blue-400">{stats.total}</div>
<div class="text-sm text-blue-600/70 dark:text-blue-400/70">{i18n(I18nKey.timelineTotal)}</div>
</div>
<div class="bg-linear-to-br from-green-50 to-green-100 dark:from-green-900/20 dark:to-green-800/20 rounded-lg p-4">
<div class="text-2xl font-bold text-green-600 dark:text-green-400">{stats.byType.work}</div>
<div class="text-sm text-green-600/70 dark:text-green-400/70">{i18n(I18nKey.timelineWork)}</div>
</div>
<div class="bg-linear-to-br from-purple-50 to-purple-100 dark:from-purple-900/20 dark:to-purple-800/20 rounded-lg p-4">
<div class="text-2xl font-bold text-purple-600 dark:text-purple-400">{stats.byType.project}</div>
<div class="text-sm text-purple-600/70 dark:text-purple-400/70">{i18n(I18nKey.timelineProjects)}</div>
</div>
<div class="bg-linear-to-br from-orange-50 to-orange-100 dark:from-orange-900/20 dark:to-orange-800/20 rounded-lg p-4">
<div class="text-2xl font-bold text-orange-600 dark:text-orange-400">
{workExperience.years}+
</div>
<div class="text-sm text-orange-600/70 dark:text-orange-400/70">{i18n(I18nKey.timelineExperience)}</div>
</div>
</div>
<!-- 当前进行中的项目 -->
{currentItems.length > 0 && (
<div class="mb-8">
<h2 class="text-2xl font-bold text-black/90 dark:text-white/90 mb-4">
{i18n(I18nKey.timelineCurrent)}
</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
{currentItems.map((item) => (
<TimelineItem item={item} layout="card" size="medium" />
))}
</div>
</div>
)}
<!-- 时间线 -->
<div class="mb-8">
<h2 class="text-2xl font-bold text-black/90 dark:text-white/90 mb-6">
{i18n(I18nKey.timelineHistory)}
</h2>
<div class="relative">
<!-- 时间线主轴 -->
<div class="absolute left-6 top-0 bottom-0 w-0.5 bg-gray-200 dark:bg-gray-700"></div>
<div class="space-y-8">
{allTimelineItems.map((item, index) => (
<TimelineItem item={item} layout="timeline" showTimeline={true} size="medium" />
))}
</div>
</div>
</div>
<!-- 类型统计 -->
<div class="mt-12 pt-8 border-t border-black/10 dark:border-white/10">
<h2 class="text-2xl font-bold text-black/90 dark:text-white/90 mb-6">
{i18n(I18nKey.timelineStatistics)}
</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-8">
<!-- 按类型分布 -->
<div>
<h3 class="text-lg font-semibold text-black/80 dark:text-white/80 mb-4">
{i18n(I18nKey.timelineByType)}
</h3>
<div class="space-y-3">
{Object.entries(stats.byType).map(([type, count]) => {
const percentage = Math.round((count / stats.total) * 100);
return (
<div class="flex items-center gap-3">
<div class="w-20 text-sm text-black/70 dark:text-white/70">
{i18n(type === 'education' ? I18nKey.timelineEducation :
type === 'work' ? I18nKey.timelineWork :
type === 'project' ? I18nKey.timelineProject :
I18nKey.timelineAchievement)}
</div>
<div class="flex-1 bg-gray-200 dark:bg-gray-700 rounded-full h-2">
<div
class={`h-2 rounded-full transition-all duration-600 ${
type === 'education' ? 'bg-blue-500' :
type === 'work' ? 'bg-green-500' :
type === 'project' ? 'bg-purple-500' :
'bg-orange-500'
}`}
style={`width: ${percentage}%`}
></div>
</div>
<div class="w-12 text-sm text-black/70 dark:text-white/70 text-right">
{count}
</div>
</div>
);
})}
</div>
</div>
<!-- 工作经验详情 -->
<div>
<h3 class="text-lg font-semibold text-black/80 dark:text-white/80 mb-4">
{i18n(I18nKey.timelineWorkExperience)}
</h3>
<div class="space-y-3">
<div class="flex justify-between items-center">
<span class="text-black/70 dark:text-white/70">{i18n(I18nKey.timelineTotalExperience)}</span>
<span class="font-semibold text-black/90 dark:text-white/90">
{workExperience.years} {i18n(I18nKey.timelineYears)} {workExperience.months} {i18n(I18nKey.timelineMonths)}
</span>
</div>
<div class="flex justify-between items-center">
<span class="text-black/70 dark:text-white/70">{i18n(I18nKey.timelineWorkPositions)}</span>
<span class="font-semibold text-black/90 dark:text-white/90">{stats.byType.work}</span>
</div>
<div class="flex justify-between items-center">
<span class="text-black/70 dark:text-white/70">{i18n(I18nKey.timelineCurrentRole)}</span>
<span class="font-semibold text-black/90 dark:text-white/90">
{currentItems.filter(item => item.type === 'work').length > 0 ? i18n(I18nKey.timelineEmployed) : i18n(I18nKey.timelineAvailable)}
</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</GridLayout>
<script>
// 确保图标库已加载,并处理页面导航
document.addEventListener('DOMContentLoaded', () => {
// 如果图标加载器存在,确保图标已加载
if (window.__iconifyLoader) {
window.__iconifyLoader.load().then(() => {
// 图标加载完成后,触发时间线项目的重新渲染
const timelineItems = document.querySelectorAll('.timeline-item');
timelineItems.forEach(item => {
item.dispatchEvent(new CustomEvent('iconify-ready'));
});
}).catch(error => {
console.error('Failed to load icons on timeline page:', error);
});
}
});
// 处理页面导航时的图标重新加载
if (typeof window !== 'undefined') {
// 监听页面显示事件(包括前进/后退导航)
window.addEventListener('pageshow', (event) => {
if (event.persisted && window.__iconifyLoader) {
// 页面从缓存恢复,重新检查图标状态
setTimeout(() => {
window.__iconifyLoader.load().catch(console.error);
}, 100);
}
});
}
</script>