🚀 Vibe Coding 实战复盘:一个人 + AI,从零打造会聊天的个人主页

从技术选型、页面设计、功能打磨,到部署上线:这是一次用 AI 辅助完成个人主页的完整实践记录。

项目地址:github-yueyq-homepage
网页地址:yueyq-homepage

成果展示 :

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述


🌱 起因:为什么要做这个项目?

这段时间我一直在学习 AI Agent 和 Vibe Coding,但学着学着总会有一个想法:如果只是看教程、记笔记,而不真正做一个项目,很难把这些东西变成自己的能力。

于是我决定做一个个人主页。

这个主页不只是简单展示头像、简介和联系方式,而是希望它能带一点“智能感”:访客不仅可以浏览我的信息,还能和一个“数字分身”对话,询问关于我的研究方向、兴趣、项目经历等内容。

最初的需求很简单:

  1. 有一个简洁的个人信息展示区,让别人快速知道我是谁、在做什么;
  2. 有一个可以对话的「数字分身」,访客可以问关于我的问题;
  3. 页面风格简约清爽,带一点科技感,同时适配移动端;
  4. 整个开发过程尽量使用 AI 辅助完成,真正体验一次 Vibe Coding。

这篇文章就是这次实践的完整复盘。


🧱 技术选型:为什么选择 Astro + TypeScript + Tailwind CSS + MDX?

最开始,我其实是用最原始的 HTML + CSS + JavaScript 写了一个原型,主打一个“能跑就行”。

但很快就遇到几个问题:

  • 没有类型检查,后续改聊天逻辑时很容易出错;
  • CSS 写得比较散,想统一调整颜色和间距时需要到处找;
  • 没有组件化,头像区、信息卡片、聊天区都挤在一起;
  • 后续如果要接入 API、写博客内容,维护成本会越来越高。

所以我决定把它重构成一个更工程化的项目,最终选型如下:

Astro + TypeScript + Tailwind CSS + MDX

Astro:适合个人主页这种“静态展示 + 局部交互”的场景

个人主页的大部分内容其实都是静态的,比如个人介绍、研究方向、时间线、项目展示等。真正需要交互的部分只有聊天窗口、作品抽屉、点击特效这些局部功能。

Astro 的 Islands Architecture 很适合这种场景:页面大部分内容可以作为静态 HTML 输出,只有需要交互的组件才加载 JavaScript。这样既能保证页面加载速度,又不会牺牲交互体验。

TypeScript:让聊天逻辑和 API 调用更稳

接入大模型 API 之后,前端和服务端之间会传递消息数组、角色字段、回复内容等数据。如果全靠 JavaScript,很容易因为字段名写错、结构不一致导致 bug。

TypeScript 的好处就是:当代码量开始增加时,它能提前帮你发现很多问题。尤其是聊天消息这种有明确结构的数据,非常适合用类型约束。

Tailwind CSS:适合快速迭代 UI

Vibe Coding 的开发节奏通常是:

描述想法 → AI 生成代码 → 看效果 → 继续调整

Tailwind CSS 很适合这种模式。它可以直接在组件里写样式,不需要在 HTML 和 CSS 文件之间来回切换。

更重要的是,Tailwind 可以配合设计令牌统一颜色、字号、间距。后续如果想调整整体风格,只需要改一处配置,而不是全局搜索替换。

MDX:让长内容更容易维护

个人主页里有一些较长的介绍内容,如果全部写成 HTML,会比较臃肿。MDX 既保留了 Markdown 的易写性,又可以在需要的时候嵌入组件,适合后续扩展成博客或项目介绍页。

这次选型给我的一个体会是:技术栈不是越新越好,而是要看它能不能解决当前项目的核心问题。对于个人主页来说,Astro 的静态能力、Tailwind 的快速样式开发、TypeScript 的类型安全,刚好形成了一个比较平衡的组合。


🗂️ 架构设计:先拆结构,再写代码

确定技术栈之后,我没有马上开始写页面,而是先把页面结构拆了一遍。

整体结构大概是这样:

