🚀 AI全栈项目第十五天:Mine界面打造与AI头像生成的奇妙之旅 ✨

0 阅读6分钟

👋 Hello 掘金的同学们! 欢迎回到我们的 AI全栈开发 系列教程!

不知不觉我们已经一起走到了第 15 天。半个月的坚持,我们从零开始搭建项目,现在的你是不是感觉前后端的交互越来越顺手了?这种打通任督二脉的感觉简直太棒了!😎

今天我们要搞点好玩的——给我们的用户增加一点“个性”。没错,我们要完成 Mine(个人中心) 页面,并且加入一个超级酷炫的功能:AI 自动生成头像!🤖🎨

想象一下,用户不需要自己在相册里翻找半天,只需要点一下“AI 生成”,一个专属的、独一无二的头像就诞生了。是不是很有极客范儿?

话不多说,系好安全带,我们发车啦!🚗💨


🎨 第一站:Mine 页面与 Drawer 组件的“积木艺术”

首先,我们需要一个入口。在移动端应用中,个人中心(Mine)通常是用户管理自己资料的地方。为了让体验更加丝滑,我们不想用生硬的弹窗,而是选择了 Shadcn UI 提供的 Drawer(抽屉)组件。

1.1 深度拆解:Drawer 组件家族 🧩

在看业务代码之前,我们需要先认识一下构建这个抽屉的“积木”。Shadcn 的 Drawer 组件采用的是组合式 API (Compound Components) 的设计模式,这非常灵活。

看看我们在 src/pages/Mine.tsx 引入的这组“全家桶”:

import {
  Drawer,            // 📦 根容器:管理抽屉的开关状态
  DrawerTrigger,     // 👆 触发器:点击它,抽屉就会滑出来
  DrawerContent,     // 📄 内容层:抽屉里真正装的东西(白色面板)
  DrawerHeader,      // 🏷️ 头部区域:通常放标题
  DrawerTitle,       // 📌 标题:对屏幕阅读器友好的标题组件
  DrawerDescription, // 📝 描述:副标题或说明文字
  DrawerFooter,      // 🦶 底部区域:通常放取消按钮
  DrawerClose        // ❌ 关闭动作:点击包裹在里面的元素会关闭抽屉
} from '@/components/ui/drawer';

它们是如何配合打造一个完美抽屉的?

  1. 最外层<Drawer> 包裹,它是一个上下文(Context)提供者,负责告诉里面的子组件“现在是开着还是关着”。
  2. 触发开关 <DrawerTrigger> 放在页面上可见的地方(比如头像),用户一点,信号传给 <Drawer>,状态变为 open: true
  3. 弹出层 <DrawerContent> 监听到状态变化,从屏幕底部优雅地滑出。
  4. 内容结构 遵循 Header (Title + Description) -> Body (自定义内容) -> Footer (Close) 的标准布局,既美观又符合无障碍访问(Accessibility)标准。

1.2 代码实战:构建 UI

理解了组件原理,我们来看代码就一目了然了。

📍 坐标frontend/notes/src/pages/Mine.tsx

// ...

