Skip to content
0

项目页面

前言

Firefly 主题移植了一个专业的项目展示(Projects)页面,用于展示您参与或开发的项目作品。这个页面可以帮助访客了解您的技术能力和项目经验。

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

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


📁 文件结构

public/
├── js/
│   └── filter-tabs-handler.js   ✨ 新增筛选标签处理器
src/
├── components/
│   ├── atoms/
│   │   └── FilterTabs.astro          ✨ 新增 - 筛选标签组件
│   │   └── index.ts                  ✨ 新增 - 组件导出
│   └── features/
│       └── page-header/
│           ├── index.ts               ✨ 新增 - 组件导出
│           ├── types.ts               ✨ 新增 - 类型定义
│           └── PageHeader.astro       ✨ 新增 - 页面头部组件
│       └── projects/
│           ├── index.ts               ✨ 新增 - 组件导出
│           ├── types.ts               ✨ 新增 - 类型定义
│           └── ProjectCard.astro       ✨ 新增 - 项目卡片组件
├── config/
│   └── siteConfig.ts                  ✏️ 修改 - 添加页面开关配置
│   └── navBarConfig.ts               ✏️ 修改 - 导航栏配置
├── constants/
│   └── link-presets.ts               ✏️ 修改 - 添加导航链接
├── data/
│   └── projects.ts                      ✨ 新增 - 项目数据管理
├── i18n/
│   ├── i18nKey.ts                    ✏️ 修改 - 添加翻译键
│   └── languages/
│       ├── en.ts                     ✏️ 修改 - 英文翻译
│       ├── zh_CN.ts                  ✏️ 修改 - 中文翻译
│       ├── zh_TW.ts                  ✏️ 修改 - 繁体翻译
│       ├── ja.ts                     ✏️ 修改 - 日文翻译
│       └── ru.ts                     ✏️ 修改 - 俄文翻译
├── pages/
│   └── projects.astro                   ✨ 新增 - 项目页面
├── types/
│   └── config.ts                      ✏️ 修改 - 添加类型定义

创建选标签处理器

文件路径public/js/filter-tabs-handler.js

js
// Shared filter handler for FilterTabs atom component
// Works with Swup page transitions
// FilterTabs renders data-filter-attr and data-filter-value on each button
// Cards/entries should have a matching data attribute (e.g. data-category, data-type)

(function () {
	function initFilterTabs(reset) {
		var containers = document.querySelectorAll(".filter-tabs");

		containers.forEach(function (container) {
			if (!reset && container.dataset.initialized) return;
			container.dataset.initialized = "true";

			var tabs = container.querySelectorAll(".filter-tabs-item");
			var filterAttr = tabs[0] ? tabs[0].dataset.filterAttr : null;
			if (!filterAttr) return;

			var dataSelector = "[data-" + filterAttr + "]";
			var parent = container.closest(".card-base") || document;
			var items = parent.querySelectorAll(dataSelector);
			var noResults = parent.querySelector("#no-results");

			if (items.length === 0) return;

			tabs.forEach(function (tab) {
				tab.addEventListener("click", function () {
					tabs.forEach(function (t) {
						t.classList.remove("active");
					});
					tab.classList.add("active");

					var activeValue = tab.dataset.filterValue || "all";
					var visibleCount = 0;

					items.forEach(function (item) {
						var itemValue = item.dataset[filterAttr];
						var match =
							activeValue === "all" || (itemValue && itemValue.split(",").indexOf(activeValue) !== -1);

						if (match) {
							item.classList.remove("filtered-out");
							visibleCount++;
						} else {
							item.classList.add("filtered-out");
						}
					});

					if (noResults) {
						noResults.classList.toggle("hidden", visibleCount > 0);
					}
				});
			});
		});
	}

	// Expose for dynamic tab rebuild (e.g. Memos API fetch)
	window.__initFilterTabs = function () {
		initFilterTabs(true);
	};

	function onInit() {
		if (document.querySelector(".filter-tabs")) {
			initFilterTabs(false);
		}
	}

	if (document.readyState === "loading") {
		document.addEventListener("DOMContentLoaded", onInit);
	} else {
		onInit();
	}

	document.addEventListener("astro:page-load", onInit);
})();