┌──────────────────────────────┐
│          BaseLayout           │
│  ┌────────────────────────┐  │
│  │    ProfileHeader        │  │  头像、名字、一句话介绍
│  │    InfoCard             │  │  个人简介、方向标签、联系方式
│  │    Timeline             │  │  成长时间线
│  │    Footer               │  │  页脚信息
│  └────────────────────────┘  │
│                              │
│  ┌────── 浮动交互层 ───────┐  │
│  │  PortfolioDrawer        │  │  左侧作品展示抽屉
│  │  ChatSection            │  │  右下角数字分身聊天窗口
│  └─────────────────────────┘  │
└──────────────────────────────┘

这里主要做了两个设计决策。

1. 页面内容和浮动交互分离

个人介绍、时间线、联系方式这些信息走正常文档流,保证阅读体验清晰稳定。

聊天窗口和作品展示抽屉则采用浮动层设计:聊天入口固定在右下角,作品展示按钮固定在左侧。这样它们不会打断页面阅读,但访客随时可以打开。

2. 组件职责尽量单一

我把页面拆成了几个独立组件:

组件 职责
ProfileHeader.astro 展示头像、名字和一句话介绍
InfoCard.astro 展示 Focus / Interests / Contact
Timeline.astro 展示成长时间线,桌面端左右交替,移动端单列
ChatSection.astro 实现聊天按钮、聊天面板和客户端交互
PortfolioDrawer.astro 实现左侧作品展示抽屉
Footer.astro 展示动态年份和页脚标语

这样拆分之后,后续让 AI 修改某个功能时,可以直接指定组件。例如:

只修改 ChatSection.astro,把聊天窗口从居中弹窗改成右下角浮窗。

这种指令比“帮我改一下页面”要清晰得多,也更符合 Vibe Coding 的工作方式:人负责拆解问题和做决策,AI 负责具体实现。


🎨 设计迭代:从“能用”到“有质感”

这个项目里,我花时间最多的不是功能,而是设计细节。

整体设计大概经历了三轮迭代。


第一轮:先做出能跑的原型

第一版非常简单:头像用渐变色块代替,信息区用几个普通卡片展示,聊天区用关键词匹配返回固定回答。

这个阶段的目标不是好看,而是验证功能是否成立:

  • 页面能否正常展示?
  • 手机端能否浏览?
  • 聊天窗口能否打开和关闭?
  • 消息能否发送和渲染?
  • 页面结构是否方便后续继续扩展?

原型阶段最重要的是快,不要一开始就陷入颜色、阴影、圆角这些细节。


第二轮:去掉“模板感”

第一版虽然能用,但有一个明显问题:看起来太像模板站。

具体表现包括:

  • 标题前面到处是 📌🚀💡 这类 emoji;
  • 多个白色卡片从上到下平铺,缺少层次;
  • 聊天区有一个绿色“在线”标签,看起来像客服插件;
  • 信息区和功能区没有明显主次。

于是我做了几处调整:

  1. 把 emoji 标题换成英文 section label,例如 FocusInterestsContact
  2. 标签使用 uppercase、小字号、浅色文字,让它更像设计元素;
  3. 个人信息区去卡片化,用细线分隔,让页面更透气;
  4. 只有聊天窗口保留明显卡片样式,突出它的功能属性;
  5. 聊天头部改成简洁的一行:圆点 +「数字分身」+「问我任何问题」。

这一步让我意识到:页面是否有质感,很多时候不是靠加东西,而是靠删东西。删掉过度装饰,统一视觉语言,页面会立刻干净很多。


第三轮:增加层次感和动态细节 ✨

“能用”和“好看”之间,差的往往是一些不显眼的细节。

配色调整

最开始背景颜色接近白色,卡片也是白色,所以页面层次不明显。后来我把背景稍微加深,卡片立刻就浮了出来。

同时我统一调整了文字色阶:

  • 主文字颜色更深,保证可读性;
  • 次级文字不要太浅,避免小字号看不清;
  • 分隔线从半透明改成更明确的浅灰蓝;
  • 品牌蓝只用于强调,不大面积滥用。

