基于 LangChain+Vue3 的多模态 AI 智能营养师Agent 开发实战

本项目是一个前后端分离的 AI 私人营养师系统,采用 LangChain/LangGraph 与 Vue 3 框架构建。后端利用 uv 进行环境管理,核心通过通义千问多模态大模型与 Tavily 搜索 API 协同工作,实现了“食材图片识别 + 联网真实菜谱检索”的 Agent 闭环,并使用 MongoDB 存储多轮对话上下文。前端基于 Vite + TypeScript 搭建,支持 SSE 流式文本渲染与腾讯云 COS 预签名图片直传。项目完整打通了多模态交互、Agent 持久化记忆与云存储集成的全链路流程。
准备工作:
工具:
工具名称
数量
备注
python环境
1
3.11以上
oss存储
1
我用的腾讯云
tavily平台开通
1
非必须
1
创建python 项目







12345
如图所示 我是用uv创建的 创建一个项目然后安装各种依赖
用于web服务的fastapi
uv add pydantic fastapi
langchian的包
uv add langchain
langchain 调用的搜索工具
uv add langchain-tavily
记忆存储我用的是mongdb
uv add langgraph-checkpoint-mongodb langgraph
我这边用的存储文件是腾讯云的所以还要安装 cos-python-sdk-v5
uv add cos-python-sdk-v5
2
编写日志工具

- 创建一个common的python包
- 然后在这个包下面创建一个logger.py文件 如图所示
代码内容如下 这样我们打印日志到控制台就会按照我们的格式进行
# logger.py
import logging
import sys
# 配置日志格式:时间 - 级别 - 模块 - 消息
LOG_FORMAT = "%(asctime)s - %(levelname)s - %(name)s - %(message)s"
def setup_logging():
logging.basicConfig(
level=logging.INFO,
format=LOG_FORMAT,
handlers=[
logging.StreamHandler(sys.stdout), # 输出到控制台
# logging.FileHandler("app.log") # 如果需要存到文件可以开启
]
)
# 创建一个全局的 logger 实例
logger = logging.getLogger("personal_chief")
3
创建数据模型

如图所示创建一个schemas包 然后在这个包下面创建一个chat.py文件
这个模型是用于接收前端传输过来的信息
from typing import List
from pydantic import BaseModel
# --- 数据模型 ---
class ChatRequest(BaseModel):
# 消息内容
message: str
# 图片地址
image_url: List[str]
# 会话id
thread_id: str
4
配置环境

在根目录下创建一个.env文件 然后书写以下内容 (没错君子也防 也得打码)
腾讯云的oss
https://cloud.tencent.com/document/product/436
阿里云百练模型
以及工具 TVILY 工具这个大家不用也可以就是ai自己想象不去百度
5
构建agent