创建筛选标签组件

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

		.filter-tabs-count {
			display: none;
		}
	}
</style>
ts
export { default as FilterTabs } from "./FilterTabs.astro";

创建页面头部组件

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

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

  • 文件路径src/components/features/diary/PageHeader.astro 页面头部组件

ts
export { default as PageHeader } from "./PageHeader.astro";
export * from "./types";
ts
export interface PageHeaderProps {
	title: string;
	subtitle?: string;
	class?: string;
}
astro
---
import type { PageHeaderProps } from "./types";

const {
	title,
	subtitle,
	class: className = "",
} = Astro.props as PageHeaderProps;
---

<div class={`flex flex-col items-start justify-center mb-8 ${className}`}>
	<h1
		class="text-4xl font-bold text-black/90 dark:text-white/90 mb-2 relative
			before:w-1 before:h-8 before:rounded-md before:bg-[var(--primary)]
			before:absolute before:top-1/2 before:-translate-y-1/2 before:-left-4"
	>
		{title}
	</h1>
	{
		subtitle && (
			<p class="text-lg text-black/60 dark:text-white/60">{subtitle}</p>
		)
	}
</div>

创建项目卡片组件

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

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

  • 文件路径src/components/features/diary/ProjectCard.astro 项目卡片组件

ts
export { default as ProjectCard } from "./ProjectCard.astro";
export * from "./types";
ts
export interface Project {
	id: string;
	title: string;
	description: string;
	image?: string;
	category: string;
	techStack: string[];
	status: "completed" | "in-progress" | "planned";
	demoUrl?: string;
	sourceUrl?: string;
	liveDemo?: string;
	sourceCode?: string;
	visitUrl?: string;
	startDate: string;
	endDate?: string;
	featured?: boolean;
	tags?: string[];
	showImage?: boolean;
}

export interface ProjectCardProps {
	project: Project;
	size?: "small" | "medium" | "large";
	showImage?: boolean;
	maxTechStack?: number;
}
ts
---
import { Icon } from "astro-icon/components";

import I18nKey from "../../../i18n/i18nKey";
import { i18n } from "../../../i18n/translation";
import type { ProjectCardProps } from "./types";

const { project, maxTechStack = 4 } = Astro.props as ProjectCardProps;

const hasImage = !!project.image;
const showImageArea = project.showImage !== false && hasImage;
const hasVisitUrl = !!project.visitUrl;
const hasSourceCode = !!project.sourceCode;

const getStatusText = (status: string) => {
	switch (status) {
		case "completed":
			return i18n(I18nKey.projectsCompleted);
		case "in-progress":
			return i18n(I18nKey.projectsInProgress);
		case "planned":
			return i18n(I18nKey.projectsPlanned);
		default:
			return status;
	}
};
---

<div
	class="project-card group relative rounded-xl border border-black/10 dark:border-white/10 overflow-hidden transition-all duration-300 hover:shadow-xl hover:-translate-y-1"
	data-category={project.category}