// 👇 open 和 setOpen 用于手动控制抽屉状态(除了点击触发,我们还需要代码控制关闭)
<Drawer open={open} onOpenChange={setOpen}>
  
  {/* 👆 1. 触发器区域 */}
  <DrawerTrigger asChild>
    {/* asChild 表示 Trigger 不会多渲染一个 button,而是直接把点击事件绑定到下面的 div 上 */}
    <div className="h-16 w-16 rounded-full bg-primary/10 flex 
    items-center justify-center text-primary text-xl font-bold">
      <Avatar className="h-16 w-16">
        <AvatarImage src={user?.avatar} />
        <AvatarFallback className="bg-primary/10 text-primary text-xl font-bold">
        {user?.name?.[0].toUpperCase()}
        </AvatarFallback>
      </Avatar>
    </div>
  </DrawerTrigger>
  
  {/* 📄 2. 抽屉内容区域 */}
  <DrawerContent>
    <div className="mx-auto w-full max-w-sm">
      
      {/* 🏷️ 头部:告知用户这是干嘛的 */}
      <DrawerHeader className="text-left">
        <DrawerTitle>修改头像</DrawerTitle>
        <DrawerDescription>
          请选择一种方式更新您的个人头像
        </DrawerDescription>
      </DrawerHeader>
      
      {/* ✨ 核心操作区:三个按钮 */}
      <div className="p-4 space-y-3">
        {/* 📸 选项一:拍照 */}
        <Button
          variant="outline"
          className="w-full justify-start h-14 text-base"
          onClick={() => handleAction('camera')}
        >
          <Camera className="mr-3 h-5 w-5 text-blue-500"/>
          拍照
        </Button>

        {/* 🖼️ 选项二:从相册上传 */}
        <Button
          variant="outline"
          className="w-full justify-start h-14 text-base"
          onClick={() => handleAction('upload')}
        >
          <Upload className="mr-3 h-5 w-5 text-blue-500"/>
          从相册上传
        </Button>

        {/* 🤖 选项三:AI 生成头像 (今天的重头戏!) */}
        <Button
          variant="outline"
          className="w-full justify-start h-14 text-base
          bg-gradient-to-r from-purple-600 to-indigo-600 border-none  
          "
          onClick={() => handleAction('ai')}
        >
          {/* Sparkles 图标非常适合 AI 这种神奇的功能 */}
          <Sparkles className="mr-3 h-5 w-5 text-yellow-300"/>
          AI 生成头像
        </Button>
      </div>
      
      {/* 🦶 底部:取消按钮 */}
      <DrawerFooter className="pt-2">
        <DrawerClose asChild>
          <Button variant="ghost" className="w-full h-12">取消</Button>
        </DrawerClose>
      </DrawerFooter>

    </div>
  </DrawerContent>
</Drawer>

💡 设计亮点:

  • 交互逻辑:每个按钮都绑定了 handleAction 方法,并传入不同的参数('camera', 'upload', 'ai')。这是一种非常清晰的 事件委托 思想的简化版。
  • 视觉层级:给 AI 按钮加了一个 bg-gradient-to-r from-purple-600 to-indigo-600 紫色渐变背景,这是设计界的“AI 色”,暗示这个功能很高级、很智能!💜

image.png

🧠 第二站:前端大脑 —— 状态管理 (Zustand)

当用户激动地点击了“AI 生成头像”按钮,发生了什么?

// Mine.tsx 中的处理函数
const handleAction = async (type: string) => {
   setOpen(false); // 先把抽屉关上,提升体验
   if(type == 'ai'){
     setLoading(true); // 开启 loading 状态,因为 AI 生成需要时间
     await aiAvatar(); // 调用 store 中的 action
     setLoading(false); // 结束 loading
  }
}

这里调用了 aiAvatar()。这个方法在哪里?它存在于我们的状态管理库 Zustand 中。

📍 坐标frontend/notes/src/store/useUserStore.ts

// ... inside create store
aiAvatar: async () => {
    // 1. 获取当前用户信息
    // get() 是 Zustand 提供的各种访问 store 内部状态的方法
    const name = get().user?.name;
    
    // 2. 核心调用:请求 AI 生成头像的接口
    // 我们把用户名传过去,让 AI 根据名字找灵感
    const avatar = await getAiAvatar(name);
    
    // 3. 更新状态
    // set() 用于更新 store
    set({
        user: {
            ...get().user, // 保留原有的 user 信息(如 id, email 等)
            avatar // 只更新 avatar 字段为新生成的 URL
        }
    })
}

🤔 为什么要这么写?

我们将业务逻辑封装在 Store 中,而不是直接写在组件里。

  • 解耦:组件只负责“显示”和“触发”,Store 负责“怎么做”。
  • 复用:如果在其他页面也需要生成头像,直接调用 aiAvatar 即可。

🌉 第三站:通信桥梁 —— API 定义

前端需要向后端喊话:“嘿,给我变个头像出来!”📣

📍 坐标frontend/notes/src/api/user.ts

import instance from './axios-instance'; 

