👋 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';
它们是如何配合打造一个完美抽屉的?
- 最外层由
<Drawer>包裹,它是一个上下文(Context)提供者,负责告诉里面的子组件“现在是开着还是关着”。 - 触发开关
<DrawerTrigger>放在页面上可见的地方(比如头像),用户一点,信号传给<Drawer>,状态变为open: true。 - 弹出层
<DrawerContent>监听到状态变化,从屏幕底部优雅地滑出。 - 内容结构 遵循 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 色”,暗示这个功能很高级、很智能!💜
🧠 第二站:前端大脑 —— 状态管理 (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;
}
}
🎩 魔术揭秘:
- Prompt 的艺术:我们给 AI 设定了角色("你是一位头像设计师")和风格要求("风格卡通时尚好看")。
- 异步流:从
handleAction到OpenAI,整个链路都是异步的(Async/Await)。这意味着在等待 AI 作画时,前端展示 Loading,后端也不会阻塞,用户体验依然流畅。
🌈 总结:全栈的魅力
走到今天,你会发现全栈开发最迷人的地方在于 “掌控感”。
- 你在 Frontend 像搭积木一样用
Drawer家族组件构建了优雅的交互。 - 你在 Store 梳理了数据流,让状态更新井井有条。
- 你在 Backend 指挥强大的 AI 模型为你服务。
当你在手机模拟器上点击那个紫色按钮,看着 Loading 转圈,最后弹出一个根据你名字生成的精美卡通头像时,那种“我创造了这个世界”的成就感,就是编程最大的乐趣吧!🥰
💻 下期预告: 头像有了,是不是该发点带图的动态了?下一节,我们将深入探讨 图片上传与云存储,让你的应用不仅仅能看,还能“存”!
掘金的伙伴们,如果你觉得这篇文章对你有帮助,别忘了 点赞 👍、收藏 🌟、关注 👀 三连哦!你的支持是我持续更新的最大动力!
我们下期见!👋
本文代码基于 AI Fullstack Project 教学项目,仅供学习参考。