这类调整看起来很细,但对整体观感影响很大。

动态粒子背景

为了让页面有一点科技感,我加了一个粒子网络背景。

实现方式是用 Canvas 在页面底部绘制 30 个半透明粒子。粒子会缓慢移动,距离较近时用细线连接。

核心结构大概是这样:

interface Particle {
  x: number;
  y: number;
  vx: number;
  vy: number;
  r: number;
  opacity: number;
}

const COUNT = 30;
const MAX_DIST = 130;

function draw(): void {
  ctx.clearRect(0, 0, width, height);

  for (let i = 0; i < particles.length; i++) {
    const p = particles[i];

    p.x += p.vx;
    p.y += p.vy;

    // 边界反弹、速度限制、微扰等逻辑
    // ...

    for (let j = i + 1; j < particles.length; j++) {
      const q = particles[j];
      const dist = Math.hypot(p.x - q.x, p.y - q.y);

      if (dist < MAX_DIST) {
        const alpha = (1 - dist / MAX_DIST) * 0.16;

        ctx.strokeStyle = `rgba(91,158,207,${alpha})`;
        ctx.beginPath();
        ctx.moveTo(p.x, p.y);
        ctx.lineTo(q.x, q.y);
        ctx.stroke();
      }
    }

    ctx.fillStyle = `rgba(91,158,207,${p.opacity})`;
    ctx.beginPath();
    ctx.arc(p.x, p.y, p.r, 0, Math.PI * 2);
    ctx.fill();
  }

  requestAnimationFrame(draw);
}

这里最关键的是克制:粒子不能太多,颜色不能太亮,动画不能太快。它应该只是背景氛围,而不是抢走页面主体的注意力。

另外,Canvas 层设置了:

pointer-events: none;
z-index: 0;

这样它不会影响用户点击、滚动和输入。

点击特效

我还加了一个轻量级点击特效:用户点击页面空白区域时,鼠标位置会随机出现一个小图案,比如 ,然后向上漂浮并渐隐。

核心逻辑如下:

const ICONS = ["♥", "✦", "◆", "●"];

function spawn(x: number, y: number): void {
  const icon = ICONS[Math.floor(Math.random() * ICONS.length)];
  const el = document.createElement("span");

  el.textContent = icon;
  el.style.cssText = `
    position: fixed;
    left: ${x}px;
    top: ${y}px;
    pointer-events: none;
    z-index: 9999;
    font-size: 18px;
    color: rgba(91,158,207,0.7);
    transform: translate(-50%, -50%) scale(0.3);
    transition:
      transform 750ms cubic-bezier(0.22, 0.61, 0.36, 1),
      opacity 750ms ease-out;
  `;

  document.body.appendChild(el);

  requestAnimationFrame(() => {
    el.style.transform = "translate(-50%, -50%) translateY(-32px) scale(1)";
    el.style.opacity = "0";
  });

  setTimeout(() => el.remove(), 800);
}

这个功能也有一个小细节:在按钮、输入框、链接等交互元素上点击时,不触发特效,避免干扰正常操作。

作品展示抽屉

作品展示没有做成普通列表,而是放在左侧抽屉里。

左侧中间有一个竖式按钮,点击后抽屉从左侧滑入,展示几个方向的产出。关闭方式支持按钮、遮罩和 ESC 键。

这种设计的好处是:作品集不会挤占主页主要空间,但又足够容易被发现。

成长时间线

时间线部分也做了排版优化。

移动端采用左侧竖线 + 单列内容,保证阅读顺畅;桌面端则采用左右交替布局,中间一条线贯穿,节点标记每个阶段。

这种结构比普通列表更有节奏感,也更适合展示个人经历。

<div class="timeline relative">
  <div class="timeline-line absolute md:left-1/2 md:-translate-x-px"></div>

  {items.map((item, i) => {
    const isLeft = i % 2 === 0;

    return (
      <div class={`flex gap-5 ${isLeft ? "md:flex-row" : "md:flex-row-reverse"}`}>
        <div class="timeline-dot"></div>
        <div class="hidden md:block md:flex-1" />
        <div class="timeline-card">
          <!-- 时间线内容 -->
        </div>
      </div>
    );
  })}
