一、前言
书接上文,上文中,我们介绍了通义千问AI落地
的后端接口。那么,接下来我们将继续介绍前端如何调用接口以及最后的效果;首先看效果:
上述就是落地到本微博客以后的页面效果,由于是基于落在现有项目之上,因此什么登录注册等基本功能都省去了,言归正传,下面我们将正式介绍通义千问AI落地的前端实现。
二、前端实现
2.1、前端依赖
前端所需依赖基本如下(本项目前端是基于Nuxtjs的,这样又益与SSE,所以有nuxt相关依赖
):
"dependencies": {
"@nuxtjs/axios": "^5.13.6",
"dayjs": "^1.11.12",
"element-ui": "^2.15.1",
"highlight.js": "^11.9.0", //代码高亮组件
"mavon-editor": "^2.10.4", //富文本展示
"nuxt": "^2.0.0",
"@stomp/stompjs": "^6.0.0", //
"ws": "^7.0.0" //websocket
}
2.2、页面布局
如上动图所示,前端项目主要是左右分布。其中,左侧负责管理各个session会话,包括激活会话、展示会话、删除会话等;
右侧则主要负责消息处理,包括消息收发、处理GPT生成的消息等。由于使用组件化开发,因此各个组件比较多,接下来的内容将采用 总-分
结构介绍。
2.2.1、主聊天页面
主聊天页面聚合了左侧的session管理和右侧的消息管理,内容如下:
<template>
<!-- 最外层页面于窗口同宽,使聊天面板居中 -->
<div class="home-view">
<!-- 整个聊天面板 -->
<div class="chat-panel">
<!-- 左侧的会话列表 -->
<div class="session-panel hidden-sm-and-down">
<div class="title">ChatGPT助手</div>
<div class="description">构建你的AI助手</div>
<div class="session-list">
<SessionItem
v-for="(session, index) in sessionList"
:key="session.id+index"
:active="session.id === activeSession.id"
:session="sessionList[index]"
class="session"
@click.native="sessionSwitch(session,index)"
@delete="deleteSession"
>
</SessionItem>
</div>
<div class="button-wrapper">
<div class="new-session">
<el-button @click="createSession">
<el-icon :size="15" class="el-icon-circle-plus-outline"></el-icon>
新的聊天
</el-button>
</div>
</div>
</div>
<!-- 右侧的消息记录 -->
<div class="message-panel">
<!-- 会话名称 -->
<div class="header">
<div class="front">
<div v-if="!isEdit" class="title">
<el-input style="font-size: 20px"
v-model="activeSession.topic"
@keyup.enter.native="editTopic()"
></el-input>
</div>
<div v-else class="title" style="margin-top: 6px;" @dblclick="editTopic()">
{{ activeSession.topic }}
</div>
<div class="description">与ChatGPT的 {{ activeSession?.messageSize ?? 0 }} 条对话</div>
</div>
<!-- 尾部的编辑按钮 -->
<div class="rear">
<i v-if="isEdit" @click="editTopic" class="el-icon-edit rear-icon"></i>
<i v-else @click="editTopic" class="el-icon-check rear-icon"></i>
</div>
</div>
<el-divider></el-divider>
<div class="message-list" id="messageListId">
<!-- 过渡效果 -->
<transition-group name="list">
<message-row
v-for="(message, index) in activeSession.messages"
:key="message.id+`${index}`"
:message="message"
></message-row>
</transition-group>
</div>
<div class="toBottom" v-if="!this.isScrolledToBottom">
<el-tooltip class="item" effect="light" content="直达最新" placement="top-center">
<el-button class="el-icon-bottom bottom-icon" @click="toBottom"></el-button>
</el-tooltip>
</div>
<!-- 监听发送事件 -->
<MessageInput @send="sendMessage" :isSend="isSend"></MessageInput>
</div>
</div>
</div>
</template>
<script>
import MessageInput from '@/components/gpt/MessageInput'
import MessageRow from '@/components/gpt/MessageRow'
import SessionItem from "@/components/gpt/SessionItem";
import {Client} from "@stomp/stompjs";
import dayjs from "dayjs";
import {scrollToBottom} from '@/utils/CommonUtil'
export default {
name: 'gpt',
layout: 'gpt',
middleware: 'auth', //权限中间件,要求用户登录以后才能使用
components: {
MessageInput, MessageRow, SessionItem
},
created() {
this.loadChart();
},
mounted() {
this.handShake()
this.$nextTick(() => {
this.messageListEl = document.getElementById('messageListId');
if (this.messageListEl) {
this.messageListEl.addEventListener('scroll', this.onScroll);
}
});
},
beforeUnmount() {
this.closeClient();
},
beforeDestroy() {
if (this.messageListEl) {
this.messageListEl.removeEventListener('scroll', this.onScroll);
}
},
watch: {
activeSession(newVal) {
if (newVal) {
//确保dom加载完毕
this.$nextTick(() => {
this.toBottom();
});
}
},
},
data() {
return {
sessionList: [],
activeSession: {
topic: '',
messageSize:0
},
isEdit: true,
isSend: false,
client: null,
gptRes: {
content:''
},
userInfo: null,
activeTopic:null,
//消息计数
msgCount:false,
isScrolledToBottom: true,
messageListEl: null,
msgQueue:[], //收到的消息队列;先将后端返回的消息收集起来放在一个队列里面,一点点追加到页面显示,这样可以使消息显示更加平滑
interval:null,
lineCount:5
}
},
methods: {
async loadChart() {
//查询历史对话
const queryArr = {
query: {
userId: this.userInfo.uid
},
pageNum: 1,
pageSize: 7
};
let res = await this.$querySession(queryArr);
if (res.code === 20000) {
if (res.data.length > 0) {
this.activeSession = res.data[0]
res.data.forEach(item => this.sessionList.push(item))
this.activeTopic = this.activeSession.topic
return
}
}
let session = {
topic: "新建的聊天",
userId: this.userInfo.uid,
}
let resp = await this.$createSession(session)
if (resp.code === 20000) {
session.id = resp.data.id
}
session.updateDate = this.now()
session.createDate = this.now()
session.messages = []
this.sessionList.push(session)
this.activeSession = this.sessionList[0]
this.activeTopic = this.activeSession.topic
},
editTopic() {
this.isEdit = !this.isEdit
if (this.isEdit) {
if (this.activeTopic===this.activeSession.topic)
return
this.$updateSession(this.activeSession).then(() => {
this.activeSession.updateDate = this.now()
this.activeTopic = this.activeSession.topic
})
}
},
deleteSession(session) {
let index = this.sessionList.findIndex((value) => {
return value.id === session.id
})
this.sessionList.splice(index, 1)
if (this.sessionList.length > 0) {
this.activeSession = this.sessionList[0]
return
}
this.createSession()
},
sessionSwitch(session,index) {
if (!session) return
if (session.messages && session.messages.length > 0) {
this.activeSession = null
this.activeSession = session
this.toBottom()
return;
}
this.$getSessionById(session.id).then(resp => {
if (resp.code === 20000) {
this.activeSession = null
this.activeSession = resp.data
this.toBottom()
this.sessionList[index] = resp.data
this.sessionList[index].messageSize = session.messageSize
}
})
},
createSession() {
let time = this.now()
let chat = {
id: time.replaceAll(" ", ""),
createDate: time,
updateDate: time,
messageSize:0,
topic: "新建的聊天",
messages: []
}
this.activeSession = chat
//从聊天列表头部插入新建的元素
this.sessionList.unshift(chat)
this.createChatMessage(chat)
},
async createChatMessage(chat) {
let resp = await this.$createSession(chat)
if (resp.code === 20000) {
this.activeSession.id = resp.data.id
}
},
//socket握手
handShake() {
this.client = new Client({
//连接地址要加上项目跟地址
brokerURL: `${process.env.socketURI}`,
onConnect: () => {
this.isSend = true
// 连接成功后订阅ChatGPT回复地址
this.client.subscribe('/user/queue/gpt', (message) => {
let msg = message.body
this.handleGPTMsg(msg)
})
}
})
// 发起连接
this.client.activate()
},
/**
* 处理GPT返回的消息
* @param msg
*/
handleGPTMsg(msg){
if (msg && msg !== '!$$---END---$$!'){
this.msgQueue.push(msg)
//设置定时器,每40毫秒取出一个队列元素,追加到页面显示,不要一下子把后端返回的内容全部追加到页面;使消息平滑显示
if (!this.interval){
this.interval = setInterval(()=>{
this.appendQueueToContent()
},40)
}
if (this.msgCount){
this.activeSession.messageSize+=1
this.msgCount = false
}
return;
}
if (msg === '!$$---END---$$!') {
clearTimeout(this.interval)
this.interval = null
//清理掉定时器以后,需要处理队列里面剩余的消息内容
this.handleLastMsgQueue()
}
},
/**
* 处理队列里面剩余的消息
*/
handleLastMsgQueue(){
while (this.msgQueue.length>0){
this.appendQueueToContent()
}
this.isSend = true
},
/**
* 将消息队列里面的消息取出一个字符追加到显示content
*/
appendQueueToContent() {
if (this.msgQueue.length <= 0) {
return
}
// 如果当前字符串还有字符未处理
const currentItem = this.msgQueue[0];
if (currentItem) {
// 取出当前字符串的第一个字符
const char = currentItem[0];
//不能频繁调用 到底部 函数
if (this.lineCount % 5 === 0) {
this.toBottom()
}
this.lineCount++
this.gptRes.content += char;
// 移除已处理的字符
this.msgQueue[0] = currentItem.slice(1);
// 如果当前字符串为空,则从队列中移除
if (this.msgQueue[0].length === 0) {
this.msgQueue.shift();
}
}
},
sendMessage(msg) {
this.buildMsg('user', msg)
let chatMessage = {
content: msg,
role: 'user',
sessionId: this.activeSession.id
}
try {
this.client.publish({
destination: '/ws/chat/send',
body: JSON.stringify(chatMessage)
})
} catch (e) {
console.log("socket connection error:{}", e)
this.handShake()
return
}
this.isSend = false
this.gptRes = {
role: 'assistant', content: '', createDate: this.now()
}
this.activeSession.messages.push(this.gptRes)
this.toBottom()
this.msgCount = true
this.activeSession.messageSize+=1
},
toBottom(){
scrollToBottom('messageListId')
},
buildMsg(_role, msg) {
let message = {role: _role, content: msg, createDate: this.now()}
this.activeSession.messages.push(message)
},
closeClient() {
try {
this.client.deactivate()
this.client = null
} catch (e) {
console.log(e)
}
},
now() {
return dayjs().format('YYYY-MM-DD HH:mm:ss');
},
onScroll(event) {
this.isScrolledToBottom = event.target.scrollHeight - event.target.scrollTop <= (event.target.clientHeight + 305);
},
},
async asyncData({store, redirect}) {
const userId = store.state.userInfo && store.state.userInfo.uid
if (typeof userId == 'undefined' || userId == null || Object.is(userId, 'null')) {
return redirect("/");
}
return {
userInfo: store.state.userInfo
}
},
}
</script>
<style lang="scss" scoped>
.home-view {
display: flex;
justify-content:center;
margin-top: -80px;
.chat-panel {
display: flex;
border-radius: 20px;
background-color: white;
box-shadow: 0 0 20px 20px rgba(black, 0.05);
margin-top: 70px;
margin-right: 75px;
.session-panel {
width: 300px;
border-top-left-radius: 20px;
border-bottom-left-radius: 20px;
padding: 5px 10px 20px 10px;
position: relative;
border-right: 1px solid rgba(black, 0.07);
background-color: rgb(231, 248, 255);
/* 标题 */
.title {
margin-top: 20px;
font-size: 20px;
}
/* 描述*/
.description {
color: rgba(black, 0.7);
font-size: 14px;
margin-top: 10px;
}
.session-list {
.session {
/* 每个会话之间留一些间距 */
margin-top: 20px;
}
}
.button-wrapper {
/* session-panel是相对布局,这边的button-wrapper是相对它绝对布局 */
position: absolute;
bottom: 20px;
left: 0;
display: flex;
/* 让内部的按钮显示在右侧 */
justify-content: flex-end;
/* 宽度和session-panel一样宽*/
width: 100%;
/* 按钮于右侧边界留一些距离 */
.new-session {
margin-right: 20px;
}
}
}
/* 右侧消息记录面板*/
.message-panel {
width: 750px;
position: relative;
.header {
text-align: left;
padding: 5px 20px 0 20px;
display: flex;
/* 会话名称和编辑按钮在水平方向上分布左右两边 */
justify-content: space-between;
/* 前部的标题和消息条数 */
.front {
.title {
color: rgba(black, 0.7);
font-size: 20px;
::v-deep {
.el-input__inner {
padding: 0 !important;
}
}
}
.description {
margin-top: 10px;
color: rgba(black, 0.5);
}
}
/* 尾部的编辑和取消编辑按钮 */
.rear {
display: flex;
align-items: center;
.rear-icon {
font-size: 20px;
font-weight: bold;
}
}
}
.message-list {
height: 560px;
padding: 15px;
// 消息条数太多时,溢出部分滚动
overflow-y: scroll;
// 当切换聊天会话时,消息记录也随之切换的过渡效果
.list-enter-active,
.list-leave-active {
transition: all 0.5s ease;
}
.list-enter-from,
.list-leave-to {
opacity: 0;
transform: translateX(30px);
}
}
::v-deep{
.el-divider--horizontal {
margin: 14px 0;
}
}
}
}
}
::v-deep {
.mcb-main {
padding-top: 10px;
}
.mcb-footer{
display: none;
}
}
.message-input {
padding: 20px;
border-top: 1px solid rgba(black, 0.07);
border-left: 1px solid rgba(black, 0.07);
border-right: 1px solid rgba(black, 0.07);
border-top-right-radius: 5px;
border-top-left-radius: 5px;
}
.button-wrapper {
display: flex;
justify-content: flex-end;
margin-top: 20px;
}
.toBottom{
display: inline;
background-color: transparent;
position: absolute;
z-index: 999;
text-align: center;
width: 100%;
bottom: 175px;
}
.bottom-icon{
align-items: center;
background: #fff;
border: 1px solid rgba(0,0,0,.08);
border-radius: 50%;
bottom: 0;
box-shadow: 0 2px 8px 0 rgb(0 0 0 / 10%);
box-sizing: border-box;
cursor: pointer;
display: flex;
font-size: 20px;
height: 40px;
justify-content: center;
position: absolute;
right: 50%;
width: 40px;
z-index: 999;
}
.bottom-icon:hover {
color: #5dbdf5;
cursor: pointer;
border: 1px solid #5dbdf5;
}
</style>
我们来着重介绍一下以下三个函数:
/**
* 处理GPT返回的消息
* @param msg
*/
handleGPTMsg(msg){
if (msg && msg !== '!$$---END---$$!'){
this.msgQueue.push(msg)
//设置定时器,每40毫秒取出一个队列元素,追加到页面显示,不要一下子把后端返回的内容全部追加到页面;使消息平滑显示
if (!this.interval){
this.interval = setInterval(()=>{
this.appendQueueToContent()
},40)
}
if (this.msgCount){
this.activeSession.messageSize+=1
this.msgCount = false
}
return;
}
if (msg === '!$$---END---$$!') {
clearTimeout(this.interval)
this.interval = null
//清理掉定时器以后,需要处理队列里面剩余的消息内容
this.handleLastMsgQueue()
}
},
/**
* 处理队列里面剩余的消息
*/
handleLastMsgQueue(){
while (this.msgQueue.length>0){
this.appendQueueToContent()
}
this.isSend = true
},
/**
* 将消息队列里面的消息取出一个字符追加到显示content
*/
appendQueueToContent() {
if (this.msgQueue.length <= 0) {
return
}
// 如果当前字符串还有字符未处理
const currentItem = this.msgQueue[0];
if (currentItem) {
// 取出当前字符串的第一个字符
const char = currentItem[0];
//不能频繁调用 到底部 函数
if (this.lineCount % 5 === 0) {
this.toBottom()
}
this.lineCount++
this.gptRes.content += char;
// 移除已处理的字符
this.msgQueue[0] = currentItem.slice(1);
// 如果当前字符串为空,则从队列中移除
if (this.msgQueue[0].length === 0) {
this.msgQueue.shift();
}
}
}
handleGPTMsg
这个函数就是处理后端websocket传递过来的消息,其中,最主要的就是这里。在这里,我们设置了一个定时器,每40ms调用一次appendQueueToContent
函数,这个函数从消息队列的队头取出一个字符,把这个字符追加到页面组件上面显示,这样处理使得消息的显示更加平滑,而不是后端生成多少就一次性展示多少。
if (!this.interval){
this.interval = setInterval(()=>{
this.appendQueueToContent()
},40)
}
appendQueueToContent
这个函数就是负责从queue里面获取内容,然后追加到gptRes这个变量里面;并且将已经追加的内容从队列里面移除掉。handleLastMsgQueue
由于前后端处理消息的速度是不一样的,当后端发送生成结束标记(即!$$---END---$$!
)后,前端就需要以此为根据,删除定时器,否则我们没办法知道queue消息队列什么时候为空、什么时候该清楚定时器。那么,此时清除定时器,我们就需要用一个while函数来处理queue里面剩下的内容,handleLastMsgQueue
函数就是干这个的。
2.2.2、session管理组件
这个组件没有什么隐晦难懂的知识,直接贴代码:
<template>
<div :class="['session-item', active ? 'active' : '']">
<div class="name">{{ session.topic }}</div>
<div class="count-time">
<div class="count">{{ session?.messageSize ?? 0 }}条对话</div>
<div class="time">{{ session.updateDate }}</div>
</div>
<!-- 当鼠标放在会话上时会弹出遮罩 -->
<div class="mask"></div>
<div class="btn-wrapper" @click.stop="$emit('click')">
<el-popconfirm
confirm-button-text='好的'
cancel-button-text='不用了'
icon="el-icon-circle-close"
icon-color="red"
@click.prevent="deleteSession(session)"
title="是否确认永久删除该聊天会话?"
@confirm="deleteSession(session)"
>
<el-icon slot="reference" :size="15" class="el-icon-circle-close"></el-icon>
</el-popconfirm>
</div>
</div>
</template>
<script>
export default {
props: {
session: {
type: Object,
required: true
},
active: {
type: Boolean,
default: false
}
},
data() {
return {
ChatSession: {}
}
},
methods: {
deleteSession(session) {
//请求后台删除接口
this.$deleteSession(session.id)
//通知父组件删除session
this.$emit('delete', session)
}
}
}
</script>
<style lang="scss" scoped>
.session-item {
padding: 12px;
background-color: white;
border-radius: 10px;
width: 91%;
/* 当鼠标放在会话上时改变鼠标的样式,暗示用户可以点击。目前还没做拖动的效果,以后会做。 */
cursor: grab;
position: relative;
overflow: hidden;
.name {
font-size: 14px;
font-weight: 700;
width: 200px;
color: rgba(black, 0.8);
text-align: left;
}
.count-time {
margin-top: 10px;
font-size: 10px;
color: rgba(black, 0.5);
/* 让消息数量和最近更新时间显示水平显示 */
display: flex;
/* 让消息数量和最近更新时间分布在水平方向的两端 */
justify-content: space-between;
}
/* 当处于激活状态时增加蓝色描边 */
&.active {
transition: all 0.12s linear;
border: 2px solid #1d93ab;
}
&:hover {
/* 遮罩入场,从最左侧滑进去,渐渐变得不透明 */
.mask {
opacity: 1;
left: 0;
}
.btn-wrapper {
&:hover {
cursor: pointer;
}
/* 按钮入场,从最右侧滑进去,渐渐变得不透明 */
opacity: 1;
right: 20px;
}
}
.mask {
transition: all 0.2s ease-out;
position: absolute;
background-color: rgba(black, 0.05);
width: 100%;
height: 100%;
top: 0;
left: -100%;
opacity: 0;
}
/* 删除按钮样式的逻辑和mask类似 */
.btn-wrapper {
color: rgba(black, 0.5);
transition: all 0.2s ease-out;
position: absolute;
top: 10px;
right: -20px;
z-index: 10;
opacity: 0;
.edit {
margin-right: 5px;
}
;
.el-icon-circle-close {
display: inline-block;
width: 25px;
height: 25px;
color: red;
}
}
}
</style>
上述代码只有一个地方稍稍注意,那就是 <div class="btn-wrapper" @click.stop="$emit('click')">
这里, 在这个div中,我们必须阻止 click
点击事件,防止我们点击div里面的删除元素时,把点击事件传递给div,激活当前session。效果如下:
2.2.3、聊天组件
各个聊天组件如下所示,其中:
2.2.3.1、MessageInput组件
<template>
<div class="message-input">
<div class="input-wrapper">
<el-input
v-model="message"
:autosize="false"
:rows="3"
class="input"
resize="none"
type="textarea"
@keydown.native="sendMessage"
autofocus="autofocus"
>
</el-input>
<div class="button-wrapper">
<el-button icon="el-icon-position" type="primary" @click="send" :disabled="!isSend">
发送
</el-button>
</div>
</div>
</div>
</template>
<script>
export default {
props: {
isSend: {
type: Boolean,
default: false
}
},
data() {
return {
message: ""
};
},
methods: {
sendMessage(e) {
//shift + enter 换行
if (!e.shiftKey && e.keyCode === 13) {
if ((this.message + "").trim() === '' || this.message.length <= 0) {
return;
}
// 阻止默认行为,避免换行
e.preventDefault();
this.send();
}
},
send(){
if (this.isSend) {
this.$emit('send', this.message);
this.message = '';
}
}
}
}
</script>
<style lang="scss" scoped>
.message-input {
padding: 20px;
border-top: 1px solid rgba(black, 0.07);
border-left: 1px solid rgba(black, 0.07);
border-right: 1px solid rgba(black, 0.07);
border-top-right-radius: 5px;
border-top-left-radius: 5px;
}
.button-wrapper {
display: flex;
justify-content: flex-end;
margin-top: 20px;
}
</style>
2.2.3.2、MessageRow组件
<!-- 整个div是用来调整内部消息的位置,每条消息占的空间都是一整行,然后根据right还是left来调整内部的消息是靠右边还是靠左边 -->
<template>
<div :class="['message-row', message.role === 'user' ? 'right' : 'left']">
<!-- 消息展示,分为上下,上面是头像,下面是消息 -->
<div class="row">
<!-- 头像, -->
<div class="avatar-wrapper">
<el-avatar v-if="message.role === 'user'" :src="$store.state.userInfo?$store.state.userInfo.imageUrl:require('@/assets/gpt.png')" class="avatar"
shape="square"/>
<el-avatar v-else :src="require('@/assets/logo.png')" class="avatar" shape="square"/>
</div>
<!-- 发送的消息或者回复的消息 -->
<div class="message">
<!-- 预览模式,用来展示markdown格式的消息 -->
<client-only>
<mavon-editor v-if="message.content" :class="message.role"
:style="{
backgroundColor: message.role === 'user' ? 'rgb(231, 248, 255)' : '#f7e8f1e3',
zIndex: 1,
minWidth: '5px',
fontSize:'15px',
}"
default-open="preview" :subfield='false' :toolbarsFlag="false" :ishljs="true" ref="md"
v-model="message.content" :editable="false"/>
<TextLoading v-else></TextLoading>
<!-- 如果消息的内容为空则显示加载动画 -->
</client-only>
</div>
</div>
</div>
</template>
<script>
import '@/assets/css/md/github-markdown.css'
import TextLoading from './TextLoading'
export default {
components: {
TextLoading
},
props: {
message: {
type: Object,
default: null
}
},
data() {
return {
Editor: "",
}
},
created(){
}
}
</script>
<style lang="scss" scoped>
.message-row {
display: flex;
&.right {
// 消息显示在右侧
justify-content: flex-end;
.row {
// 头像也要靠右侧
.avatar-wrapper {
display: flex;
justify-content: flex-end;
}
// 用户回复的消息和ChatGPT回复的消息背景颜色做区分
.message {
background-color: rgb(231, 248, 255);
}
}
}
// 默认靠左边显示
.row {
.avatar-wrapper {
.avatar {
box-shadow: 20px 20px 20px 3px rgba(0, 0, 0, 0.03);
margin-bottom: 10px;
max-width: 40px;
max-height: 40px;
background: #d4d6dcdb !important;
}
}
.message {
font-size: 15px;
padding: 1.5px;
// 限制消息展示的最大宽度
max-width: 500px;
// 圆润一点
border-radius: 7px;
// 给消息框加一些描边,看起来更加实一些,要不然太扁了轻飘飘的。
border: 1px solid rgba(black, 0.1);
// 增加一些阴影看起来更加立体
box-shadow: 20px 20px 20px 1px rgba(0, 0, 0, 0.01);
margin-bottom: 5px;
}
}
}
.left {
text-align: left;
.message {
background-color: rgba(247, 232, 241, 0.89);
}
}
// 调整markdown组件的一些样式,deep可以修改组件内的样式,正常情况是scoped只能修改本组件的样式。
::v-deep {
.v-note-wrapper .v-note-panel .v-note-show .v-show-content, .v-note-wrapper .v-note-panel .v-note-show .v-show-content-html {
padding: 9px 10px 0 15px;
}
.markdown-body {
min-height: 0;
flex-grow: 1;
.v-show-content {
background-color: transparent !important;
}
}
}
</style>
2.2.3.3、TextLoading组件
<template>
<div class="loading">
<!-- 三个 div 三个黑点 -->
<div></div>
<div></div>
<div></div>
</div>
</template>
<style lang="scss" scoped>
.loading {
// 三个黑点水平展示
display: flex;
// 三个黑点均匀分布在54px中
justify-content: space-around;
color: #000;
width: 54px;
padding: 15px;
div {
background-color: currentColor;
border: 0 solid currentColor;
width: 5px;
height: 5px;
// 变成黑色圆点
border-radius: 100%;
// 播放我们下面定义的动画,每次动画持续0.7s且循环播放。
animation: ball-beat 0.7s -0.15s infinite linear;
}
div:nth-child(2n-1) {
// 慢0.5秒
animation-delay: -0.5s;
}
}
// 动画定义
@keyframes ball-beat {
// 关键帧定义,在50%的时候是颜色变透明,且缩小。
50% {
opacity: 0.2;
transform: scale(0.75);
}
// 在100%时是回到正常状态,浏览器会自动在这两个关键帧间平滑过渡。
100% {
opacity: 1;
transform: scale(1);
}
}
</style>
2.2.3.4、scrollToBottom 函数
export function scrollToBottom(elementId) {
const container = document.getElementById(elementId);
if (!container) {
return
}
// 头部
const start = container.scrollTop;
//底部-头部
const change = container.scrollHeight - start;
const duration = 1000; // 动画持续时间,单位毫秒
let startTime = null;
const animateScroll = (timestamp) => {
if (!startTime) startTime = timestamp;
const progress = timestamp - startTime;
const run = easeInOutQuad(progress, start, change, duration);
container.scrollTop = Math.floor(run);
if (progress < duration) {
requestAnimationFrame(animateScroll);
}
};
// 二次贝塞尔曲线缓动函数
function easeInOutQuad(t, b, c, d) {
t /= d / 2;
if (t < 1) return c / 2 * t * t + b;
t--;
return -c / 2 * (t * (t - 2) - 1) + b;
}
requestAnimationFrame(animateScroll);
}
三、总结
通义千问AI落地前端实现大致如上,在下篇中,我们将继续介绍Websocket是如何实现前后端的消息接受与发送的,敬请期待。。。。