12
如图所示创建一个agent 的python包 然后agent下面创建一个dietitian的python文件
代码如下我们简单讲解一下 大家也可以直接看视频
- 首先我们是进行构建一个模型 因为我们用的是qwen的他没有对langchain的一个直接支持 所以我们用openai的方式 这也是qwen官方推荐的方式
- 然后去创建一个工具 这个工具是用于模型搜索网页上的消息的毕竟是食谱网上有的总比ai自我发挥的好
- 然后是我写的一个提示词 告诉ai他是谁 要做什么
- 后面就是一个流式对话返回给前端这样是流式显示的字
- 再就是删除历史的会话记录
- 以及获取历史消息记录 做了一些处理把返回的结果解析为前端需要的格式
from langchain.chat_models import init_chat_model
from langchain.agents import create_agent
from langchain_core.messages import HumanMessage, AIMessage, AIMessageChunk
from langgraph.checkpoint.mongodb import MongoDBSaver
from langchain_tavily import TavilySearch
from pymongo import MongoClient # 引入原生客户端以彻底移除 with
from typing import List
import os
import dotenv
from common.logger import logger
dotenv.load_dotenv() # 加载 .env 文件
# 构建聊天模型
model = init_chat_model(
model="qwen3-omni-flash-2025-09-15",
model_provider="openai",
base_url=os.getenv("DASHSCOPE_BASE_URL"),
api_key=os.getenv("DASHSCOPE_API_KEY")
)
# 工具
web_search = TavilySearch(
max_results=5,
topic="general",
tavily_api_key=os.getenv("TVILY_API_KEY")
)
# 系统提示词
system_prompt = """
# 角色
你是一名 AI 私人营养师(Nutrition Chef)。
你的目标是:
根据用户提供的食材图片或食材清单,生成“健康、营养均衡、制作简单、食材利用率高”的菜谱推荐。
---
# 工作流程(必须严格执行)
## 1. 食材识别与评估
如果用户上传图片:
- 识别所有食材
- 判断新鲜度、可用量、是否变质
- 输出“当前可用食材清单”
格式:
- 食材名|新鲜度(1~10)|建议优先级
若发现疑似变质食材,必须提醒用户不要食用。
---
## 2. 营养需求分析
优先结合用户目标:
- 减脂
- 增肌
- 控糖
- 高蛋白
- 低盐
- 儿童餐
- 老人餐
如果用户未说明,默认按“普通成年人均衡饮食”处理。
---
## 3. 智能食谱检索
必须优先调用 web_search 工具 搜索真实菜谱。
搜索关键词需结合:
- 可用食材
- 用户目标
- 菜系
- 难度
例如:
“鸡胸肉 西兰花 减脂 高蛋白 食谱”
只有搜索不到时,才能自行生成菜谱。
---
## 4. 菜谱评分
对候选菜谱进行评分:
- 营养价值(0~10)
- 健康程度(0~10)
- 制作难度(0~ 10 )越简单分越高
- 食材利用率(0~10)
综合得分:
营养价值35% + 健康程度25% + 制作难度20% + 食材利用率20%
优先推荐:
- 高蛋白
- 高纤维
- 低油低糖
- 制作简单
- 少浪费
---
## 5. 结构化输出
按得分排序输出,要包含食谱信息、得分、推荐理由、营养分析、使用食材、做法、web_search查询出来食谱的参考图片,帮助用户快速做出决策。
# 推荐菜谱示例
## TOP 1:xxx
综合评分:92/100
### 推荐理由
- 高蛋白低脂
- 制作简单
- 食材利用率高
- ...
### 营养分析
- 热量:
- 蛋白质:
- 碳水:
- 脂肪:
### 使用食材
- xxx
- xxx
### 做法
1. ...
2. ...
### 营养建议
- ...
### 参考图

---
# 规则
你必须:
- 优先食品安全
- 优先营养均衡
- 优先真实可执行
- 优先减少浪费
- 优先调用 web_search 工具 搜索不到了才能自己发挥。
禁止:
- 编造危险饮食建议
- 忽略过敏与变质风险
- 只输出菜名不给做法
"""
# 创建记忆数据库
MONGODB_URI = "mongodb://localhost:27017"
checkpointer = MongoDBSaver(MongoClient(MONGODB_URI))
# 创建agent
agent = create_agent(
model=model,
tools=[web_search],
system_prompt=system_prompt,
checkpointer=checkpointer
)
# 流式对话
async def chat_dietitian(text: str, images: List[str], thread_id: str):
"""
对话营养师
:param text: 用户说的话
:param images: 图片
:param thread_id: 会话id
:return:
"""
logger.info(f"[用户]: {text}, images: {images}, thread_id: {thread_id}")
try:
# 判断是否有图片,封装不同格式的消息
if not images or all(not img.strip() for img in images):
message = HumanMessage(content=text)
else:
# 有多张图片,封装成列表
content_list = [{"type": "image", "url": url} for url in images if url.strip()]
# 加上文字
content_list.append({"type": "text", "text": text})
message = HumanMessage(content=content_list)
# 流式调用Agent
for chunk, metadata in agent.stream(
{"messages": [message]},
{"configurable": {"thread_id": thread_id}},
stream_mode="messages"
):
if isinstance(chunk, AIMessageChunk) and chunk.content:
yield chunk.content
except Exception as e:
logger.error(f"\n[错误]: {str(e)}")
yield "信息检索失败,试试看手动输入食物列表?"
# 清空会话
def clear_messages(thread_id: str):
"""
清空会话
:param thread_id: 会话id
"""
logger.info(f"清空历史消息,thread_id: {thread_id}")
checkpointer.delete_thread(thread_id)
# 查询会话历史
def get_messages_history(thread_id: str) -> list[dict[str, str]]:
"""
获取会话历史
:param thread_id: 会话id
:return: 历史记录
"""
logger.info(f"获取历史消息,thread_id: {thread_id}")
# 根据 thread_id 查询 checkpoint
checkpoint = checkpointer.get({"configurable": {"thread_id": thread_id}})
# 如果不存在,返回空列表
if not checkpoint:
return []
# 安全获取 messages
channel_values = checkpoint.get("channel_values")
if not channel_values:
return []
messages = channel_values.get("messages", [])
if not messages:
return []
# 转换消息格式
result = []
for msg in messages:
if not msg.content:
continue
if isinstance(msg, HumanMessage):
# 如果 msg.content 是列表(多图+文字)
if isinstance(msg.content, list):
texts = []
images = []
for item in msg.content:
if isinstance(item, dict):
if item.get("type") == "image" and item.get("url"):
images.append(item["url"])
elif item.get("type") == "text" and item.get("text"):
texts.append(item["text"])
result.append({
"role": "user",
"message": " ".join(texts) if texts else "",
"image_url": images
})
print(msg.content)
else:
# 纯文本消息
result.append({
"role": "user",
"message": msg.content,
"image_url": []
})
elif isinstance(msg, AIMessage):
result.append({"role": "assistant", "message": msg.content})
return result
6
编写api接口