>
	{
		showImageArea && (
			<div class="aspect-video overflow-hidden relative bg-gradient-to-br from-[var(--primary)]/5 to-[var(--primary)]/10">
				{hasImage ? (
					<img
						src={project.image!}
						alt={project.title}
						class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500"
						loading="lazy"
					/>
				) : (
					<div class="w-full h-full flex items-center justify-center">
						<span class="text-4xl font-bold text-[var(--primary)]/15 select-none tracking-wide">
							{project.title}
						</span>
					</div>
				)}
				<div class="absolute inset-0 bg-gradient-to-t from-black/20 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300" />
				{project.featured && (
					<div class="absolute top-3 right-3">
						<Icon
							name="material-symbols:star-rounded"
							class="text-[var(--primary)] text-xl drop-shadow-sm"
							style="filter: drop-shadow(0 1px 2px rgba(0,0,0,0.2));"
						/>
					</div>
				)}
			</div>
		)
	}

	<div class="p-5">
		<div class="flex items-center justify-between mb-3">
			<h3
				class="text-lg font-bold text-black/90 dark:text-white/90 truncate group-hover:text-[var(--primary)] transition-colors duration-200"
			>
				{project.title}
			</h3>
			<span
				class="shrink-0 ml-3 px-2 py-0.5 text-xs rounded-md bg-[var(--primary)]/10 text-[var(--primary)] font-medium"
			>
				{getStatusText(project.status)}
			</span>
		</div>

		<p
			class="text-sm text-black/60 dark:text-white/60 mb-4 line-clamp-2 min-h-[2.5rem]"
		>
			{project.description}
		</p>

		{
			project.techStack && project.techStack.length > 0 && (
				<div class="flex flex-wrap gap-2 mb-4">
					{project.techStack.slice(0, maxTechStack).map((tech) => (
						<span class="px-2 py-1 text-xs rounded-md bg-[var(--primary)]/10 text-[var(--primary)] font-medium">
							{tech}
						</span>
					))}
					{project.techStack.length > maxTechStack && (
						<span class="px-2 py-1 text-xs rounded-md bg-[var(--btn-regular-bg)] text-black/50 dark:text-white/50 font-medium">
							+{project.techStack.length - maxTechStack}
						</span>
					)}
				</div>
			)
		}

		{
			(hasVisitUrl || hasSourceCode) && (
				<div class="flex gap-2">
					{hasVisitUrl && (
						<a
							href={project.visitUrl!}
							target="_blank"
							rel="noopener noreferrer"
							class="btn-regular flex-1 flex items-center justify-center gap-2 px-4 py-2 rounded-lg text-sm font-medium"
						>
							<Icon
								name="material-symbols:open-in-new"
								class="text-base w-4 h-4"
							/>
							{i18n(I18nKey.projectsVisit)}
						</a>
					)}
					{hasSourceCode && (
						<a
							href={project.sourceCode!}
							target="_blank"
							rel="noopener noreferrer"
							class="btn-regular flex items-center justify-center gap-2 px-4 py-2 rounded-lg text-sm font-medium"
							style={hasVisitUrl ? "" : "flex: 1;"}
						>
							<Icon name="mdi:github" class="text-base w-4 h-4" />
							{!hasVisitUrl && "GitHub"}
						</a>
					)}
				</div>
			)
		}
	</div>

	<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"
	>
	</div>
</div>

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

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

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

	.project-card:nth-child(1) {
		animation-delay: 0.05s;
	}
	.project-card:nth-child(2) {
		animation-delay: 0.1s;
	}
	.project-card:nth-child(3) {
		animation-delay: 0.15s;
	}
	.project-card:nth-child(4) {
		animation-delay: 0.2s;
	}
	.project-card:nth-child(5) {
		animation-delay: 0.25s;
	}
	.project-card:nth-child(6) {
		animation-delay: 0.3s;
	}
	.project-card:nth-child(7) {
		animation-delay: 0.35s;
	}
	.project-card:nth-child(8) {
		animation-delay: 0.4s;
	}

	.line-clamp-2 {
		display: -webkit-box;
		-webkit-line-clamp: 2;
		-webkit-box-orient: vertical;
		overflow: hidden;
	}
</style>

页面开关导航栏配置

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

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

ts
// 页面开关配置
pages: {
    // ... 其他配置
    // 项目页面开关
	projects: true,
},
ts
// 我的及其子菜单
	links.push({
		name: "我的",
		url: "/my/",
		icon: "material-symbols:person",
		children: [
			// 根据配置决定是否添加项目,在siteConfig关闭pages.projects时导航栏不显示项目
			...(siteConfig.pages.projects ? [LinkPreset.Projects] : []),
		],
	});

更新类型配置链接

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

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

ts
export type SiteConfig = {
	// 页面开关配置
	pages: {
		projects?: boolean; // 项目页面开关
	};
}