</div>

整个设计迭代过程,其实就是不断重复这个循环:

描述想要的效果 → AI 生成实现 → 看页面效果 → 说明哪里不满意 → AI 继续修改

Vibe Coding 不是一次性生成完美项目,而是通过高频、小步、可见的反馈,把结果逐渐逼近你想要的样子。


🤖 数字分身:从关键词匹配到真实 AI 对话

这个项目最有意思的功能,是右下角的「数字分身」。

一开始我只是用关键词匹配实现了一个简易版本:

if (input.includes("研究方向")) {
  return "我的研究方向是伪装图像生成。";
}

if (input.includes("兴趣")) {
  return "我平时比较关注 AI Agent、深度学习和前端开发。";
}

这种方式可以跑,但缺点非常明显:

  • 可扩展性差;
  • 回答生硬;
  • 只能覆盖预设问题;
  • 稍微换一种问法就匹配不到。

所以后面我决定接入大模型 API,让数字分身真正具备对话能力。


接入 DeepSeek API

接入 API 时,我重点考虑了三个问题:安全、身份和边界。

1. 安全:API Key 不能出现在浏览器端 🔐

API Key 绝对不能写在前端代码里,也不能用 PUBLIC_ 这类会暴露到浏览器端的环境变量。

最终采用的结构是:

浏览器
  ↓
POST /api/chat
  ↓
Astro 服务端 API Route
  ↓
DeepSeek API
  ↓
返回模型回复

也就是说,浏览器只请求我自己的 /api/chat,真正的 DeepSeek API Key 只存在服务端环境变量里。

服务端通过:

import.meta.env.DEEPSEEK_API_KEY

读取密钥,前端完全拿不到。


2. 身份:它不是通用机器人,而是“我”

这个功能的重点不是做一个通用聊天助手,而是做一个“数字分身”。

所以系统提示词非常关键。最初我写的是“你是我的数字分身”,结果模型经常用第三人称描述我,例如“yueyq 是一个……”。后来我把提示词改成:

你就是 yueyq 本人。访客正在和你对话,你要用第一人称“我”回答所有问题。

这样模型才稳定地用第一人称回复。


3. 边界:不知道就说不知道

另一个坑是模型会“友好地编造”。

比如访客问它年龄、具体经历、某些没有提供的信息时,如果提示词没有明确限制,它就可能为了保持对话自然,生成一些看似合理但实际不存在的内容。

所以我在系统提示词里加了严格边界:

你只能根据我提供的信息回答问题。
如果访客问到未提供的信息,必须直接说:
“这个我不太清楚,建议发邮件进一步交流~”

不要为了显得友好而编造比喻、经历或细节。
不确定的事一概说不清楚。

这个边界非常重要。数字分身的目标不是“什么都能答”,而是“在已知信息范围内可靠地答”。


服务端 API 端点

核心代码如下:

// src/pages/api/chat.ts
export const POST: APIRoute = async ({ request }) => {
  const apiKey = import.meta.env.DEEPSEEK_API_KEY;
  const body = await request.json();

  const res = await fetch("https://api.deepseek.com/v1/chat/completions", {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      Authorization: `Bearer ${apiKey}`,
    },
    body: JSON.stringify({
      model: "deepseek-chat",
      messages: [
        { role: "system", content: SYSTEM_PROMPT },
        ...body.messages,
      ],
      temperature: 0.7,
      max_tokens: 600,
    }),
  });

  const data = await res.json();

  return new Response(
    JSON.stringify({
      reply: data.choices[0].message.content,
    }),
    {
      headers: {
        "Content-Type": "application/json",
      },
    },
  );
};

客户端则保留最近几轮对话,让模型具备基本上下文:

const history: ChatMessage[] = [];