- 如图所示创建一个api的python包
- 然后我们有两个python文件一个是和聊天相关的chat文件 另外一个是负责文件相关的file
- chat api文件内容如下 这里就是一个简单的调用
from fastapi import APIRouter
from starlette.responses import StreamingResponse
from agents.dietitian import chat_dietitian, get_messages_history, clear_messages
from schemas.chat import ChatRequest
router = APIRouter()
@router.post("/chat/stream")
async def chat_endpoint(request: ChatRequest):
"""
流式对话
Args:
request (ChatRequest): 请求参数
"""
return StreamingResponse(
chat_dietitian(request.message, request.image_url, request.thread_id),
media_type="text/event-stream"
)
@router.get("/chat/history/{thread_id}")
async def get_chat_history(thread_id: str):
"""
获取历史消息
Args:
thread_id (str): 会话id
"""
messages = get_messages_history(thread_id)
return {"messages": messages}
@router.delete("/chat/history/{thread_id}")
async def clear_chat_history(thread_id: str):
"""
清空历史消息
Args:
thread_id (str): 会话id
"""
clear_messages(thread_id)
return {"success": True}
- file api的内容如下 这是一个获取签名的方法 目的是让前端直接上传图片
import os
import uuid
from dotenv import load_dotenv
from fastapi import APIRouter
from pydantic import BaseModel
from qcloud_cos import CosConfig, CosS3Client
load_dotenv()
router = APIRouter()
# COS 配置
secret_id = os.getenv("COS_SECRET_ID")
secret_key = os.getenv("COS_SECRET_KEY")
app_id = os.getenv("COS_APP_ID")
region = os.getenv("COS_REGION", "ap-beijing")
bucket = os.getenv("COS_BUCKET")
config = CosConfig(
Region=region,
SecretId=secret_id,
SecretKey=secret_key,
Scheme="https"
)
client = CosS3Client(config)
@router.get("/file/presign")
def get_presigned_url(filename: str):
print(filename)
"""
获取签证
:param filename: 文件名
:return: 签证实体
"""
# 获取后缀
ext = filename.split(".")[-1].lower() if "." in filename else "jpg"
# 生成新的文件名
uid = str(uuid.uuid4())
new_filename = f"{uid}.{ext}"
# 生成 COS 预签名上传 URL
upload_url = client.get_presigned_url(
Method="PUT",
Bucket=f"{bucket}-{app_id}",
Key=new_filename,
Expired=120
)
# 生成访问地址
access_url = f"https://{bucket}-{app_id}.cos.{region}.myqcloud.com/{new_filename}"
return {
"uploadUrl": upload_url,
"accessUrl": access_url,
"filename": new_filename,
}
7
创建vue项目

如图所示 输入命令 pnpm create vite
- Project name: dietitian-ui 我们设置项目名称
- framework 选择vue
- variant 我选择的是ts
8
vue axios requset




12
首先输入命令 pnpm add axios 安装axios
然后创建一个utils 文件夹 这个文件夹里面负责放一些工具类 request.ts就是我们的请求工具类 内容如下
import axios, {type AxiosInstance, type AxiosRequestConfig, type AxiosResponse} from 'axios'
axios.defaults.headers['Content-Type'] = 'application/json;charset=utf-8'
// 创建axios实例
const service: AxiosInstance = axios.create({
baseURL: '/dietitian/api',
timeout: 10000
})
// request拦截器
service.interceptors.request.use(
(config: AxiosRequestConfig & { headers: any }) => {
return config
},
(error) => Promise.reject(error)
)
// 响应拦截器
service.interceptors.response.use(
(response: AxiosResponse) => {
return response.data
},
(error) => {
return Promise.reject(error)
}
)
// 封装 request 函数,支持泛型
const request = <T = any>(config: AxiosRequestConfig): Promise<T> => {
// 注意这里指定返回类型 T
return service.request<any, T>(config)
}
export default request
9
vue request api