export enum LinkPreset {
	Projects = 10, // ✨ 新增
}
ts
export const LinkPresets: { [key in LinkPreset]: NavBarLink } = {
	[LinkPreset.Projects]: {
		name: i18n(I18nKey.projects),
		url: "/projects/",
		icon: "material-symbols:work",
	},
}

项目数据管理

文件路径src/data/projects.ts

ts
// Project data configuration file
// Used to manage data for the project display page

export interface Project {
	id: string;
	title: string;
	description: string;
	image: string;
	category: "web" | "mobile" | "desktop" | "other";
	techStack: string[];
	status: "completed" | "in-progress" | "planned";
	liveDemo?: string;
	sourceCode?: string;
	visitUrl?: string;
	startDate: string;
	endDate?: string;
	featured?: boolean;
	tags?: string[];
	showImage?: boolean;
}

export const projectsData: Project[] = [
	{
		id: "Firefly", //(必填) 项目的唯一标识符,通常是字符串格式的名称。用于内部引用和过滤。
		title: "Firefly", //(必填) 项目的名称,显示在项目列表中。
		// 项目的详细描述,可以多行文本。
		description:
			"Firefly 是一款基于 Astro 框架和 Fuwari 模板开发的清新美观且现代化个人博客主题模板,专为技术爱好者和内容创作者设计。该主题融合了现代 Web 技术栈,提供了丰富的功能模块和高度可定制的界面,让您能够轻松打造出专业且美观的个人博客网站。",
		// (必填) 项目展示图片的路径,通常放在 public/images/projects/ 目录下。
		image: "https://docs-firefly.cuteleaf.cn/images/1.webp",
		//"web" | "mobile" | "desktop" | "other": (必填) 项目的类型分类,用于筛选。
		category: "web",
		// (必填) 项目使用的技术栈数组,如 ["React", "Node.js"]。
		techStack: ["Astro", "TypeScript", "Tailwind CSS", "Svelte"],
		// "completed" | "in-progress" | "planned": (必填) 项目的当前状态,用于筛选。
		status: "completed",
		//  (可选) 项目源代码仓库的URL地址,通常是GitHub链接。
		sourceCode: "https://github.com/CuteLeaf/Firefly",
		//  (可选) 项目访问链接,可以是演示链接或项目主页。
		visitUrl: "https://firefly.cuteleaf.cn/",
		// (必填) 项目开始日期,格式为 "YYYY-MM-DD"。
		startDate: "2025-10-111",
		// (可选) 项目结束日期,格式为 "YYYY-MM-DD"。对于进行中的项目,此字段可省略。
		endDate: "2024-06-01",
		// (可选) 是否为特色项目,特色项目会优先展示。
		featured: true,
		// (可选) 项目标签数组,用于更细致的分类。
		tags: ["Blog", "Theme", "Open Source"],
	},
	{
		id: "vue-pure-admin",
		title: "vue-pure-admin",
		description:
			"一款开源免费且开箱即用的中后台管理系统模块版本。完全采用ECMAScript模块(ESM)规范来编写和组织代码,使用了最新的Vue3、 Vite、Element-Plus、TypeScript、Pinia、Tailwindcss等主流技术开发",
		image: "https://camo.githubusercontent.com/56acae4e405b42111d2ba2b56a85c3d07a93fe0d2e4865960da81df74386af3c/68747470733a2f2f7869616f7869616e3532312e6769746875622e696f2f68797065726c696e6b2f696d672f7675652d707572652d61646d696e2f312e6a7067",
		category: "mobile",
		techStack: ["Vue3", "Vite", "Element-Plus++", "TypeScript","Pinia","Tailwindcss"],
		status: "in-progress",
		sourceCode: "https://github.com/pure-admin/vue-pure-admin",
		visitUrl: "https://pure-admin.github.io/vue-pure-admin/#/login",
		startDate: "2024-03-01",
		featured: true,
		tags: ["Android", "Root", "Kernel"],
	},
	{
		id: "file-transfer-go",
		title: "文件快传 - P2P文件传输工具",
		description:
			"Go/React开发的端到端webrtc的文件传输/文字传输/桌面共享,安全,隐私,数据不经过服务器。",
		image: "https://raw.githubusercontent.com/MatrixSeven/file-transfer-go/refs/heads/main/img.png",
		category: "desktop",
		techStack: ["Go", "React "],
		status: "completed",
		sourceCode: "https://github.com/MatrixSeven/file-transfer-go",
		visitUrl: "https://transfer.52python.cn/",
		startDate: "2026-02-01",
		endDate: "2026-02-28",
		tags: ["Android", "Tool", "Desktop"],
		showImage: true,
	},
];