async function fetchReply(userMessage: string): Promise<string> {
  const messages = [
    ...history.slice(-10),
    {
      role: "user",
      content: userMessage,
    },
  ];

  const res = await fetch("/api/chat", {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
    },
    body: JSON.stringify({ messages }),
  });

  const data = await res.json();
  return data.reply;
}

这里我只保留最近 10 轮消息,避免上下文过长,也避免无意义地增加 token 消耗。


🌐 部署上线:从静态站到 SSR

最开始,这个项目只是一个普通静态站,Astro 默认构建就可以直接部署。

但接入 /api/chat 后,项目就不再只是纯静态页面了,因为它需要服务端 API Route 来代理请求大模型。这时就需要切换到 SSR 模式。

一开始我用了 @astrojs/node 适配器,本地运行没问题,但部署到 Vercel 后出现了 404。后来才意识到:部署到 Vercel 时,应该使用 Vercel 对应的适配器。

最终配置如下:

// astro.config.mjs
import { defineConfig } from "astro/config";
import tailwind from "@astrojs/tailwind";
import mdx from "@astrojs/mdx";
import vercel from "@astrojs/vercel";

export default defineConfig({
  integrations: [tailwind(), mdx()],
  output: "server",
  adapter: vercel(),
});

环境变量则在 Vercel 后台配置:

DEEPSEEK_API_KEY=你的 API Key

这样 API Key 不会和代码一起提交,也不会暴露到浏览器端。


⚠️ 一次 API Key 泄露事故:最重要的一课

这个项目里最惊险的一次踩坑,是 API Key 泄露。

早期提交时,我没有把 .vercel/output/ 这类构建产物目录加入 .gitignore。结果构建产物被提交到了 GitHub,而其中包含了和环境变量相关的内容,最终触发了 GitGuardian 的泄露提醒。

这个提醒非常有价值,因为它说明:问题不一定出现在你当前的源码文件里,也可能出现在历史提交、构建产物、预览输出或被忽略掉的某个目录里。

发现问题后,我第一时间做了三件事。

1. 立刻撤销旧 Key

这一步比清理 Git 历史更重要。

一旦 API Key 出现在公开仓库里,就应该默认它已经不安全。即使后来删掉文件,只要旧 Key 没有撤销,它依然可能被别人使用。

所以第一步永远是:

撤销旧 Key → 新建 Key → 重新配置环境变量

2. 清理 Git 历史

仅仅删除当前文件是不够的,因为 Git 历史里仍然保留着旧提交。

我当时使用 git filter-branch 清理了 .vercel/ 目录:

git filter-branch --force --index-filter \
  "git rm --cached --ignore-unmatch -r .vercel" \
  --prune-empty -- --all

git update-ref -d refs/original/refs/heads/main
git reflog expire --expire=now --all
git gc --prune=now --aggressive
git push --force --all

不过现在回头看,个人项目里更稳妥的方案其实是:撤销旧 Key 后,直接重建一个干净仓库,只保留当前安全代码,不带旧 Git 历史。

3. 补全 .gitignore

最后,我补全了 .gitignore

.env
.env.*
!.env.example

dist/
.astro/
.vercel/
node_modules/

.env.example 只保留占位符:

DEEPSEEK_API_KEY=your_deepseek_api_key_here

这次事故给我的教训非常直接:

  • .env 必须在项目最开始就加入 .gitignore
  • 构建产物目录也必须忽略;
  • 不要把任何真实 Key 写进 README、注释、示例文件;
  • 只要出现泄露提醒,就先撤销旧 Key,不要先争论是不是误报;
  • 清理历史是补救,撤销 Key 才是止血。

🧠 Vibe Coding 复盘:AI 到底帮我做了什么?

回顾整个项目,AI 并不是简单地“替我写代码”,而是在不同环节扮演了不同角色。


AI 主导、我审核的部分

这些任务相对明确,交给 AI 效率非常高:

  • Tailwind 样式编写;
  • 响应式布局调整;
  • 粒子背景动画;
  • 点击特效;
  • 抽屉面板交互;
  • 重复性重构,比如替换硬编码色值、统一 class 命名。

