You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
657 lines
16 KiB
657 lines
16 KiB
<template>
|
|
<view class="v-pages Ai">
|
|
<!-- 对话框 -->
|
|
<scroll-view class="scroll-view" scroll-y scroll-with-animation :scroll-top="top">
|
|
<view class="v-ai-list">
|
|
<view class="list_item" :class="item.userType" v-for="(item,index) in list" :key="index">
|
|
<view v-if="item.userType=='self'">
|
|
<view class="text-name row flex-align-center">
|
|
<image class="img"
|
|
src="https://eluyou.ailuquan.cn/upload/image/2024/mapIcon/daolan/ai-self.png"></image>
|
|
<text>E鹿小助手</text>
|
|
</view>
|
|
<zero-markdown-view :markdown="item.content"></zero-markdown-view>
|
|
<!-- <view class="loading" v-else><text class="loading-dot"></text><text
|
|
class="loading-dot"></text><text class="loading-dot"></text></view> -->
|
|
</view>
|
|
<view class="row flex-align-center" style="justify-content: flex-end;"
|
|
v-if="item.userType=='friend'">
|
|
<view class="text-box">{{item.content}}</view>
|
|
<view class="text-name row flex-align-center">
|
|
<image class="img"
|
|
src="https://eluyou.ailuquan.cn/upload/image/2024/mapIcon/daolan/ai-friend.png"></image>
|
|
</view>
|
|
</view>
|
|
</view>
|
|
</view>
|
|
</scroll-view>
|
|
<!-- 语音/文字发送 -->
|
|
<view class="tool flex-align-center">
|
|
<block v-if="messageType === 'text'">
|
|
<image src="https://eluyou.ailuquan.cn/upload/image/2024/mapIcon/daolan/icon-voice.png" mode="widthFix"
|
|
class="left-icon" @click="messageType='voice'">
|
|
</image>
|
|
<input type="text" :disabled='inputDisabled' v-model="content" class="input" @confirm="sendMsg" />
|
|
</block>
|
|
<block v-if="messageType === 'voice'">
|
|
<image src="https://eluyou.ailuquan.cn/upload/image/2024/mapIcon/daolan/icon-text.png" mode="widthFix"
|
|
class="left-icon" @click="messageType='text'">
|
|
</image>
|
|
<text class="voice-crl" @click="clickSprink()" @touchstart="touchstart"
|
|
@touchend="touchend">{{ recordStart ? '松开 发送' : '按住 说话' }}</text>
|
|
</block>
|
|
</view>
|
|
<view v-if="recordStart" class="audio-animation">
|
|
<view class="audio-wave">
|
|
<text class="audio-wave-text" v-for="item in 10" :style="{'animation-delay': `${item/10}s`}"></text>
|
|
<view class="text">松开 发送</view>
|
|
</view>
|
|
</view>
|
|
|
|
</view>
|
|
</template>
|
|
|
|
<script>
|
|
const recorderManager = wx.getRecorderManager()
|
|
import ZeroMarkdownView from "@/subPageC/components/zero-markdown-view/components/zero-markdown-view/zero-markdown-view.vue"
|
|
import {
|
|
mapState,
|
|
mapMutations
|
|
} from "vuex";
|
|
export default {
|
|
components: {
|
|
ZeroMarkdownView
|
|
},
|
|
computed: {
|
|
...mapState(["PrimaryColor", "userInfo"])
|
|
},
|
|
data() {
|
|
return {
|
|
//公共路径
|
|
rootPath: this.$config.ROOTPATH,
|
|
content: '',
|
|
list: [{
|
|
content: "Hi,你好,E鹿小助手很高兴为您提供咨询服务。",
|
|
userType: 'self',
|
|
}],
|
|
top: '-90rpx',
|
|
//发送类型
|
|
messageType: 'voice', // text 发送文本;voice 发送语音
|
|
recordStart: false,
|
|
identity: "",
|
|
//websocket
|
|
Token: null,
|
|
inputDisabled: false,
|
|
querUserInfo: {},
|
|
wsInfo: {
|
|
ws: null, // ws对象
|
|
alive: false, // 是否连接
|
|
isLogin: false, // 是否登录
|
|
isJoin: false, // 是否加入
|
|
lock: false, // 锁住重连
|
|
reconnectTimer: null, // 重连计时
|
|
reconnectTime: 5000, // 重连计时间隔
|
|
clientTimer: null, // 客户端计时
|
|
clientTime: 10000, // 客户端计时间隔
|
|
serverTimer: null, // 服务端计时
|
|
serverTime: 30000, // 服务端计时间隔
|
|
},
|
|
//是否长按事件
|
|
islongPress: false,
|
|
timer: null, //长按计时器
|
|
}
|
|
},
|
|
onUnload() {
|
|
this.closeWs()
|
|
},
|
|
onShow() {
|
|
this.getUserInfo()
|
|
this.connectWs()
|
|
},
|
|
methods: {
|
|
// 用户信息
|
|
getUserInfo() {
|
|
this.querUserInfo = {
|
|
"message": "",
|
|
"dialogId": "",
|
|
"identity": "",
|
|
"userId": this.userInfo.userId
|
|
};
|
|
this.Token = this.userInfo.accessToken;
|
|
},
|
|
// websocket
|
|
connectWs() {
|
|
const $this = this
|
|
this.wsInfo.ws = null;
|
|
console.log('wss://eluyou.ailuquan.cn/prod-api/infra/ws?token=' + this.Token)
|
|
this.wsInfo.ws = uni.connectSocket({
|
|
url: 'wss://eluyou.ailuquan.cn/prod-api/infra/ws?token=' + this.Token,
|
|
success() {
|
|
$this.wsInfo.alive = true;
|
|
console.log("ws连接成功!");
|
|
},
|
|
fail() {
|
|
$this.wsInfo.alive = false;
|
|
console.log("ws连接失败!");
|
|
},
|
|
});
|
|
// ws打开
|
|
this.wsInfo.ws.onOpen((res) => {
|
|
$this.wsInfo.alive = true;
|
|
// 开启心跳
|
|
$this.heartBeat();
|
|
console.log("ws开启成功!", $this.wsInfo.alive);
|
|
});
|
|
// ws消息
|
|
this.wsInfo.ws.onMessage((res) => {
|
|
$this.heartBeat();
|
|
// 处理消息
|
|
let data = JSON.parse(res.data);
|
|
$this.handlerMessage(JSON.parse(data.content));
|
|
});
|
|
// ws关闭
|
|
this.wsInfo.ws.onClose((res) => {
|
|
$this.wsInfo.alive = false;
|
|
$this.reConnect();
|
|
console.log("ws连接关闭:", res);
|
|
});
|
|
// ws错误
|
|
this.wsInfo.ws.onError((err) => {
|
|
$this.wsInfo.alive = false;
|
|
$this.reConnect();
|
|
console.log("ws连接错误:", err);
|
|
});
|
|
},
|
|
// 心跳检测
|
|
heartBeat() {
|
|
const $this = this
|
|
clearTimeout(this.wsInfo.clientTimer);
|
|
clearTimeout(this.wsInfo.serverTimer);
|
|
this.wsInfo.clientTimer = setTimeout(() => {
|
|
if ($this.wsInfo.ws) {
|
|
let pong = {
|
|
type: "ping",
|
|
};
|
|
$this.wsInfo.ws.send({
|
|
data: JSON.stringify(pong),
|
|
fail() {
|
|
$this.wsInfo.serverTimer = setTimeout(() => {
|
|
$this.closeWs();
|
|
}, $this.wsInfo.serverTime);
|
|
},
|
|
});
|
|
}
|
|
}, $this.wsInfo.clientTime);
|
|
},
|
|
//发送消息
|
|
handlerMessage(data) {
|
|
if (data.context !== null) {
|
|
this.list[this.list.length - 1].content += data.context
|
|
}
|
|
if (data.aiStatus == 2) {
|
|
this.inputDisabled = false
|
|
}
|
|
},
|
|
//重新连接
|
|
reConnect() {
|
|
if (this.wsInfo.lock) return;
|
|
this.wsInfo.lock = true;
|
|
this.wsInfo.reconnectTimer = setTimeout(() => {
|
|
this.connectWs();
|
|
this.wsInfo.lock = false;
|
|
}, this.wsInfo.reconnectTime);
|
|
},
|
|
// 断开连接
|
|
closeWs() {
|
|
console.log("断开连接")
|
|
if (!this.wsInfo.alive) {
|
|
uni.showToast({
|
|
title: "请先连接!",
|
|
icon: "error",
|
|
});
|
|
return;
|
|
}
|
|
// wsLogout();
|
|
this.wsInfo.ws.close();
|
|
},
|
|
// 发送消息
|
|
sendMsg() {
|
|
const $this = this
|
|
this.getUserInfo()
|
|
this.list.push({
|
|
content: this.content,
|
|
userType: 'friend',
|
|
})
|
|
|
|
if (this.querUserInfo.identity == "") {
|
|
this.querUserInfo.identity = this.generateRandomString(8);
|
|
}
|
|
|
|
this.querUserInfo.message = this.content
|
|
this.content = ''
|
|
this.$Request.post(this.$config.aiSendMsg, $this.querUserInfo, "json", null, false, true)
|
|
.then(res => {
|
|
this.inputDisabled = true
|
|
setTimeout(() => {
|
|
$this.list.push({
|
|
content: "",
|
|
userType: 'self',
|
|
})
|
|
$this.scrollToBottom()
|
|
}, 500)
|
|
});
|
|
},
|
|
//屏幕滚动
|
|
scrollToBottom() {
|
|
this.top = this.list.length * 1000
|
|
},
|
|
//开启麦克风权限
|
|
authTips() {
|
|
uni.showModal({
|
|
title: '提示',
|
|
content: '您拒绝了麦克风权限,将导致功能不能正常使用,去设置权限?',
|
|
confirmText: '去设置',
|
|
cancelText: '取消',
|
|
success: (res) => {
|
|
if (res.confirm) {
|
|
uni.openSetting({
|
|
success: (res) => {
|
|
if (res.authSetting['scope.record']) {
|
|
console.log("已授权麦克风");
|
|
this._recordAuth = true
|
|
} else {
|
|
// 未授权
|
|
wx.showModal({
|
|
title: '提示',
|
|
content: '您未授权麦克风,功能将无法使用',
|
|
showCancel: false,
|
|
confirmText: '知道了'
|
|
})
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
})
|
|
},
|
|
//长按事件
|
|
longpress() {
|
|
const $this = this
|
|
this.islongPress = true;
|
|
if (this.islongPress == true) {
|
|
//开始录音
|
|
const _permission = 'scope.record'
|
|
uni.getSetting({
|
|
success: (res) => {
|
|
if (res.authSetting.hasOwnProperty('scope.record')) {
|
|
|
|
if (!res.authSetting[_permission]) {
|
|
$this.authTips()
|
|
} else {
|
|
// 已授权
|
|
$this._recordAuth = true
|
|
// 开始录音
|
|
recorderManager.start({
|
|
format: 'pcm',
|
|
})
|
|
recorderManager.onStart(() => {
|
|
$this.recordStart = true
|
|
})
|
|
|
|
// 错误回调
|
|
recorderManager.onError((res) => {
|
|
console.log('recorder error', res)
|
|
uni.showToast({
|
|
icon: 'none',
|
|
title: '系统出错,请重试'
|
|
})
|
|
$this.recordStart = false
|
|
})
|
|
}
|
|
} else {
|
|
// 属性不存在,需要授权
|
|
console.log('属性不存在,需要授权')
|
|
uni.authorize({
|
|
scope: 'scope.record',
|
|
success: () => {
|
|
// 授权成功
|
|
$this._recordAuth = true
|
|
console.log('授权成功')
|
|
},
|
|
fail: (res) => {
|
|
console.log('// 未授权隐私协议', res.errno)
|
|
/**
|
|
* 104 未授权隐私协议
|
|
* 用户可能拒绝官方隐私授权弹窗,为了避免过度弹窗打扰用户,开发者再次调用隐私相关接口时,
|
|
* 若距上次用户拒绝不足10秒,将不再触发弹窗,直接给到开发者用户拒绝隐私授权弹窗的报错
|
|
*/
|
|
if (res.errno == 104) {
|
|
uni.showModal({
|
|
title: '温馨提示',
|
|
content: '您拒绝了隐私协议,请稍后再试',
|
|
confirmText: '知道了',
|
|
showCancel: false,
|
|
success: () => {}
|
|
})
|
|
} else {
|
|
// 用户拒绝授权
|
|
console.log('// 用户拒绝授权')
|
|
$this.authTips()
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
})
|
|
}
|
|
},
|
|
//开启麦克风权限
|
|
authTips() {
|
|
const $this = this
|
|
uni.showModal({
|
|
title: '提示',
|
|
content: '您拒绝了麦克风权限,将导致功能不能正常使用,去设置权限?',
|
|
confirmText: '去设置',
|
|
cancelText: '取消',
|
|
success: (res) => {
|
|
if (res.confirm) {
|
|
uni.openSetting({
|
|
success: (res) => {
|
|
if (res.authSetting['scope.record']) {
|
|
console.log("已授权麦克风");
|
|
$this._recordAuth = true
|
|
} else {
|
|
// 未授权
|
|
wx.showModal({
|
|
title: '提示',
|
|
content: '您未授权麦克风,功能将无法使用',
|
|
showCancel: false,
|
|
confirmText: '知道了'
|
|
})
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
})
|
|
},
|
|
//开启录音
|
|
touchstart() {
|
|
const $this = this
|
|
this.timer = setTimeout(() => {
|
|
$this.longpress();
|
|
}, 200)
|
|
},
|
|
//停止录音
|
|
touchend() {
|
|
const $this = this
|
|
//延时执行为了防止 click() 还未判断 islongPress 的值就被置为 fasle
|
|
clearTimeout(this.timer);
|
|
setTimeout(() => {
|
|
this.islongPress = false
|
|
}, 200)
|
|
|
|
if (!this._recordAuth || !this.recordStart) return
|
|
//停止录音
|
|
recorderManager.stop();
|
|
recorderManager.onStop((res) => {
|
|
const {
|
|
duration,
|
|
tempFilePath
|
|
} = res
|
|
$this.recordStart = false
|
|
|
|
wx.uploadFile({
|
|
url: $this.rootPath + '/app-api/wechatshop/toolIdentify/identifySpeech',
|
|
filePath: tempFilePath,
|
|
name: 'file',
|
|
formData: {
|
|
'file': tempFilePath
|
|
},
|
|
success: function(res) {
|
|
let data = JSON.parse(res.data)
|
|
console.log("dsfaD", data)
|
|
if (data.data) {
|
|
$this.content = data.data
|
|
$this.sendMsg()
|
|
} else {
|
|
wx.showModal({
|
|
title: '提示',
|
|
content: "没听清,请您再说一遍",
|
|
showCancel: false,
|
|
success: function(res) {}
|
|
});
|
|
}
|
|
|
|
},
|
|
fail: function(res) {
|
|
UTIL.log(res);
|
|
wx.showModal({
|
|
title: '提示',
|
|
content: "网络请求失败,请确保网络是否正常",
|
|
showCancel: false,
|
|
success: function(res) {}
|
|
});
|
|
wx.hideToast();
|
|
}
|
|
});
|
|
})
|
|
},
|
|
//点击事件
|
|
clickSprink() {
|
|
// 非长按
|
|
if (this.islongPress == false) {
|
|
wx.showModal({
|
|
title: '提示',
|
|
content: "说话时间太短",
|
|
showCancel: false,
|
|
success: function(res) {}
|
|
});
|
|
} else if (this.islongPress == true) {
|
|
console.log("长按事件");
|
|
}
|
|
},
|
|
//获取当前对话 key
|
|
generateRandomString(length) {
|
|
const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
|
let result = '';
|
|
for (let i = 0; i < length; i++) {
|
|
const randomIndex = Math.floor(Math.random() * characters.length);
|
|
result += characters.charAt(randomIndex);
|
|
}
|
|
return result;
|
|
},
|
|
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<style lang="scss">
|
|
.scroll-view {
|
|
/* #ifdef H5 */
|
|
height: calc(100vh - 44px);
|
|
/* #endif */
|
|
/* #ifndef H5 */
|
|
height: 100vh;
|
|
/* #endif */
|
|
box-sizing: border-box;
|
|
top: 0;
|
|
padding-bottom: 60rpx;
|
|
}
|
|
|
|
.v-pages.Ai {
|
|
// background: url('https://eluyou.ailuquan.cn/upload/image/2024/mapIcon/daolan/ai-bg.png');
|
|
// background-repeat: no-repeat;
|
|
// background-position: top;
|
|
// background-size: cover;
|
|
|
|
}
|
|
|
|
.v-ai-list {
|
|
padding: 24rpx 24rpx 60rpx 24rpx;
|
|
|
|
.list_item {
|
|
margin: 24rpx 0;
|
|
|
|
.text-name {
|
|
font-size: 28rpx;
|
|
color: #333333;
|
|
|
|
.img {
|
|
width: 80rpx;
|
|
height: 80rpx;
|
|
}
|
|
}
|
|
|
|
.text-box {
|
|
margin-top: 16rpx;
|
|
padding: 24rpx;
|
|
font-size: 28rpx;
|
|
color: #1B1B1B;
|
|
border-radius: 8rpx 16rpx 16rpx 16rpx;
|
|
}
|
|
|
|
&.self {
|
|
padding-right: 60rpx;
|
|
|
|
.text-name {
|
|
.img {
|
|
margin-right: 10rpx;
|
|
}
|
|
}
|
|
|
|
.text-box {
|
|
background-color: #fff;
|
|
}
|
|
}
|
|
|
|
&.friend {
|
|
padding-left: 60rpx;
|
|
|
|
.text-name {
|
|
margin-left: 10rpx;
|
|
}
|
|
|
|
.text-box {
|
|
color: #fff;
|
|
background: linear-gradient(to right, #0983FF, #57ABFF);
|
|
}
|
|
}
|
|
|
|
|
|
}
|
|
}
|
|
|
|
.tool {
|
|
position: fixed;
|
|
width: 100%;
|
|
min-height: 120rpx;
|
|
left: 0;
|
|
bottom: 0;
|
|
z-index: 99;
|
|
background: #fff;
|
|
display: flex;
|
|
box-sizing: border-box;
|
|
padding: 20rpx 24rpx 20rpx 40rpx;
|
|
padding-bottom: calc(20rpx + constant(safe-area-inset-bottom)/2) !important;
|
|
padding-bottom: calc(20rpx + env(safe-area-inset-bottom)/2) !important;
|
|
|
|
.left-icon {
|
|
width: 56rpx;
|
|
height: 56rpx;
|
|
margin-right: 10rpx;
|
|
}
|
|
|
|
.input,
|
|
.voice-crl {
|
|
background: #eee;
|
|
border-radius: 10rpx;
|
|
height: 70rpx;
|
|
margin-right: 30rpx;
|
|
flex: 1;
|
|
padding: 0 20rpx;
|
|
box-sizing: border-box;
|
|
font-size: 28rpx;
|
|
}
|
|
|
|
.thumb {
|
|
width: 64rpx;
|
|
height: 64rpx;
|
|
}
|
|
|
|
.voice-crl {
|
|
text-align: center;
|
|
line-height: 70rpx;
|
|
font-weight: bold;
|
|
}
|
|
}
|
|
|
|
.audio-animation {
|
|
position: fixed;
|
|
// width: 100vw;
|
|
// height: 100vh;
|
|
left: 50%;
|
|
top: 50%;
|
|
transform: translate(-50%, -50%);
|
|
z-index: 202410;
|
|
display: flex;
|
|
justify-content: center;
|
|
align-items: center;
|
|
|
|
.text {
|
|
text-align: center;
|
|
font-size: 28rpx;
|
|
color: #333;
|
|
margin-top: 60rpx;
|
|
}
|
|
|
|
.audio-wave {
|
|
padding: 50rpx;
|
|
|
|
.audio-wave-text {
|
|
background-color: blue;
|
|
width: 7rpx;
|
|
height: 12rpx;
|
|
margin: 0 6rpx;
|
|
border-radius: 5rpx;
|
|
display: inline-block;
|
|
border: none;
|
|
animation: wave 0.25s ease-in-out;
|
|
animation-iteration-count: infinite;
|
|
animation-direction: alternate;
|
|
}
|
|
|
|
/* 声波动画 */
|
|
@keyframes wave {
|
|
from {
|
|
transform: scaleY(1);
|
|
}
|
|
|
|
to {
|
|
transform: scaleY(4);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
.loading {
|
|
height: 40rpx;
|
|
}
|
|
|
|
.loading-dot {
|
|
display: inline-block;
|
|
width: 4px;
|
|
height: 4px;
|
|
border-radius: 4px;
|
|
background-color: #000;
|
|
animation: loading 1s infinite;
|
|
}
|
|
|
|
.loading-dot:nth-child(2) {
|
|
animation-delay: 0.2s;
|
|
}
|
|
|
|
.loading-dot:nth-child(3) {
|
|
animation-delay: 0.4s;
|
|
}
|
|
</style> |