- 在src下面新建一个api文件夹用于存放调用api的ts文件
- 然后创建chat 和file两个文件 chat是负责对话 file负责文件上传相关的api
- file.ts 代码如下
import request from "../utils/request.ts";
import type {IPresign} from "@/types/file.ts";
/**
* 获取预签名
* @param filename
*/
export function getPresignedUrl(filename: string) {
return request({
url: '/file/presign',
method: 'get',
params: {
filename
}
})
}
/**
* 上传文件
* @param url
* @param file
*/
export function uploadCosFile(url: string, file: File) {
return request<IPresign>({
url: url,
method: 'PUT',
data: file
})
}
- chat.ts代码如下 我们封装的requst无法进行流式的处理只能使用axios原生
import type {IAgentChat} from "@/types/agent.ts";
import axios from "axios";
import request from "@/utils/request.ts";
/**
* 使用 Axios 接收流式对话
* @param params 请求体
* @param onChunk 收到文本碎片的的回调
* @param onDone 完成后的回调
* @param onError 错误回调
*/
export async function chatStream(
params: IAgentChat,
onChunk: (text: string) => void,
onDone?: () => void,
onError?: (err: any) => void
) {
try {
const response = await axios.post('/dietitian/api/chat/stream', params, {
headers: {
'Accept': 'text/event-stream',
},
// 强制 Axios 使用 fetch 适配器以完美支持浏览器端的 ReadableStream
adapter: 'fetch',
responseType: 'stream'
});
// 此时 response.data 是一个原生的 ReadableStream
const stream = response.data as ReadableStream;
const reader = stream.getReader();
const decoder = new TextDecoder('utf-8');
while (true) {
const { value, done } = await reader.read();
if (done) break;
const chunkText = decoder.decode(value, { stream: true });
onChunk(chunkText);
}
if (onDone) onDone();
} catch (error) {
if (onError) onError(error);
else console.error('Axios stream error:', error);
}
}
/**
* 获取历史记录
* @param threadId
*/
export function getChatHistory(threadId: string) {
return request({
url: '/chat/history/'+threadId,
method: 'get',
})
}
/**
* 清空聊天记录
* @param threadId
*/
export function clearChatHistory(threadId: string) {
return request({
url: '/chat/history/'+threadId,
method: 'delete',
})
}
10
导入静态资源





123
这是几张图片 分别用于 添加图片的按钮图标 logo图标 以及发送按钮图标
add.png
2.83KB
logo.png
547.02KB
send.png
6.38KB
11
编写页面



