项目展示页重构:从普通列表到时间线布局

发表于 2026-01-29 14:00 1257 字 7 min read

吕小布 avatar

吕小布

The first step is to establish that something is possible; then probability will occur.

暂无目录
记录将博客项目展示页从 Bento Grid 布局重构为时间线布局的过程,让作品集更有故事感,展示成长历程。

最近对博客的项目展示页做了一次重构,把原来的 Bento Grid 布局改成了时间线布局。这篇文章记录一下整个过程和思考。

为什么要改?

说实话,最开始看到原来的作品集页面时,我感觉很普通。

重构前的项目页

就是一个很常见的卡片网格布局,虽然功能上没什么问题,但总觉得缺少点什么。看起来就像是一个「项目清单」,而不是一个「成长故事」。

我希望访客在浏览我的项目时,能够感受到:

  • 时间的流动:知道这些项目是什么时候做的
  • 成长的轨迹:看到我从第一个项目到现在的进步
  • 里程碑时刻:哪些项目对我来说意义重大

于是我想到了时间线布局——这种布局天然就带有「故事感」,很适合展示个人的成长历程。

设计思路

在动手之前,我先在脑海中构思了一下想要的效果:

                    2024

    ┌─────────────────●
    │  🎯 项目 A      │
    │  ┌───────────┐  │
    │  │  项目图片  │  │
    │  └───────────┘  │
    │  Vue 3 · Java   │
    └─────────────────┤

                      ●─────────────────┐
                      │  🚀 项目 B      │
                      │  ┌───────────┐  │
                      │  │  项目图片  │  │
                      │  └───────────┘  │
                      ├─────────────────┘

                    2023

                    起点

核心设计要点

元素设计
时间线居中的垂直线,连接所有项目
项目卡片左右交替排列,增加视觉层次
年份标记醒目的年份徽章,划分时间段
里程碑特殊标记重要项目,带脉冲动画
响应式移动端改为单列布局

技术实现

组件结构

整个时间线由四个组件组成:

Timeline.tsx          # 主容器,负责按年份分组
├── TimelineYearMarker.tsx   # 年份标记
├── TimelineItem.tsx         # 项目卡片
│   └── TimelineNode.tsx     # 时间线节点
└── "起点" 标记

按年份分组

项目数据需要按年份分组,这样才能在每组项目前显示年份标记:

function groupProjectsByYear(projects: Project[]): ProjectsByYear[] {
  // 按日期降序排序
  const sorted = [...projects].sort(
    (a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()
  );

  // 按年份分组
  const groups: Map<number, Project[]> = new Map();
  for (const project of sorted) {
    const year = new Date(project.date).getFullYear();
    const existing = groups.get(year);
    if (existing) {
      existing.push(project);
    } else {
      groups.set(year, [project]);
    }
  }

  // 转换为数组,年份降序
  return Array.from(groups.entries())
    .sort(([a], [b]) => b - a)
    .map(([year, projects]) => ({ year, projects }));
}

左右交替布局

桌面端的卡片需要左右交替排列,通过计算全局索引来实现:

// 计算全局索引用于左右交替
const globalIndex = projectsByYear
  .slice(0, yearIndex)
  .reduce((acc, g) => acc + g.projects.length, 0) + projectIndex;

const isLeft = globalIndex % 2 === 0;

然后根据 isLeft 决定卡片放在左边还是右边:

<div className={cn(
  'relative grid gap-4',
  isMobile
    ? 'grid-cols-[24px_1fr] pl-2'           // 移动端:单列
    : 'grid-cols-[1fr_48px_1fr]'            // 桌面端:三列
)}>
  {/* 左侧区域 */}
  {!isMobile && (
    <div className={cn('flex', isLeft ? 'justify-end' : 'justify-start')}>
      {isLeft && <TimelineCard project={project} />}
    </div>
  )}

  {/* 中间时间线节点 */}
  <TimelineNode milestone={project.milestone} />

  {/* 右侧区域 */}
  <div>
    {(isMobile || !isLeft) && <TimelineCard project={project} />}
  </div>
</div>

里程碑脉冲动画

对于标记为里程碑的项目,节点会有一个脉冲动画效果:

{milestone && (
  <motion.div
    className="absolute h-5 w-5 rounded-full bg-primary"
    initial={{ scale: 1, opacity: 0.5 }}
    animate={{ scale: 2.5, opacity: 0 }}
    transition={{
      duration: 2,
      repeat: Infinity,
      ease: 'easeOut',
    }}
  />
)}

滚动渐入动画

使用 Motion 的 whileInView 实现滚动时的渐入效果:

<motion.div
  initial={{ opacity: 0, y: 30 }}
  whileInView={{ opacity: 1, y: 0 }}
  viewport={{ once: true, margin: '-80px' }}
  transition={{ ...microDampingPreset, duration: 0.5 }}
  whileHover={{ y: -6, scale: 1.01 }}
>
  {/* 卡片内容 */}
</motion.div>

数据结构调整

为了支持时间线布局,项目数据需要添加几个新字段:

- id: "pet-social"
  title: "PetSocial 宠物社交"
  description: "..."
  link: "https://github.com/..."
  image: "/img/cover/1.webp"
  tags: ["Vue 3", "Java", "AI"]
  # 新增字段
  date: "2024-06-15"           # 项目日期
  milestone: true              # 是否里程碑
  milestoneLabel: "首个 AI 项目" # 里程碑标签

对于没有 date 字段的旧数据,代码会自动提供默认值,保证向后兼容。

最终效果

重构后的项目页看起来清爽多了:

重构后的项目页

改进点

方面改进
视觉层次左右交替 + 年份分隔,层次分明
时间感一眼就能看出项目的时间顺序
故事性从「起点」到现在,展示成长历程
交互体验滚动渐入 + 悬浮效果,更有活力
里程碑重要项目有特殊标记,突出重点

写在最后

这次重构让我意识到,展示方式和内容本身一样重要

同样的项目列表,用普通的卡片网格展示,就是一个冷冰冰的清单;用时间线展示,就变成了一个有温度的成长故事。

作为开发者,我们不仅要关注功能实现,也要思考如何更好地呈现内容。一个好的展示方式,能让访客更好地理解你、记住你。

如果你也想给自己的作品集加点「故事感」,不妨试试时间线布局。

© 2024 - 2026 吕小布 @insist
Powered by theme astro-koharu · Inspired by Shoka