vue3实现上面逻辑 用到的el-drawer组件,从页面右下角弹出问答框,用户可以输入问题。
如果用户咨询的问题,后台返回了很多答案,需要用户选择问题描述,这部分逻辑在前台处理。
组件的整体代码如下,包装成一个弹窗组件,需要的时候引用就行。
<template>
<div class="ai-drawer-box">
<el-drawer class="ai-drawer" :modal-append-to-body="false" v-model="aiDrawer" z-index="2005" :direction="direction" :before-close="handleClose" placement="right-end">
<template #header>
<div class="custom-header">
<div class="ai-img">
<img :src="aiLogo" />
</div>
<div class="title">
<h3>{{ aiTitle }}</h3>
</div>
</div>
</template>
<template #default>
<div class="talkShow" id="talkShow">
<div v-for="(item, index) in talkList" :key="index">
<!-- 机器人回答 -->
<div v-show="item.person == 'mechaniCal' && !item.multipleAnswers" class="mechanicalTalk">
<span class="talk_header"><img :src="aiLogo" alt="" /></span>
<span class="talk_detail">
<span class="time">
<span style="padding-right: 5px; font-size: 14px">{{ '问答小助手' }}</span>
<span>{{ item.time }} </span>
</span>
<span class="talk_text" v-show="Array.isArray(item.say)">
<span v-for="(str, i) in item.say" :key="i">
<span v-html="findAndColorText(str)"></span>
<br v-show="i != item.say.length - 1" />
</span>
</span>
<span class="talk_text" v-show="typeof item.say == 'string'">{{ item.say }}</span>
</span>
</div>
<!-- 用户提问 -->
<div v-show="item.person == 'mineTalk'" class="mineTalk">
<span class="talk_detail">
<div style="margin-bottom: 5px; width: 100%">{{ item.time }}</div>
<div class="talk_text">{{ item.say }}</div>
</span>
</div>
<!-- 机器人回答数组 需要用户选择具体序号进行回复 -->
<div v-show="item.person == 'mechaniCal' && item.multipleAnswers" class="mechanicalTalk mechanicalTalkChhoose">
<span class="talk_header"><img :src="aiLogo" alt="" /></span>
<span class="talk_detail">
<span class="time">
<span style="padding-right: 5px">{{ '问答小助手' }}</span>
<span>{{ item.time }} </span>
</span>
<span class="talk_text">
<span class="desc_tips">{{ '您咨询的问题可能是以下哪个?请输入序号获取回答。' }}</span
><br />
<span class="loop_answer" v-for="(k, ind) in item.answerArr" :key="ind">
<span> {{ k.orderSeq + ':' }}</span>
<span style="color: #0e8eff; padding-left: 3px">{{ k.issueDesc }}</span>
<br />
</span>
</span>
</span>
</div>
</div>
</div>
</template>
<template #footer>
<div class="chat-input">
<el-input type="textarea" style="border: none" class="pass_input" rows="4" @keyup.enter.native="sendMessage" v-model.trim="message" placeholder="很高兴为您服务,请描述您的问题"> </el-input>
<el-button style="position: absolute; bottom: 6px; right: 8px" class="send-btn" @click="sendMessage">发送</el-button>
</div>
</template>
</el-drawer>
</div>
</template>
<script lang="ts">
import { toRefs, defineComponent, reactive } from 'vue';
import aiLogo from '@/assets/images/ai-person.png'; // ai的头像 可以自定义
import baseService from '@/service/base-service'; // 包装好的请求逻辑 替换成你们自己项目的
import { ElMessage } from 'element-plus';
export default defineComponent({
name: 'myAiAnswer',
props: {},
setup(props, context) {
const formatTime = (date: any) => {
const hours = date.getHours().toString().padStart(2, '0');
const minutes = date.getMinutes().toString().padStart(2, '0');
const seconds = date.getSeconds().toString().padStart(2, '0');
return `${hours}:${minutes}:${seconds}`;
};
const state = reactive({
direction: 'rtl',
aiTitle: '问答小助手',
aiDrawer: false,
message: '',
answerUrl: '这里写你们请求调用的后台接口',
initMechaniCal: { time: formatTime(new Date()), person: 'mechaniCal', say: '您好,我是ai小助手,有什么可以帮您的?' },
talkList: [] as any,
hasAnswer: false,
// 这部分注释内容是聊天记录数组的数据格式 请参考
// { time: formatTime(new Date()), person: 'mineTalk', say: '我问报销' },
// {
// time: formatTime(new Date()),
// multipleAnswers: true,
// person: 'mechaniCal',
// answerArr: [
// { orderSeq: 1, issueDesc: '招待费申请晚于招待费报销怎么办' },
// { orderSeq: 2, issueDesc: '在差旅报销单据中出差类型选择培训出差' },
// { orderSeq: 3, issueDesc: '差旅报销是否可以多人一起提报销单' },
// { orderSeq: 4, issueDesc: '报销次数以及时间有没有限制' },
// ],
// },
});
const handleClose = () => {
state.aiDrawer = false; // 关掉弹窗
state.talkList = []; // 清掉聊天记录
state.message = ''; // 清掉对话框输入内容
state.hasAnswer = false; // 清掉答案请求状态
};
// 初始化ai问答
const aiInit = () => {
state.message = '';
state.aiDrawer = true;
state.talkList.push(state.initMechaniCal);
};
const getLastItem = (arr: any) => {
const filteredArr = arr.filter((item: any) => {
return item.multipleAnswers;
});
return filteredArr.pop();
};
const findAndColorText = (str: any) => {
// 匹配连续的英文或数字 这个一般返回的是单据编号或者是微信号码
const regex = /(\b[A-Za-z0-9]+\b)/g;
const emailRegex = /(\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b)/g;
// 假设手机号为11位数字
const phoneRegex = /(\b\d{11}\b)/g;
// 假设电话号码格式为xxx-xxx-xxxx或xxx.xxx.xxxx或xxx xxx xxxx
const phoneNumberRegex = /(\b\d{3}[-\.\s]??\d{3}[-\.\s]??\d{4}\b)/g;
const matches = [...str.matchAll(emailRegex), ...str.matchAll(phoneRegex), ...str.matchAll(phoneNumberRegex), ...str.matchAll(regex)];
if (matches.length === 0) {
return str;
}
let formattedText = str;
matches.forEach((match) => {
const matchedText = match[0];
const replacement = `<span style="color: #0e8eff">${matchedText}</span>`;
formattedText = formattedText.replace(matchedText, replacement);
});
return formattedText;
};
// 发送问题
const sendMessage = () => {
if (state.message == '' || !state.message) {
return false;
}
// 不需要请求接口 : 当返回多条 需要用户输入序号 咨询某个问题时 直接取前台的回答数组里返回
const lastMultipleAnswers = getLastItem(state.talkList); // 聊天记录里 最后一组数组形式的answer
if (lastMultipleAnswers != undefined) {
// tempory前台匹配的答案 如有 直接返回 没有则请求接口
const tempory = lastMultipleAnswers.answerArr.filter((K: any) => {
return K.orderSeq == state.message;
});
if (tempory && tempory.length == 1 && tempory[0].say && state.hasAnswer) {
state.hasAnswer = true;
state.talkList.push({ time: formatTime(new Date()), person: 'mineTalk', say: state.message });
state.talkList.push({ time: formatTime(new Date()), person: 'mechaniCal', say: tempory[0].say });
state.message = '';
setBoxPosition();
return false;
} else {
state.hasAnswer = false;
}
}
// 正常提问 前台没有已知答案 需要请求接口 处理接口回答
if (!state.hasAnswer) {
// 先问
state.talkList.push({ time: formatTime(new Date()), person: 'mineTalk', say: state.message });
setBoxPosition();
const data = {
issueDesc: state.message,
};
state.message = '';
baseService.post(state.answerUrl, data).then((res) => {
if (res.code != 0) {
return ElMessage.error(res.msg)
}
if (res.data.length == 0) {
state.talkList.push({ time: formatTime(new Date()), person: 'mechaniCal', say: '我没听清楚您说了什么,请您换一个说法试试!' });
setBoxPosition();
} else if (res.data.length == 1) {
// 不需要处理
// if (res.data[0].issueAnswer.indexOf('。') != -1) {
// res.data[0].issueAnswer = res.data[0].issueAnswer.split('。');
// console.log(res.data[0].issueAnswer);
// }
state.talkList.push({ time: formatTime(new Date()), person: 'mechaniCal', say: res.data[0].issueAnswer });
setBoxPosition();
} else if (res.data.length > 1) {
const obj = {
time: formatTime(new Date()),
multipleAnswers: true,
person: 'mechaniCal',
answerArr: [],
} as any;
res.data.map((ans: any) => {
const data = {
orderSeq: ans.orderSeq,
issueDesc: ans.issueDesc,
say: ans.issueAnswer,
id: ans.id,
};
obj.answerArr.push(data);
});
state.talkList.push(obj);
setBoxPosition();
state.hasAnswer = true;
}
});
}
};
const setBoxPosition = () => {
setTimeout(() => {
let container: any = document.getElementById('talkShow');
container.scrollIntoView({ behavior: 'smooth', block: 'end' });
}, 20);
};
// 流式输出文字逐个展示
const flowShowing = async (data: any) => {
let text = '';
const length = state.talkList.length;
for (let i = 0; i < data.length; i++) {
text += data.charAt(i);
await new Promise((resolve) => {
setTimeout(resolve, 50);
});
state.talkList[length - 1].say = text;
}
};
return {
...toRefs(state),
handleClose,
aiInit,
aiLogo,
sendMessage,
formatTime,
findAndColorText,
};
},
});
</script>
<style scoped lang="less">
.ai-drawer-box:deep(.el-drawer) {
width: 30% !important;
height: 60% !important;
top: auto !important;
padding: 10px;
background-color: rgb(226, 226, 226);
box-shadow: 0px 0 22px #d6d6d6 inset;
}
.ai-drawer-box:deep(.el-drawer__header) {
margin-bottom: 0 !important;
padding: 0;
border-radius: 5px 5px 0 0;
background-color: rgb(255, 255, 255);
padding-right: 10px;
}
.ai-drawer-box:deep(.el-drawer__body) {
margin-bottom: 0 !important;
background-color: #f5f5f5;
padding: 10px 5px 10px 2px;
overflow-y: auto;
border-top: 12px solid #f5f5f5;
}
.ai-drawer-box:deep(.el-drawer__footer) {
padding: 0 !important;
}
.custom-header {
display: flex;
justify-content: flex-start;
align-items: center;
padding-left: 10px;
.ai-img {
width: 43px;
height: 43px;
background-color: #383838;
// ~ 'var(--color-primary)'
text-align: center;
display: flex;
flex-direction: column;
justify-content: space-around;
border-radius: 27px;
margin-right: 20px;
img {
width: 35px;
height: 42px;
border-radius: 50%;
margin: 0 auto;
}
}
.title {
color: #383838;
}
}
.chat-input {
height: 90px;
position: relative;
:deep(.el-textarea__inner) {
border: none !important;
box-shadow: none !important;
resize: none;
border-radius: 0 0 5px 5px;
}
}
.talkShow {
padding-bottom: 10px;
// 机器人回答样式
.mechanicalTalk {
margin: 10px;
display: flex;
.talk_header {
flex-shrink: 0;
width: 43px;
height: 43px;
background-color: #383838;
text-align: center;
display: flex;
flex-direction: column;
justify-content: space-around;
border-radius: 27px;
img {
width: 35px;
height: 42px;
border-radius: 50%;
margin: 0 auto;
}
}
.talk_detail {
margin-left: 8px;
.time {
width: 100%;
margin-bottom: 5px;
display: inline-block;
}
.talk_text {
display: inline-block;
background: white;
border-radius: 5px;
padding: 10px 10px;
border: 1px solid rgb(214, 216, 219);
border-top-left-radius: 0px;
word-break: break-all;
text-align: left;
color: #383838;
}
}
}
// 用户提问
.mineTalk {
margin: 10px;
text-align: right;
.talk_detail {
.talk_text {
display: inline-block;
border-radius: 5px;
border-top-right-radius: 0px;
background: #0088ff;
color: #fff;
padding: 10px 10px;
word-break: break-all;
text-align: left;
}
}
}
.mechanicalTalkChhoose {
.talk_text {
span {
display: inline-block;
}
.desc_tips {
margin-bottom: 4px;
}
.loop_answer {
margin-bottom: 2px;
width: 100%;
}
}
}
}
</style>
上面的代码还处理了多选逻辑,对话内容如下:
用户问的问题有多种回复时,后端会返回数组,前台只需要输入序号,就可以得到对应答案,这部分逻辑是在前台处理,详情看代码就可以。需要用的可以复制,不用的这段逻辑删掉就行。