export const getAiAvatar = (name:string) => {
    // 发送 HTTP GET 请求
    // 注意:这里我们使用 GET 请求,把 name 作为查询参数传递
    return instance.get(`/ai/avatar?name=${name}`);
}   

📝 知识点卡片: 虽然创建资源常用 POST,但这里语义更偏向于“获取”(Get)一个基于名字计算出来的资源。所以使用 GET 请求配合 Query Parameters (?name=xxx) 是完全符合 RESTful 风格的。


⚙️ 第四站:后端引擎 —— NestJS 与 AI 服务

请求飞到了我们的后端服务器(NestJS)。

4.1 Controller (控制层)

Controller 是后端的大门,负责接收请求。

📍 坐标backend/posts/src/ai/ai.controller.ts

@Controller('ai')
export class AiController {
  constructor(private readonly aiService: AiService) {}

  // 对应前端的 GET /ai/avatar 请求
  @Get('avatar')
  async avatar(@Query('name') name: string) {
    // 委托给 aiService 去处理
    return this.aiService.avatar(name);
  }
}

@Query('name') 自动帮我们解析了 URL 中的 ?name=... 参数。

4.2 Service (服务层) —— AI 的栖息地 🤖

这里是核心逻辑所在。我们要调用 OpenAI (或兼容接口) 的 DALL·E 模型。

📍 坐标backend/posts/src/ai/ai.service.ts

首先,我们需要一个能够与 AI 对话的对象。

// 引入必要的库,这里使用的是 LangChain 的封装
import { DallEAPIWrapper } from '@langchain/openai'; 

@Injectable()
export class AiService {
  // 定义私有属性,用于存放图像生成器的实例
  private imageGenerator: DallEAPIWrapper;

  constructor() {
    // 💡 在构造函数中初始化模型
    this.imageGenerator = new DallEAPIWrapper({
      openAIApiKey: process.env.OPENAI_API_KEY, // 记得在 .env 文件里配好 Key!
      n: 1,               // 一次生成一张图
      size: '1024x1024',  // 高清大图
      quality: 'standard' // 标准质量
    })
  }

接下来是具体的 avatar 方法,这里包含了我们的 Prompt Engineering(提示词工程)

  async avatar(name: string) {
    // invoke 方法调用 DALL-E
    // 这里的字符串就是我们发给 AI 的“咒语” (Prompt)
    const imageUrl = await this.imageGenerator.invoke(`
      你是一位头像设计师
      请根据用户的姓名${name}
      生成一个专业头像
      风格卡通时尚好看
    `);
    
    console.log(imageUrl, '/////');
    return imageUrl;
  }
}

🎩 魔术揭秘:

  1. Prompt 的艺术:我们给 AI 设定了角色("你是一位头像设计师")和风格要求("风格卡通时尚好看")。
  2. 异步流:从 handleActionOpenAI,整个链路都是异步的(Async/Await)。这意味着在等待 AI 作画时,前端展示 Loading,后端也不会阻塞,用户体验依然流畅。

🌈 总结:全栈的魅力

走到今天,你会发现全栈开发最迷人的地方在于 “掌控感”

  • 你在 Frontend 像搭积木一样用 Drawer 家族组件构建了优雅的交互。
  • 你在 Store 梳理了数据流,让状态更新井井有条。
  • 你在 Backend 指挥强大的 AI 模型为你服务。

当你在手机模拟器上点击那个紫色按钮,看着 Loading 转圈,最后弹出一个根据你名字生成的精美卡通头像时,那种“我创造了这个世界”的成就感,就是编程最大的乐趣吧!🥰

💻 下期预告: 头像有了,是不是该发点带图的动态了?下一节,我们将深入探讨 图片上传与云存储,让你的应用不仅仅能看,还能“存”!

掘金的伙伴们,如果你觉得这篇文章对你有帮助,别忘了 点赞 👍、收藏 🌟、关注 👀 三连哦!你的支持是我持续更新的最大动力!

我们下期见!👋


本文代码基于 AI Fullstack Project 教学项目,仅供学习参考。