12
页面代码如下
首先是输入框的一个动态高度计算
然后发送消息进行的一个实时的显示
以及图片上传
自动监听滚动的方法
<script setup lang="ts">
import {nextTick, onMounted, ref, watch} from 'vue'
import type {IChatMessage} from "@/types/chat";
import type {IAgentChat} from "@/types/agent";
import {chatStream, clearChatHistory, getChatHistory} from "@/api/chat";
import {getPresignedUrl, uploadCosFile} from "@/api/file.ts";
import type {IPresign} from "@/types/file.ts";
const textareaRef = ref<HTMLTextAreaElement | null>(null)
// 最大高度限制 (px)
const maxHeight = window.innerHeight * 0.5
// 动态调整高度
const adjustHeight = () => {
const el = textareaRef.value
if (!el) return
// 先重置高度
el.style.height = 'auto'
// 根据滚动高度动态计算
if (el.scrollHeight > maxHeight) {
el.style.height = `${maxHeight}px`
el.style.overflowY = 'auto'
} else {
el.style.height = `${el.scrollHeight}px`
el.style.overflowY = 'hidden'
}
}
// 聊天内容实体
const chat = ref<IAgentChat>({
message: "",
thread_id: "001",
image_url: []
})
/**
* 发送消息
*/
const sendMessage = async () => {
if (!chat.value.message.trim() && (!chat.value.image_url || chat.value.image_url.length === 0)) return;
// 压入用户自己的消息
chatMessage.value.push({
role: 'user',
message: chat.value.message,
image_url: chat.value.image_url
});
console.log(chatMessage.value)
// 临时保存用户输入
const userPrompt = chat.value.message;
const userImages = chat.value.image_url?.slice() || []; // 拷贝一份
chat.value.message = '';
chat.value.image_url = [];
// 预先压入 AI 空消息
chatMessage.value.push({role: 'assistant', message: ''});
const lastIndex = chatMessage.value.length - 1;
// 调用流式接口,传入 image_url
await chatStream(
{...chat.value, message: userPrompt, image_url: userImages},
(chunk) => {
chatMessage.value[lastIndex].message += chunk;
},
() => {
console.log('AI 回复渲染完毕');
},
(err) => {
console.error('流式请求失败:', err);
chatMessage.value[lastIndex].message = '网络出错了,请稍后再试。';
}
);
}
// 聊天消息列表
const chatMessage = ref<IChatMessage[]>([])
const chatContainerRef = ref<HTMLElement | null>(null)
// 监听自动跳转到底部
watch(() => chatMessage.value,
async () => {
// 等待 Vue 把新蹦出来的字渲染到 DOM 树中
await nextTick()
chatContainerRef.value!.scrollTo({top: chatContainerRef.value!.scrollHeight, behavior: "auto"});
},
{deep: true}
)
/**
* 获取会话的历史记录
*/
const getThreadHistory = () => {
getChatHistory(chat.value.thread_id).then(response => {
console.log(response)
chatMessage.value = response.messages
})
}
/**
* 清空历史
*/
const clearHistory = () => {
clearChatHistory(chat.value.thread_id).then(response => {
if (response.success) {
chatMessage.value.length = 0
}
})
}
/**
* 图片上传
*/
const handleFile = async (event: any) => {
const files = event.target.files
for (let i = 0; i <= files.length; i++) {
const file = files[i]
// 判断是否为图片
if (file.type.startsWith('image/')) {
const presign: IPresign = await getPresignedUrl(file.name)
await uploadCosFile(presign.uploadUrl, file);
chat.value.image_url?.push(presign.accessUrl)
} else {
alert("只能上传图片")
}
}
}
onMounted(() => {
getThreadHistory()
})
</script>
<template>
<div class="dietitian-chat">
<div class="chat__header">
<img class="chat__header-logo" src="@/assets/logo.png" alt="logo"/>
私人营养师
<button class="chat__header-clear" @click="clearHistory">清空记录</button>
</div>
<div class="chat__content" ref="chatContainerRef">
<div v-for="(item,index) in chatMessage" :key="index"
:class="`chat__content-wrapper chat__message--${item.role}`">
<div class="chat__content--ai-avatar" v-if="item.role=='assistant'">
<img width="100%" alt="头像" src="@/assets/logo.png">
</div>
<!-- 内容展示 -->
<div class="chat__content-message">
<div v-text="item.message" class="chat__content-text"/>
<div class="chat__content-img">
<div class="chat__content-img-wrapper" v-for="(img,index) in item.image_url" :key="index">
<img :src="img" alt="图片" width="100%">
</div>
</div>
</div>
</div>
<div class="chat__content-default" v-if="chatMessage.length==0">
<img class="chat__content-default-logo" src="@/assets/logo.png" alt="logo">
<span class="chat__content-default-title">欢迎使用AI营养师</span>
<span class="chat__content-default-description">我可以帮您分析食物营养、制定饮食计划、提供健康建议</span>
</div>
</div>
<div class="chat__actions">
<!-- 图片列表 -->
<div class="chat__file">
<div class="chat__file-imgbox" v-for="(item,index) in chat.image_url" :key="index">
<img :src="item" alt="图片" width="100%"/>
</div>
</div>
<!-- 内容输入部分 -->
<div class="chat__input-wrapper">
<button class="chat__actions-upload" @click="($refs.fileRef as HTMLInputElement).click()">
<img width="100%" src="@/assets/add.png" alt="上传">
</button>
<input ref="fileRef" type="file" accept="image/*" multiple style="display: none" @change="handleFile">
<textarea @keydown.prevent.enter="sendMessage" @input="adjustHeight" v-model="chat.message" ref="textareaRef"
class="chat__actions-input" placeholder="请输入消息..." rows="1"></textarea>
<button class="chat__actions-send" @click="sendMessage">
<img width="100%" src="@/assets/send.png" alt="发送">
</button>
</div>
</div>
</div>
</template>
<style scoped lang="scss">
.dietitian-chat {
background: linear-gradient(
to bottom right,
#f0fdf4,
#eff6ff
);
display: flex;
flex-direction: column;
height: 100vh;
}
// 聊天头部展示
.chat__header {
display: flex;
align-items: center;
gap: 15px;
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1),
0 1px 2px -1px rgba(0, 0, 0, 0.1);
padding: 15px 20px;
background-color: white;
border-bottom: 1px solid #ebe6e7;
font-size: 18px;
font-weight: bold;
color: #474747;
}
// 头部logo
.chat__header-logo {
width: 30px;
}
// 清空记录按钮
.chat__header-clear {
background-color: #10b981;
border: none;
color: white;
margin-left: auto;
padding: 6px 15px;
font-weight: bold;
border-radius: 10px;
}
// 默认内容部分
.chat__content-default {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 10px;
}
// 默认内容的logo
.chat__content-default-logo {
width: 100px;
}
// 默认内容标题
.chat__content-default-title {
color: #333333;
font-weight: bold;
font-size: 24px;
}
// 默认内容描述
.chat__content-default-description {
color: #6a7282;
font-size: 16px;
font-weight: bold;
}
// 聊天内容部分
.chat__content {
overflow: auto;
padding: 20px;
display: flex;
flex-direction: column;
gap: 20px;
align-items: flex-start;
flex: 1;
}
.chat__content-wrapper {
display: flex;
gap: 15px;
}
// 聊天内容的消息
.chat__content-message {
gap: 10px;
padding: 15px;
border-radius: 15px;
font-size: 16px;
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px -1px rgba(0, 0, 0, 0.1);
max-width: 50vw;
white-space: pre-wrap;
word-break: break-all;
}
// 为空显示正在思考
.chat__message--assistant .chat__content-text:empty::after {
content: "正在思考...";
display: inline-block;
color: #999;
font-style: italic;
font-size: 14px;
}
// 底部图片部分
.chat__content-img {
display: flex;
align-items: center;
justify-content: flex-start;
gap: 10px;
flex-wrap: wrap;
}
.chat__content-img-wrapper {
background-color: white;
border-radius: 5px;
border: 1px solid #eeeeee;
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
overflow: hidden;
}
// ai头像
.chat__content--ai-avatar {
border-radius: 50%;
width: 50px;
height: 50px;
line-height: 100px;
}
// 系统聊天消息
.chat__message--assistant {
align-self: flex-start;
}
// 系统聊天消息内容
.chat__message--assistant > .chat__content-message {
background-color: white;
}
// 用户部分聊天
.chat__message--user {
align-self: flex-end;
}
// 用户聊天消息内容
.chat__message--user > .chat__content-message {
color: white;
background-image: linear-gradient(to right, #3b82f6, #2563eb);
}
// 聊天的操作部分
.chat__actions {
display: flex;
flex-direction: column;
width: 90vw;
border-radius: 15px;
background-color: #ffffff;
padding: 10px 20px;
margin: 10px auto 20px;
}
// 文件部分展示
.chat__file {
display: flex;
align-items: flex-start;
justify-content: flex-start;
gap: 10px;
}
// 图片边框
.chat__file-imgbox {
display: flex;
align-items: center;
justify-content: center;
border: 1px solid #eeeeee;
overflow: hidden;
width: 40px;
height: 40px;
border-radius: 5px;
position: relative;
}
// 聊天内容输入
.chat__input-wrapper {
display: flex;
align-items: center;
justify-content: center;
gap: 15px;
width: 100%;
}
// 聊天输入的上传图片按钮
.chat__actions-upload {
padding: 8px;
display: flex;
align-items: center;
justify-content: center;
border: none;
border-radius: 50%;
width: 35px;
height: 35px;
background-color: transparent;
cursor: pointer;
&:hover {
background-color: #f5f5f5;
}
}
// 聊天输入的输入框
.chat__actions-input {
flex: 1;
outline: none;
border: none;
resize: none;
box-sizing: border-box;
font-size: 16px;
padding: 10px 0;
line-height: 1.5;
min-height: 38px; /* 初始高度 */
}
// 发送按钮样式
.chat__actions-send {
padding: 10px;
border-radius: 50%;
background-color: #eee;
width: 35px;
height: 35px;
display: flex;
align-items: center;
justify-content: center;
border: none;
cursor: pointer;
&:hover {
background-color: #ddd;
}
}
</style>
12
完整代码&效果
前端和后端都放附件里了 效果看视频
dietitian.zip
36.38MB
dietitian-ui.zip
615.67KB
0
0
0
qq空间
微博
复制链接
分享 更多相关项目
猜你喜欢
评论/提问(已发布 0 条)
0