// Get project statistics
export const getProjectStats = () => {
	const total = projectsData.length;
	const completed = projectsData.filter(
		(p) => p.status === "completed",
	).length;
	const inProgress = projectsData.filter(
		(p) => p.status === "in-progress",
	).length;
	const planned = projectsData.filter((p) => p.status === "planned").length;

	return {
		total,
		byStatus: {
			completed,
			inProgress,
			planned,
		},
	};
};

// Get projects by category
export const getProjectsByCategory = (category?: string) => {
	if (!category || category === "all") {
		return projectsData;
	}
	return projectsData.filter((p) => p.category === category);
};

// Get featured projects
export const getFeaturedProjects = () => {
	return projectsData.filter((p) => p.featured);
};

// Get all tech stacks
export const getAllTechStack = () => {
	const techSet = new Set<string>();
	projectsData.forEach((project) => {
		project.techStack.forEach((tech) => {
			techSet.add(tech);
		});
	});
	return Array.from(techSet).sort();
};

添加国际化翻译

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

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

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

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

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

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

ts
export enum I18nKey {
    // 项目展示页面
	projects = "projects",
	projectsSubtitle = "projectsSubtitle",
	projectsAll = "projectsAll",
	projectsWeb = "projectsWeb",
	projectsMobile = "projectsMobile",
	projectsDesktop = "projectsDesktop",
	projectsOther = "projectsOther",
	projectTechStack = "projectTechStack",
	projectLiveDemo = "projectLiveDemo",
	projectSourceCode = "projectSourceCode",
	projectDescription = "projectDescription",
	projectStatus = "projectStatus",
	projectStatusCompleted = "projectStatusCompleted",
	projectStatusInProgress = "projectStatusInProgress",
	projectStatusPlanned = "projectStatusPlanned",
	projectsTotal = "projectsTotal",
	projectsCompleted = "projectsCompleted",
	projectsInProgress = "projectsInProgress",
	projectsTechStack = "projectsTechStack",
	projectsFeatured = "projectsFeatured",
	projectsPlanned = "projectsPlanned",
	projectsDemo = "projectsDemo",
	projectsSource = "projectsSource",
	projectsVisit = "projectsVisit",
	projectsGitHub = "projectsGitHub",
}
ts
	// Projects Page
	[Key.projects]: "Projects",
	[Key.projectsSubtitle]: "My development project portfolio",
	[Key.projectsAll]: "All",
	[Key.projectsWeb]: "Web Applications",
	[Key.projectsMobile]: "Mobile Applications",
	[Key.projectsDesktop]: "Desktop Applications",
	[Key.projectsOther]: "Other",
	[Key.projectTechStack]: "Tech Stack",
	[Key.projectLiveDemo]: "Live Demo",
	[Key.projectSourceCode]: "Source Code",
	[Key.projectDescription]: "Project Description",
	[Key.projectStatus]: "Status",
	[Key.projectStatusCompleted]: "Completed",
	[Key.projectStatusInProgress]: "In Progress",
	[Key.projectStatusPlanned]: "Planned",
	[Key.projectsTotal]: "Total Projects",
	[Key.projectsCompleted]: "Completed",
	[Key.projectsInProgress]: "In Progress",
	[Key.projectsTechStack]: "Tech Stack Statistics",
	[Key.projectsFeatured]: "Featured Projects",
	[Key.projectsPlanned]: "Planned",
	[Key.projectsDemo]: "Live Demo",
	[Key.projectsSource]: "Source Code",
	[Key.projectsVisit]: "Visit Project",
	[Key.projectsGitHub]: "GitHub",