这类工作如果手写会很耗时间,但本质上是“明确目标下的实现细节”,非常适合交给 AI。


我主导、AI 辅助的部分

这些部分需要先做判断,再让 AI 执行:

  • 技术选型;
  • 页面信息架构;
  • 组件拆分方式;
  • 数字分身的系统提示词;
  • 聊天 API 的安全设计;
  • 部署方案选择。

例如技术选型时,AI 可以给出 Astro、Next.js、Vue、纯 HTML 等方案的对比,但最终要不要用 Astro,还是要结合项目目标来判断。


必须由我自己判断的部分

有些事情 AI 可以辅助,但不能替代人的判断。

比如:

  • 当前设计是不是太像模板站;
  • 信息层次是否清晰;
  • 某个动效是不是太抢眼;
  • 一个功能到底值不值得做;
  • 数字分身的回答是否符合“我”的表达方式。

这些判断依赖审美、语境和目标感。AI 可以执行修改,但很难独立判断“什么才是刚刚好”。


💡 几个关键认知

1. 精准描述比“让 AI 自由发挥”更重要

当我只说“帮我改好看一点”时,AI 给出的结果通常比较泛,甚至会继续堆装饰。

但如果我明确描述:

把 emoji 标题换成英文 uppercase 小标签;
字号 12px;
颜色使用次级文字色;
个人信息区去掉卡片阴影;
只保留细线分隔。

AI 的输出就会非常接近预期。

Vibe Coding 不是“你随便说一句,AI 自动猜出完美结果”,而是“你越清楚自己要什么,AI 越能快速帮你实现”。


2. 小步迭代比一次性生成更可靠

这个项目我做了很多次小提交,每次只改一个组件、一个样式点或一个功能点。

我的迭代节奏大概是:

提出一个明确改动
→ AI 生成代码
→ 本地看效果
→ 继续描述不满意的地方
→ AI 修改
→ 满意后提交

每一轮都很小,所以即使 AI 改错了,也能很快定位问题。

相比一次性让 AI 生成整个项目,小步迭代更稳,也更容易保持项目结构清晰。


3. 人负责价值判断,AI 负责实现细节

这是我对 Vibe Coding 最核心的理解。

AI 很擅长写样式、补类型、改结构、实现动画、接 API;但它不一定知道这个功能是否值得做,也不一定知道这个页面应该呈现什么气质。

真正决定项目质量的,仍然是人的判断:

  • 这个页面应该给人什么感觉?
  • 访客最先看到什么?
  • 哪些内容应该弱化?
  • 哪些信息应该突出?
  • 数字分身应该回答到什么边界?

AI 提高的是实现速度,而不是替代产品判断。


📝 结语

从最开始的 HTML + CSS + JavaScript 三文件原型,到后来的 Astro + TypeScript + Tailwind 工程化项目,再到接入 DeepSeek API、部署到 Vercel、处理 API Key 泄露问题,这个个人主页项目给了我一次非常完整的 Vibe Coding 实践体验。

如果完全手写,这个项目可能要多花两到三倍时间。但这次实践让我觉得,Vibe Coding 最大的价值不只是“快”,而是它让开发者可以把更多注意力放在真正重要的事情上:

  • 信息怎么组织;
  • 页面应该是什么风格;
  • 交互是否自然;
  • AI 的身份边界如何设计;
  • 安全问题如何处理。

而 CSS 细节、动画代码、响应式适配、API 调用这些实现工作,则可以更多交给 AI 来完成。

如果你也在学习 AI Agent 或 Vibe Coding,我非常建议动手做一个小项目。哪怕只是一个个人主页,也足够让你经历完整的需求拆解、技术选型、设计迭代、接口接入和部署上线流程。

看十篇教程,不如亲手踩一次坑。🌟

Logo

这里是“一人公司”的成长家园。我们提供从产品曝光、技术变现到法律财税的全栈内容,并连接云服务、办公空间等稀缺资源,助你专注创造,无忧运营。

更多推荐