ts
	// 项目展示页面
	[Key.projects]: "项目展示",
	[Key.projectsSubtitle]: "我的开发项目作品集",
	[Key.projectsAll]: "全部",
	[Key.projectsWeb]: "网页应用",
	[Key.projectsMobile]: "移动应用",
	[Key.projectsDesktop]: "桌面应用",
	[Key.projectsOther]: "其他",
	[Key.projectTechStack]: "技术栈",
	[Key.projectLiveDemo]: "在线演示",
	[Key.projectSourceCode]: "源代码",
	[Key.projectDescription]: "项目描述",
	[Key.projectStatus]: "项目状态",
	[Key.projectStatusCompleted]: "已完成",
	[Key.projectStatusInProgress]: "进行中",
	[Key.projectStatusPlanned]: "计划中",
	[Key.projectsTotal]: "项目总数",
	[Key.projectsCompleted]: "已完成",
	[Key.projectsInProgress]: "进行中",
	[Key.projectsTechStack]: "技术栈统计",
	[Key.projectsFeatured]: "精选项目",
	[Key.projectsPlanned]: "计划中",
	[Key.projectsDemo]: "在线演示",
	[Key.projectsSource]: "源代码",
	[Key.projectsVisit]: "前往",
	[Key.projectsGitHub]: "GitHub",
ts
	// 專案展示頁面
	[Key.projects]: "專案展示",
	[Key.projectsSubtitle]: "我的開發專案作品集",
	[Key.projectsAll]: "全部",
	[Key.projectsWeb]: "網頁應用",
	[Key.projectsMobile]: "移動應用",
	[Key.projectsDesktop]: "桌面應用",
	[Key.projectsOther]: "其他",
	[Key.projectTechStack]: "技術堆疊",
	[Key.projectLiveDemo]: "線上展示",
	[Key.projectSourceCode]: "原始碼",
	[Key.projectDescription]: "專案描述",
	[Key.projectStatus]: "專案狀態",
	[Key.projectStatusCompleted]: "已完成",
	[Key.projectStatusInProgress]: "進行中",
	[Key.projectStatusPlanned]: "計劃中",
	[Key.projectsTotal]: "專案總數",
	[Key.projectsCompleted]: "已完成",
	[Key.projectsInProgress]: "進行中",
	[Key.projectsTechStack]: "技術堆疊統計",
	[Key.projectsFeatured]: "精選專案",
	[Key.projectsPlanned]: "計劃中",
	[Key.projectsDemo]: "線上展示",
	[Key.projectsSource]: "原始碼",
	[Key.projectsVisit]: "前往專案",
	[Key.projectsGitHub]: "GitHub",
ts
	// プロジェクトページ
	[Key.projects]: "プロジェクト",
	[Key.projectsSubtitle]: "開発プロジェクトのポートフォリオ",
	[Key.projectsAll]: "すべて",
	[Key.projectsWeb]: "ウェブアプリ",
	[Key.projectsMobile]: "モバイルアプリ",
	[Key.projectsDesktop]: "デスクトップアプリ",
	[Key.projectsOther]: "その他",
	[Key.projectTechStack]: "技術スタック",
	[Key.projectLiveDemo]: "ライブデモ",
	[Key.projectSourceCode]: "ソースコード",
	[Key.projectDescription]: "プロジェクトの説明",
	[Key.projectStatus]: "ステータス",
	[Key.projectStatusCompleted]: "完了",
	[Key.projectStatusInProgress]: "進行中",
	[Key.projectStatusPlanned]: "計画中",
	[Key.projectsTotal]: "プロジェクトの合計",
	[Key.projectsCompleted]: "完了",
	[Key.projectsInProgress]: "進行中",
	[Key.projectsTechStack]: "技術スタック",
	[Key.projectsFeatured]: "注目のプロジェクト",
	[Key.projectsPlanned]: "計画中",
	[Key.projectsDemo]: "ライブデモ",
	[Key.projectsSource]: "ソースコード",
	[Key.projectsVisit]: "プロジェクトを開く",
	[Key.projectsGitHub]: "GitHub",
ts
	// Страница проектов
	[Key.projects]: "Проекты",
	[Key.projectsSubtitle]: "Мой портфель проектов",
	[Key.projectsAll]: "Все",
	[Key.projectsWeb]: "Веб-приложения",
	[Key.projectsMobile]: "Мобильные приложения",
	[Key.projectsDesktop]: "Десктопные приложения",
	[Key.projectsOther]: "Другое",
	[Key.projectTechStack]: "Технологический стек",
	[Key.projectLiveDemo]: "Онлайн демо",
	[Key.projectSourceCode]: "Исходный код",
	[Key.projectDescription]: "Описание проекта",
	[Key.projectStatus]: "Статус проекта",
	[Key.projectStatusCompleted]: "Завершено",
	[Key.projectStatusInProgress]: "В процессе",
	[Key.projectStatusPlanned]: "Запланировано",
	[Key.projectsTotal]: "Всего проектов",
	[Key.projectsCompleted]: "Завершено",
	[Key.projectsInProgress]: "В процессе",
	[Key.projectsTechStack]: "Статистика технологий",
	[Key.projectsFeatured]: "Избранные проекты",
	[Key.projectsPlanned]: "Запланировано",
	[Key.projectsDemo]: "Онлайн демо",
	[Key.projectsSource]: "Исходный код",
	[Key.projectsVisit]: "Посетить",
	[Key.projectsGitHub]: "GitHub",

新增项目页面组件

文件路径src/pages/projects.astro

astro
---
import { FilterTabs } from "@components/atoms";
import { PageHeader } from "@components/features/page-header";
import { ProjectCard } from "@components/features/projects";
import MainGridLayout from "@layouts/MainGridLayout.astro";
import { Icon } from "astro-icon/components";

import { siteConfig } from "../config";
import { UNCATEGORIZED } from "../constants/constants";
import { projectsData } from "../data/projects";
import I18nKey from "../i18n/i18nKey";
import { i18n } from "../i18n/translation";

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

const categories = [
	...new Set(projectsData.map((project) => project.category)),
];

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;
	}
};

const getCategoryIcon = (category: string) => {
	switch (category) {
		case "web":
			return "material-symbols:language";
		case "mobile":
			return "material-symbols:smartphone";
		case "desktop":
			return "material-symbols:desktop-windows";
		case "other":
			return "material-symbols:widgets";
		default:
			return "material-symbols:folder";
	}
};

const filterTabs = [
	{
		value: "all",
		label: i18n(I18nKey.friendsFilterAll),
		icon: "material-symbols:apps",
		count: projectsData.length,
	},
	...categories.map((category) => ({
		value: category,
		label: getCategoryText(category),
		icon: getCategoryIcon(category),
		count: projectsData.filter((p) => p.category === category).length,
	})),
];

const title = i18n(I18nKey.projects);
const subtitle = i18n(I18nKey.projectsSubtitle);
---

<MainGridLayout title={title} description={subtitle}>
	<script>
		import { initIconLoader } from "../utils/icon-loader";

		initIconLoader();
	</script>

	<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 px-6 sm:px-9 py-6 relative w-full">
			<PageHeader title={title} subtitle={subtitle} />

			<div class="mb-8">
				<FilterTabs tabs={filterTabs} dataAttr="category" />
			</div>

			<div
				id="projects-grid"
				class="grid grid-cols-1 md:grid-cols-2 gap-6 items-start"
			>
				{
					projectsData.map((project) => (
						<ProjectCard project={project} maxTechStack={4} />
					))
				}
			</div>

			<div id="no-results" class="hidden text-center py-16">
				<Icon
					name="material-symbols:search-off-rounded"
					class="text-6xl text-black/15 dark:text-white/15 mb-4"
				/>
				<p class="text-black/40 dark:text-white/40 text-lg">
					No matching projects
				</p>
			</div>
		</div>
	</div>
</MainGridLayout>

配置教程

有关项目页面配置教程,请参考 项目页面配置教程

最近更新