@ -0,0 +1,414 @@ |
|||||
|
<template> |
||||
|
<view class="client-chat" style="height: 100%"> |
||||
|
<view ref="first" clazz="pop-demo" :use-arrow="true"> |
||||
|
<view class="pop-demo-list" v-for="pop in popperList" :key="pop.id"> |
||||
|
<v-button type="link" kind="primary" @click="openSearchUrl(pop, pop.id)"> |
||||
|
{{ pop.id }}.{{ pop.name }} |
||||
|
<v-icon remote name="arrow_right_line" size="12" valign="-1"></v-icon> |
||||
|
</v-button> |
||||
|
</view> |
||||
|
</view> |
||||
|
<view class="qa-item" v-for="(item, index) in msgList" :key="index"> |
||||
|
<!-- 时间戳 --> |
||||
|
<view class="timestamp" |
||||
|
v-if="index === 0 || (index !== 0 && item.timestamp && (Number(item.timestamp) - Number(msgList[index - 1].timestamp)) > timestampGap)"> |
||||
|
{{ moment(new Date(String(item.timestamp).length === 10 ? item.timestamp * 1000 : |
||||
|
Number(item.timestamp))).format('MM-DD HH:mm') }} |
||||
|
</view> |
||||
|
|
||||
|
<!-- 问题 --> |
||||
|
<view class="question-item" v-if="item.is_from_self"> |
||||
|
<v-spinner status="default" class="qs-loading" v-if="item.is_loading"></v-spinner> |
||||
|
<!-- <VueMarkdown class="question-text" style="max-width: 352px" :source="item.content" |
||||
|
:anchorAttributes="{ target: '_blank' }" :linkify="false" /> --> |
||||
|
</view> |
||||
|
<!-- 答案 --> |
||||
|
<view class="answer-item" v-if="!item.is_from_self"> |
||||
|
<!-- 头像 --> |
||||
|
<view class="answer-avatar"> |
||||
|
<img class="robot-avatar" :src="item.from_avatar" /> |
||||
|
</view> |
||||
|
|
||||
|
<!-- 答案信息 --> |
||||
|
<view class="answer-info" :ref="item.record_id"> |
||||
|
<view v-if="item.agent_thought && item.agent_thought.procedures && item.agent_thought.procedures.length > 0"> |
||||
|
<!-- 思考部分 --> |
||||
|
<MsgThought |
||||
|
v-for="(thought, i) in item.agent_thought.procedures" |
||||
|
:key="i" |
||||
|
:content="thought.debugging.content" |
||||
|
:title="thought.title" |
||||
|
:titleIcon="thought.icon" |
||||
|
:nodeName="thought.name" |
||||
|
:status="thought.status" |
||||
|
:elapsed="thought.elapsed" |
||||
|
:detailVisible="thought.detailVisible" |
||||
|
/> |
||||
|
</view> |
||||
|
<view class="loading" v-if="item.loading_message">正在思考中</view> |
||||
|
<!-- 回复主体 --> |
||||
|
<MsgContent :showTags="true" |
||||
|
:recordId="item.record_id" |
||||
|
:isReplaceLinks="true" |
||||
|
:loadingMessage="item.loading_message" |
||||
|
:content="item.content" |
||||
|
:isFinal="item.is_final" |
||||
|
:isMdExpand="item.isMdExpand" |
||||
|
@littleTagClick="littleTagClick" |
||||
|
/> |
||||
|
<!-- 参考来源 --> |
||||
|
<Reference v-if="item.references && item.references.length>0" :references-list="item.references"/> |
||||
|
<!-- 运行状态 --> |
||||
|
<TokensBoardBfr class="tokens-board-class" :showDtl="true" :tokensData="item.tokens_msg"></TokensBoardBfr> |
||||
|
</view> |
||||
|
</view> |
||||
|
</view> |
||||
|
</view> |
||||
|
</template> |
||||
|
|
||||
|
<script> |
||||
|
import clone from 'clone'; |
||||
|
// import VueMarkdown from 'vue-markdown'; |
||||
|
import elementResizeDetectorMaker from 'element-resize-detector'; |
||||
|
import { scrollToBottom } from './../utils/util'; |
||||
|
import { MESSAGE_TYPE, ACCESS_TYPE } from './../constants'; |
||||
|
import TokensBoardBfr from './tokens-board-brif.vue'; |
||||
|
import Reference from './reference-component.vue'; |
||||
|
|
||||
|
export default { |
||||
|
name: 'ClientChat', |
||||
|
components: { |
||||
|
// VueMarkdown, |
||||
|
Reference, |
||||
|
TokensBoardBfr |
||||
|
}, |
||||
|
data () { |
||||
|
return { |
||||
|
popperList: [], |
||||
|
oldPopDemo: null, |
||||
|
loading: false, |
||||
|
historyLoading: false, |
||||
|
timestampGap: 5 * 60, // 两次问题间隔大于5min,则展示时间戳(接口侧返回的时间戳是秒级) |
||||
|
msgList: [], // 对话消息列表 |
||||
|
robotName: '', // 机器人名称 |
||||
|
chatBoxHeight: document.body.clientHeight, |
||||
|
jsScrolling: false, |
||||
|
userScrolling: false |
||||
|
}; |
||||
|
}, |
||||
|
created () { |
||||
|
// 监听用户端/管理端体验侧的ws事件 |
||||
|
this.listenClientAndManageEvent(); |
||||
|
// 监听公共的ws事件 |
||||
|
this.listenCommonEvent(); |
||||
|
}, |
||||
|
mounted () { |
||||
|
const erd = elementResizeDetectorMaker(); |
||||
|
const bodyDom = document.body; |
||||
|
|
||||
|
erd.listenTo(bodyDom, (element) => { |
||||
|
this.chatBoxHeight = element.clientHeight - 113; // 57+56 头部的高度 |
||||
|
}); |
||||
|
|
||||
|
document.addEventListener('click', this.handleOutsideClick); |
||||
|
|
||||
|
const sDom = document.querySelector('.client-chat'); |
||||
|
sDom.addEventListener('scroll', () => { |
||||
|
if (this.msgList[this.msgList.length - 1].is_final === false && !this.jsScrolling) { |
||||
|
this.userScrolling = true; |
||||
|
} else { |
||||
|
this.jsScrolling = false; |
||||
|
} |
||||
|
}); |
||||
|
}, |
||||
|
beforeDestroy () { |
||||
|
// 移除全局事件监听器 |
||||
|
document.removeEventListener('click', this.handleOutsideClick); |
||||
|
}, |
||||
|
methods: { |
||||
|
openSearchUrl (refer, index) { |
||||
|
window.open(refer.url); |
||||
|
}, |
||||
|
// 监听用户端/管理端体验侧的ws事件 |
||||
|
listenClientAndManageEvent () { |
||||
|
// 从缓存获取机器人信息 |
||||
|
let cachedConfig = null; |
||||
|
if (ACCESS_TYPE === 'ws') { |
||||
|
cachedConfig = this.$clientData.getConfigInfo(); |
||||
|
} else { |
||||
|
cachedConfig = this.$SseCls.sseQueryConfigInfo(); |
||||
|
} |
||||
|
if (cachedConfig) { |
||||
|
this.robotName = cachedConfig.name; |
||||
|
} |
||||
|
|
||||
|
// 监听答案消息队列变更事件 |
||||
|
this.$eventHub.$on('client_msgContentChange', (res) => { |
||||
|
const { chatsContent, type } = res; |
||||
|
|
||||
|
// PS:若新消息不属于当前机器人,则在 $clientData 中监听到ws消息后判断并屏蔽。不在此处判断和屏蔽 |
||||
|
this.renderMsgList(chatsContent, type); |
||||
|
}); |
||||
|
}, |
||||
|
// 监听公共的ws事件 |
||||
|
listenCommonEvent () { |
||||
|
this.$eventHub.$on('data_history', () => { |
||||
|
this.historyLoading = false; |
||||
|
}); |
||||
|
|
||||
|
this.$eventHub.$on('data_historyError', () => { |
||||
|
this.historyLoading = false; |
||||
|
}); |
||||
|
}, |
||||
|
// 渲染消息会话页面 |
||||
|
renderMsgList (data, type) { |
||||
|
// 无需滚动至底部的ws事件:用户端拉取历史记录、用户端停止生成、坐席端取历史记录、点赞点踩 |
||||
|
const noScrollEvt = [MESSAGE_TYPE.HISTORY, MESSAGE_TYPE.STOP, MESSAGE_TYPE.WORKBENCH_HISTORY, MESSAGE_TYPE.FEEDBACK]; |
||||
|
const list = data.map(el => { |
||||
|
return { ...el, showPop: true }; |
||||
|
}); |
||||
|
this.msgList = clone(list); |
||||
|
// console.log('=======更新消list========', clone(list)); |
||||
|
|
||||
|
// 对话框滚动至底部(部分ws事件类型无需执行滚动) |
||||
|
this.$nextTick(() => { |
||||
|
const sDom = document.querySelector('.client-chat'); |
||||
|
|
||||
|
if (!sDom) return; |
||||
|
|
||||
|
if (!this.userScrolling && (!noScrollEvt.includes(type))) { |
||||
|
this.jsScrolling = true; |
||||
|
scrollToBottom(sDom, sDom.scrollHeight); |
||||
|
} |
||||
|
if (this.msgList.length > 0 && this.msgList[this.msgList.length - 1].is_final === true) { |
||||
|
this.userScrolling = false; |
||||
|
} |
||||
|
}); |
||||
|
}, |
||||
|
handleOutsideClick (event) { |
||||
|
if (!this.oldPopDemo) { return; }; |
||||
|
const firstElement = document.getElementsByClassName('pop-demo')[0]; |
||||
|
if (this.oldPopDemo.contains(event.target) || firstElement.contains(event.target)) { |
||||
|
} else { |
||||
|
if (this.oldPopDemo) { |
||||
|
this.$refs['first'] && this.$refs['first'].unbindTrigger(this.oldPopDemo); |
||||
|
} |
||||
|
// 调用你想要执行的方法 |
||||
|
this.$refs['first'] && this.$refs['first'].hide(); |
||||
|
this.oldPopDemo = null; |
||||
|
} |
||||
|
}, |
||||
|
littleTagClick (e, r) { |
||||
|
const findMsg = this.$clientData.getMsgById(r); |
||||
|
let innerDome = e.querySelectorAll('.little-tags'); |
||||
|
let outerTextArr = []; |
||||
|
if (innerDome && innerDome.length > 0) { |
||||
|
innerDome.forEach(dom => { |
||||
|
outerTextArr.push(dom.outerText); |
||||
|
}); |
||||
|
} |
||||
|
this.popperList = findMsg.references.filter(e => outerTextArr.includes(e.id)); |
||||
|
if (e) { |
||||
|
this.$refs['first'] && this.$refs['first'].bindTrigger(e, 'manual'); |
||||
|
this.$refs['first'] && this.$refs['first'].update(); |
||||
|
this.$refs['first'] && this.$refs['first'].show(); |
||||
|
this.oldPopDemo = e; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
}; |
||||
|
</script> |
||||
|
|
||||
|
<style lang="scss"> |
||||
|
.client-chat::-webkit-scrollbar { |
||||
|
display: none; |
||||
|
} |
||||
|
.pop-demo{ |
||||
|
// background-color: pink; |
||||
|
padding: 10px; |
||||
|
display: flex; |
||||
|
min-width: var(--size-l); |
||||
|
padding: var(--spacing-base); |
||||
|
flex-direction: column; |
||||
|
justify-content: center; |
||||
|
// align-items: center; |
||||
|
gap: var(--spacing-tight); |
||||
|
|
||||
|
border-radius: var(--radius-normal); |
||||
|
border: 0.5px solid var(--color-border-normal); |
||||
|
|
||||
|
background: var(--color-bg-2); |
||||
|
/* shadow/--shadow-medium */ |
||||
|
box-shadow: var(--shadow-medium-x-1) var(--shadow-medium-y-2) var(--shadow-medium-blur-1) var(--shadow-medium-spread-1) var(--shadow-medium-color-1), var(--shadow-medium-x-2) var(--shadow-medium-y-2) var(--shadow-medium-blur-2) var(--shadow-medium-spread-2) var(--shadow-medium-color-2), var(--shadow-medium-x-3) var(--shadow-medium-y-3) var(--shadow-medium-blur-3) var(--shadow-medium-spread-3) var(--shadow-medium-color-3); |
||||
|
|
||||
|
.v-popper__arrow{ |
||||
|
display: block; |
||||
|
} |
||||
|
.pop-demo-list{ |
||||
|
color: var(--color-link-normal); |
||||
|
/* caption/--caption-regular */ |
||||
|
font-family: var(--font-family-normal); |
||||
|
font-size: 12px; |
||||
|
font-style: normal; |
||||
|
font-weight: 400; |
||||
|
line-height: 16px; /* 133.333% */ |
||||
|
.v-button { |
||||
|
text-decoration: none; |
||||
|
text-align: left; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
.client-chat { |
||||
|
display: flex; |
||||
|
flex-direction: column; |
||||
|
flex: 1; |
||||
|
overflow-y: overlay; |
||||
|
padding: 0 12px; |
||||
|
|
||||
|
.loading { |
||||
|
margin: 1em 0; |
||||
|
width: 150px; |
||||
|
|
||||
|
&:after { |
||||
|
content: "."; |
||||
|
animation: ellipsis 1.5s steps(1, end) infinite; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
@keyframes ellipsis { |
||||
|
0% { |
||||
|
content: "."; |
||||
|
} |
||||
|
|
||||
|
33% { |
||||
|
content: ".."; |
||||
|
} |
||||
|
|
||||
|
66% { |
||||
|
content: "..."; |
||||
|
} |
||||
|
|
||||
|
100% { |
||||
|
content: "."; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
.qa-item { |
||||
|
display: flex; |
||||
|
flex-direction: column; |
||||
|
margin-bottom: 16px; |
||||
|
font-weight: 400; |
||||
|
font-size: 14px; |
||||
|
color: var(--color-text-primary); |
||||
|
|
||||
|
.timestamp { |
||||
|
font-weight: 400; |
||||
|
font-size: 12px; |
||||
|
line-height: 16px; |
||||
|
text-align: center; |
||||
|
color: var(--color-text-caption); |
||||
|
margin: 16px 0; |
||||
|
} |
||||
|
|
||||
|
.question-item { |
||||
|
display: flex; |
||||
|
align-items: center; |
||||
|
width: fit-content; |
||||
|
text-align: center; |
||||
|
align-self: flex-end; |
||||
|
padding-left: 44px; |
||||
|
|
||||
|
.qs-error { |
||||
|
min-width: 16px; |
||||
|
margin-right: 10px; |
||||
|
color: var(--color-error-normal); |
||||
|
} |
||||
|
|
||||
|
.qs-loading { |
||||
|
margin-right: 10px; |
||||
|
} |
||||
|
|
||||
|
.question-text { |
||||
|
background: #DBE8FF; |
||||
|
border-radius: 6px; |
||||
|
padding: 0 12px; |
||||
|
text-align: left; |
||||
|
word-break: break-all; |
||||
|
word-wrap: break-word; |
||||
|
|
||||
|
code { |
||||
|
white-space: break-spaces; |
||||
|
} |
||||
|
|
||||
|
img { |
||||
|
max-width: 80%; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
.summary-item { |
||||
|
align-self: center; |
||||
|
margin: 12px 0; |
||||
|
} |
||||
|
|
||||
|
.answer-item { |
||||
|
display: flex; |
||||
|
|
||||
|
.contacter-avatar { |
||||
|
width: 32px; |
||||
|
height: 32px; |
||||
|
border-radius: 50%; |
||||
|
margin-right: 12px; |
||||
|
display: flex; |
||||
|
align-items: center; |
||||
|
justify-content: center; |
||||
|
} |
||||
|
|
||||
|
.answer-info { |
||||
|
position: relative; |
||||
|
display: flex; |
||||
|
flex-direction: column; |
||||
|
padding: 12px; |
||||
|
background: #F4F5F7; |
||||
|
border-radius: 6px; |
||||
|
width: calc(100% - 67px); |
||||
|
|
||||
|
.answer-expand { |
||||
|
display: flex; |
||||
|
justify-content: center; |
||||
|
align-items: center; |
||||
|
cursor: pointer; |
||||
|
width: 44px; |
||||
|
height: 24px; |
||||
|
margin-bottom: 12px; |
||||
|
background: var(--color-bg-2); |
||||
|
box-shadow: var(--shadow-small-light); |
||||
|
border-radius: 16px; |
||||
|
align-self: center; |
||||
|
} |
||||
|
|
||||
|
.stop-ws { |
||||
|
color: var(--color-text-caption); |
||||
|
margin-left: 5px; |
||||
|
} |
||||
|
|
||||
|
.answer-source { |
||||
|
margin: 12px 0; |
||||
|
font-size: 14px; |
||||
|
color: var(--color-text-caption); |
||||
|
text-align: left; |
||||
|
|
||||
|
.v-button { |
||||
|
text-decoration: none; |
||||
|
text-align: left; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
.qa-item:last-child { |
||||
|
padding-bottom: 120px; |
||||
|
} |
||||
|
} |
||||
|
</style> |
@ -0,0 +1,232 @@ |
|||||
|
<template> |
||||
|
<view class="pending"> |
||||
|
{{title}} |
||||
|
</view> |
||||
|
</template> |
||||
|
|
||||
|
<script> |
||||
|
export default { |
||||
|
name: 'Pending', |
||||
|
data() { |
||||
|
return { |
||||
|
timeObj: null, |
||||
|
title: '正在思考中...' |
||||
|
}; |
||||
|
}, |
||||
|
mounted() { |
||||
|
this.init() |
||||
|
}, |
||||
|
beforeDestroy() { |
||||
|
this.timeObj && clearInterval(this.timeObj); |
||||
|
}, |
||||
|
methods: { |
||||
|
init() { |
||||
|
this.timeObj = setInterval(() => { |
||||
|
this.title = this.title == '正在思考中...' ? this.title === '正在思考中..' ? '正在思考中.' : '正在思考中..' : '正在思考中...' |
||||
|
}, 500) |
||||
|
} |
||||
|
} |
||||
|
}; |
||||
|
</script> |
||||
|
|
||||
|
<style lang="scss"> |
||||
|
.client-chat::-webkit-scrollbar { |
||||
|
display: none; |
||||
|
} |
||||
|
.pop-demo { |
||||
|
// background-color: pink; |
||||
|
padding: 10px; |
||||
|
display: flex; |
||||
|
min-width: var(--size-l); |
||||
|
padding: var(--spacing-base); |
||||
|
flex-direction: column; |
||||
|
justify-content: center; |
||||
|
// align-items: center; |
||||
|
gap: var(--spacing-tight); |
||||
|
|
||||
|
border-radius: var(--radius-normal); |
||||
|
border: 0.5px solid var(--color-border-normal); |
||||
|
|
||||
|
background: var(--color-bg-2); |
||||
|
/* shadow/--shadow-medium */ |
||||
|
box-shadow: var(--shadow-medium-x-1) var(--shadow-medium-y-2) |
||||
|
var(--shadow-medium-blur-1) var(--shadow-medium-spread-1) |
||||
|
var(--shadow-medium-color-1), |
||||
|
var(--shadow-medium-x-2) var(--shadow-medium-y-2) |
||||
|
var(--shadow-medium-blur-2) var(--shadow-medium-spread-2) |
||||
|
var(--shadow-medium-color-2), |
||||
|
var(--shadow-medium-x-3) var(--shadow-medium-y-3) |
||||
|
var(--shadow-medium-blur-3) var(--shadow-medium-spread-3) |
||||
|
var(--shadow-medium-color-3); |
||||
|
|
||||
|
.v-popper__arrow { |
||||
|
display: block; |
||||
|
} |
||||
|
.pop-demo-list { |
||||
|
color: var(--color-link-normal); |
||||
|
/* caption/--caption-regular */ |
||||
|
font-family: var(--font-family-normal); |
||||
|
font-size: 12px; |
||||
|
font-style: normal; |
||||
|
font-weight: 400; |
||||
|
line-height: 16px; /* 133.333% */ |
||||
|
.v-button { |
||||
|
text-decoration: none; |
||||
|
text-align: left; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
.client-chat { |
||||
|
display: flex; |
||||
|
flex-direction: column; |
||||
|
flex: 1; |
||||
|
overflow-y: overlay; |
||||
|
padding: 0 12px; |
||||
|
|
||||
|
.loading { |
||||
|
margin: 1em 0; |
||||
|
width: 150px; |
||||
|
|
||||
|
&:after { |
||||
|
content: "."; |
||||
|
animation: ellipsis 1.5s steps(1, end) infinite; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
@keyframes ellipsis { |
||||
|
0% { |
||||
|
content: "."; |
||||
|
} |
||||
|
|
||||
|
33% { |
||||
|
content: ".."; |
||||
|
} |
||||
|
|
||||
|
66% { |
||||
|
content: "..."; |
||||
|
} |
||||
|
|
||||
|
100% { |
||||
|
content: "."; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
.qa-item { |
||||
|
display: flex; |
||||
|
flex-direction: column; |
||||
|
margin-bottom: 16px; |
||||
|
font-weight: 400; |
||||
|
font-size: 14px; |
||||
|
color: var(--color-text-primary); |
||||
|
|
||||
|
.timestamp { |
||||
|
font-weight: 400; |
||||
|
font-size: 12px; |
||||
|
line-height: 16px; |
||||
|
text-align: center; |
||||
|
color: var(--color-text-caption); |
||||
|
margin: 16px 0; |
||||
|
} |
||||
|
|
||||
|
.question-item { |
||||
|
display: flex; |
||||
|
align-items: center; |
||||
|
width: fit-content; |
||||
|
text-align: center; |
||||
|
align-self: flex-end; |
||||
|
padding-left: 44px; |
||||
|
|
||||
|
.qs-error { |
||||
|
min-width: 16px; |
||||
|
margin-right: 10px; |
||||
|
color: var(--color-error-normal); |
||||
|
} |
||||
|
|
||||
|
.qs-loading { |
||||
|
margin-right: 10px; |
||||
|
} |
||||
|
|
||||
|
.question-text { |
||||
|
background: #dbe8ff; |
||||
|
border-radius: 6px; |
||||
|
padding: 0 12px; |
||||
|
text-align: left; |
||||
|
word-break: break-all; |
||||
|
word-wrap: break-word; |
||||
|
|
||||
|
code { |
||||
|
white-space: break-spaces; |
||||
|
} |
||||
|
|
||||
|
img { |
||||
|
max-width: 80%; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
.summary-item { |
||||
|
align-self: center; |
||||
|
margin: 12px 0; |
||||
|
} |
||||
|
|
||||
|
.answer-item { |
||||
|
display: flex; |
||||
|
|
||||
|
.contacter-avatar { |
||||
|
width: 32px; |
||||
|
height: 32px; |
||||
|
border-radius: 50%; |
||||
|
margin-right: 12px; |
||||
|
display: flex; |
||||
|
align-items: center; |
||||
|
justify-content: center; |
||||
|
} |
||||
|
|
||||
|
.answer-info { |
||||
|
position: relative; |
||||
|
display: flex; |
||||
|
flex-direction: column; |
||||
|
padding: 12px; |
||||
|
background: #f4f5f7; |
||||
|
border-radius: 6px; |
||||
|
width: calc(100% - 67px); |
||||
|
|
||||
|
.answer-expand { |
||||
|
display: flex; |
||||
|
justify-content: center; |
||||
|
align-items: center; |
||||
|
cursor: pointer; |
||||
|
width: 44px; |
||||
|
height: 24px; |
||||
|
margin-bottom: 12px; |
||||
|
background: var(--color-bg-2); |
||||
|
box-shadow: var(--shadow-small-light); |
||||
|
border-radius: 16px; |
||||
|
align-self: center; |
||||
|
} |
||||
|
|
||||
|
.stop-ws { |
||||
|
color: var(--color-text-caption); |
||||
|
margin-left: 5px; |
||||
|
} |
||||
|
|
||||
|
.answer-source { |
||||
|
margin: 12px 0; |
||||
|
font-size: 14px; |
||||
|
color: var(--color-text-caption); |
||||
|
text-align: left; |
||||
|
|
||||
|
.v-button { |
||||
|
text-decoration: none; |
||||
|
text-align: left; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
.qa-item:last-child { |
||||
|
padding-bottom: 120px; |
||||
|
} |
||||
|
} |
||||
|
</style> |
@ -0,0 +1,799 @@ |
|||||
|
<template> |
||||
|
<view class="chat-wrap__main"> |
||||
|
<view class="chat-wrap__main-content" :style="{ 'margin-bottom': `${chatMainMrgBottom}px` }"> |
||||
|
<!-- <view>显示内容</view> --> |
||||
|
<!-- <ClientChat @send="onSendQuestion" /> --> |
||||
|
<scroll-view class="chat-wrapper" :scroll-into-view="bottom" :scroll-y="true" :scroll-with-animation="true" scroll-into-view-offset="10"> |
||||
|
<view class="list-container"> |
||||
|
<!-- for循环 --> |
||||
|
<view class="chat-list" v-for="n,index in msgList" :key="index"> |
||||
|
<view class="msg-container other" v-if="n.type =='reply' "> |
||||
|
<image class="ava" :src="robotObj.headImage"></image> |
||||
|
<view class="msg"> |
||||
|
<view class="msg-nickname">{{robotObj.name}}</view> |
||||
|
<view :class="n.contentType==='img' ? 'msg-content-img' : '' " class="msg-content"> |
||||
|
<template v-if="n.pending"> |
||||
|
<Pending></Pending> |
||||
|
</template> |
||||
|
<template v-else> |
||||
|
<template v-if="n.contentType === 'text'"> |
||||
|
<rich-text v-if="n.contentType === 'text'" :nodes="n.content"></rich-text> |
||||
|
<view v-if="n.is_final" class="msg-btns"> |
||||
|
<image mode="widthFix" class="btn-img" @click="copy(n.content)" src="./../../static/imgs/icon-copy.png"></image> |
||||
|
<image mode="widthFix" class="btn-img" @click="audio(n)" :src="audioActive == n.timestamp ? './../../static/imgs/icon-bf-active.png':'./../../static/imgs/icon-bf.png'"></image> |
||||
|
</view> |
||||
|
</template> |
||||
|
|
||||
|
<image v-else-if="n.contentType === 'img'" mode="widthFix" class="cimg" src="./../../static/imgs/more.png"></image> |
||||
|
</template> |
||||
|
</view> |
||||
|
</view> |
||||
|
</view> |
||||
|
<view v-if="n.type==='send' " class="msg-container self"> |
||||
|
<view class="msg"> |
||||
|
<!-- <view class="msg-nickname">本人</view> --> |
||||
|
<view class="msg-content" :class="n.contentType==='img' ? 'msg-content-img' : '' "> |
||||
|
<text v-if="n.contentType === 'text'">{{n.content}}</text> |
||||
|
<image v-else-if="n.contentType === 'img'" mode="widthFix" class="cimg" :src="n.content"></image> |
||||
|
</view> |
||||
|
</view> |
||||
|
<!-- <image class="ava" src="./../../static/imgs/more.png"></image> --> |
||||
|
</view> |
||||
|
</view> |
||||
|
</view> |
||||
|
<view id="bottom" style="opacity:0"> |
||||
|
</view> |
||||
|
</scroll-view> |
||||
|
|
||||
|
</view> |
||||
|
<view class="chat-wrap__main-footer"> |
||||
|
|
||||
|
<view class="disabled-loadding" v-if="disabledStatus"></view> |
||||
|
<view class="chatinput-wrapper"> |
||||
|
<view class="chatinput-content"> |
||||
|
<view class="chatinput-btn-wrap"> |
||||
|
<image :src="videoStatus ? './../../static/imgs/icon-txt.png' :'./../../static/imgs/icon-video.png'" class='chatinput-img' @click="changeVideoStatus"></image> |
||||
|
</view> |
||||
|
<view class='chatinput-input-wrap' v-if="videoStatus"> |
||||
|
<view style="text-align: center; flex: 1" @longpress="startVideo" @touchend="stopVideo"> |
||||
|
按住 说话 |
||||
|
</view> |
||||
|
</view> |
||||
|
<view v-else class='chatinput-input-wrap'> |
||||
|
<input v-model="inputValue" @focus="inputFocus" @input="inputChange" @confirm="inputSend" placeholder="想对TA说点什么呢…" confirm-type='send' /> |
||||
|
</view> |
||||
|
<view class="chatinput-btn-wrap"> |
||||
|
<image v-if="!isEditInput" src="./../../static/imgs/icon-pic.png" class='chatinput-img' @click="tapChooseImage"></image> |
||||
|
<button v-if="isEditInput" class="chatinput-send">发送</button> |
||||
|
</view> |
||||
|
</view> |
||||
|
</view> |
||||
|
</view> |
||||
|
</view> |
||||
|
</template> |
||||
|
|
||||
|
<script> |
||||
|
import Socket from './utils/socket' |
||||
|
import { splitTextForTTS } from './utils/util' |
||||
|
import { |
||||
|
getHistroyMsg, |
||||
|
} from './utils/message' |
||||
|
import ClientData from './utils/ClientData'; |
||||
|
import Pending from './components/pending'; |
||||
|
export default { |
||||
|
props: { |
||||
|
agentId: { |
||||
|
type: String, |
||||
|
default: '', |
||||
|
} |
||||
|
}, |
||||
|
components: { |
||||
|
Pending |
||||
|
}, |
||||
|
watch: { |
||||
|
agentId: { |
||||
|
handler(val) { |
||||
|
if (val) { |
||||
|
this.init(); |
||||
|
} |
||||
|
}, |
||||
|
immediate: true, |
||||
|
}, |
||||
|
}, |
||||
|
data() { |
||||
|
return { |
||||
|
title: 'Hello', |
||||
|
socketObj: null, |
||||
|
isEditInput: false, |
||||
|
inputValue: '', |
||||
|
chatMainMrgBottom: '', |
||||
|
bottom: '', |
||||
|
videoStatus: false, |
||||
|
msgList: [], |
||||
|
recorderManager: null, |
||||
|
audioCtx: '', |
||||
|
audioActive: '', |
||||
|
$ClientData: '', |
||||
|
robotObj: {}, |
||||
|
audioArray: [], |
||||
|
asrStatus: false, |
||||
|
tmpMsg: {}, |
||||
|
lastTxt: {}, |
||||
|
txtAudioStaus: {}, |
||||
|
disabledStatus: false |
||||
|
} |
||||
|
}, |
||||
|
onLoad() { |
||||
|
}, |
||||
|
mounted() { |
||||
|
console.log('【init message connect type------>】',); |
||||
|
// this.init() |
||||
|
}, |
||||
|
destroyed() { |
||||
|
console.log('【destroy message connect type------>】',); |
||||
|
this.socketObj.selfCloseStatus = true |
||||
|
this.socketObj.destroy() |
||||
|
this.audioCtx && this.audioCtx.destroy() |
||||
|
this.audioCtx = null |
||||
|
this.audioActive = '' |
||||
|
this.audioStatus = false |
||||
|
this.asrStatus = false |
||||
|
}, |
||||
|
onShow() { |
||||
|
this.scrollToBottom() |
||||
|
}, |
||||
|
methods: { |
||||
|
async init() { |
||||
|
this.audioStatus = true |
||||
|
this.asrStatus = true |
||||
|
uni.showLoading() |
||||
|
console.log('【init message connect type------>】',); |
||||
|
this.socketObj = new Socket({ |
||||
|
agentId: this.agentId, |
||||
|
onMessage: (e) => { |
||||
|
const index = this.msgList.findIndex(it => it.request_id == e.request_id && it.type == 'reply') |
||||
|
console.log(e.request_id + '回复内容', e.content) |
||||
|
// this.tmpMsg[e.request_id] = this.tmpMsg[e.request_id] ?? [] |
||||
|
// if (this.tmpMsg[e.request_id].length) { |
||||
|
// const txt = this.removeSpecificText(e.content??'', this.lastTxt[e.request_id] ?? '') |
||||
|
// if (txt) { |
||||
|
// this.tmpMsg[e.request_id].push(txt) |
||||
|
// } |
||||
|
// } else { |
||||
|
// this.tmpMsg[e.request_id].push(e.content) |
||||
|
// } |
||||
|
// this.lastTxt[e.request_id] = e.content |
||||
|
// // console.log(this.tmpMsg[e.request_id]) |
||||
|
// if (!this.txtAudioStaus[e.request_id]) { |
||||
|
// this.txtAudioStaus[e.request_id] = true |
||||
|
// this.renderTxt(e) |
||||
|
// } |
||||
|
|
||||
|
// console.log(e.content) |
||||
|
e.pending = false |
||||
|
// if (index > -1) { |
||||
|
// this.msgList[index] = e |
||||
|
// } else { |
||||
|
// if (this.msgList.length) { |
||||
|
// const obj = this.msgList[this.msgList.length - 1] |
||||
|
// if (obj.pending) { |
||||
|
// this.$nextTick(() => { |
||||
|
// this.msgList[this.msgList.length - 1] = e |
||||
|
// }) |
||||
|
|
||||
|
// } else { |
||||
|
// this.msgList.push(e) |
||||
|
// } |
||||
|
|
||||
|
// } else { |
||||
|
// this.msgList.push(e) |
||||
|
// } |
||||
|
// } |
||||
|
|
||||
|
if (!e.is_from_self && e.is_final) { |
||||
|
const audioParams = e |
||||
|
console.log( audioParams); |
||||
|
// 回复结束 写入缓存 |
||||
|
this.audio(audioParams, (txt)=> { |
||||
|
const index = this.msgList.findIndex(it => it.request_id == e.request_id && it.type == 'reply') |
||||
|
if (index > -1) { |
||||
|
this.$nextTick(() => { |
||||
|
this.msgList[index].content += txt |
||||
|
this.scrollToBottom() |
||||
|
}) |
||||
|
} else { |
||||
|
if (this.msgList.length) { |
||||
|
const obj = this.msgList[this.msgList.length - 1] |
||||
|
if (obj.pending) { |
||||
|
this.$nextTick(() => { |
||||
|
const tmp = { |
||||
|
...e, |
||||
|
pending: false, |
||||
|
content: txt |
||||
|
} |
||||
|
this.msgList[this.msgList.length - 1] = tmp |
||||
|
this.scrollToBottom() |
||||
|
}) |
||||
|
} else { |
||||
|
this.msgList.push(e) |
||||
|
} |
||||
|
} else { |
||||
|
const tmp = { |
||||
|
...e, |
||||
|
pending: false |
||||
|
} |
||||
|
this.$nextTick(() => { |
||||
|
this.msgList.push(tmp) |
||||
|
this.scrollToBottom() |
||||
|
}) |
||||
|
} |
||||
|
} |
||||
|
this.scrollToBottom() |
||||
|
}) |
||||
|
|
||||
|
// |
||||
|
this.disabledStatus = false |
||||
|
} |
||||
|
this.scrollToBottom() |
||||
|
} |
||||
|
}) |
||||
|
this.getRecord(this.agentId) |
||||
|
this.scrollToBottom(1000) |
||||
|
await this.socketObj.init() |
||||
|
this.$ClientData = new ClientData() |
||||
|
this.$ClientData.init() |
||||
|
this.$ClientData.setAttr({ socketObj: this.socketObj }) |
||||
|
this.robotObj = this.socketObj.robotObj |
||||
|
wx.hideLoading() |
||||
|
this.sendMsg() |
||||
|
}, |
||||
|
async renderTxt(e) { |
||||
|
for (let index = 0; index < this.tmpMsg[e.request_id].length; index++) { |
||||
|
const txt = this.tmpMsg[e.request_id][index]; |
||||
|
const params = { |
||||
|
content: txt, |
||||
|
timestamp: e.timestamp |
||||
|
} |
||||
|
await this.audio(params) |
||||
|
} |
||||
|
}, |
||||
|
sendMsg() { |
||||
|
if (!this.msgList.length) { |
||||
|
this.$ClientData.triggerSendMsg(this.socketObj.robotObj.firstWord, 'text', false); |
||||
|
} |
||||
|
}, |
||||
|
|
||||
|
removeSpecificText(originalText, textToRemove) { |
||||
|
// 使用字符串替换方法删除指定文本 |
||||
|
return originalText.replace(new RegExp(textToRemove, 'g'), ''); |
||||
|
}, |
||||
|
// 复制 |
||||
|
copy(e) { |
||||
|
wx.setClipboardData({ |
||||
|
data: e, |
||||
|
success: function () { |
||||
|
wx.showToast({ title: '复制成功' }); |
||||
|
} |
||||
|
}); |
||||
|
}, |
||||
|
|
||||
|
async audio(n, cb) { |
||||
|
console.log('【audio------>】', n); |
||||
|
const txt = this.stripHtmlTags(n.content) |
||||
|
if (!txt) { return } |
||||
|
this.audioCtx && this.audioCtx.destroy() |
||||
|
this.audioCtx = null |
||||
|
|
||||
|
if (this.audioActive == n.timestamp) { |
||||
|
this.audioActive = '' |
||||
|
return |
||||
|
} |
||||
|
const texts = splitTextForTTS(txt) |
||||
|
console.log('语言划分', texts) |
||||
|
|
||||
|
this.audioActive = n.timestamp |
||||
|
for (let index = 0; index < texts.length; index++) { |
||||
|
const txt = texts[index]; |
||||
|
const nextText = texts[index + 1] ?? '' |
||||
|
if (nextText) { |
||||
|
this.loadAudioUrl(nextText) |
||||
|
} |
||||
|
await this.audioText(txt, cb) |
||||
|
} |
||||
|
this.disabledStatus = false // 语音播发结束 |
||||
|
this.audioActive = '' |
||||
|
|
||||
|
}, |
||||
|
|
||||
|
audioText(text, cb) { |
||||
|
return new Promise(async (resolve, reject) => { |
||||
|
if (!this.audioStatus) { reject() } |
||||
|
const url = await this.loadAudioUrl(text) |
||||
|
console.log('播放文字', text) |
||||
|
cb && cb(text) |
||||
|
this.audioCtx = wx.createInnerAudioContext(); |
||||
|
this.audioCtx.src = url //'data:audio/wav;base64,' + res.data.data |
||||
|
this.audioCtx.play() |
||||
|
this.audioCtx.onStop(() => { |
||||
|
this.delAudioFile(url) |
||||
|
resolve() |
||||
|
}) |
||||
|
this.audioCtx.onEnded(() => { |
||||
|
this.delAudioFile(url) |
||||
|
resolve() |
||||
|
}) |
||||
|
}); |
||||
|
}, |
||||
|
loadAudioUrl(text) { |
||||
|
if (this.audioArray[text]) { |
||||
|
return Promise.resolve(this.audioArray[text]) |
||||
|
} else { |
||||
|
return new Promise((resolve, reject) => { |
||||
|
uni.request({ |
||||
|
method: "post", |
||||
|
data: { |
||||
|
text, |
||||
|
voiceType: this.socketObj.robotObj.voiceType ?? 1002 |
||||
|
}, |
||||
|
dataType: "json", |
||||
|
url: `https://des.js-dyyj.com/xcx/api/voice/tts`, |
||||
|
success: (res) => { |
||||
|
console.log('文字合成语音', res) |
||||
|
if (res.data.code) { |
||||
|
this.audioArray[text] = res.data.msg |
||||
|
const audio = wx.createInnerAudioContext(); |
||||
|
audio.src = res.data.msg; |
||||
|
audio.onCanplay(() => { |
||||
|
audio.destroy(); // 加载后释放资源 |
||||
|
}); |
||||
|
|
||||
|
resolve(res.data.msg) |
||||
|
|
||||
|
} else { |
||||
|
wx.showToast({ title: '文字转化失败' }); |
||||
|
reject() |
||||
|
} |
||||
|
|
||||
|
}, |
||||
|
fail: (err) => { |
||||
|
console.log("【init msg-------getDemoToken---->】", err); |
||||
|
|
||||
|
}, |
||||
|
}); |
||||
|
}) |
||||
|
} |
||||
|
}, |
||||
|
|
||||
|
stripHtmlTags(html) { |
||||
|
return html.replace(/<\/?[^>]+(>\$)/g, '') |
||||
|
.replace(/\u00a0/g, '') |
||||
|
// .replace(/\s+/g, ' ') |
||||
|
.replace(/\([^)]*\)/g, '') |
||||
|
.replace(/\([^)]*\)/g, '') |
||||
|
.replace(/\<br\/\>/g, '') |
||||
|
.replace(/\<br\>/g, '') |
||||
|
}, |
||||
|
|
||||
|
getRecord(id) { |
||||
|
// 获取聊天记录 |
||||
|
let { msgList } = getHistroyMsg(id) |
||||
|
if (msgList && msgList.length) { |
||||
|
this.msgList = msgList.slice(-10) |
||||
|
} else { |
||||
|
this.msgList = [] |
||||
|
} |
||||
|
}, |
||||
|
changeVideoStatus() { |
||||
|
this.videoStatus = !this.videoStatus |
||||
|
}, |
||||
|
|
||||
|
startVideo() { |
||||
|
if (!this.recorderManager) { |
||||
|
this.recorderManager = wx.getRecorderManager() |
||||
|
} |
||||
|
this.recorderManager.start({ |
||||
|
duration: 10000, |
||||
|
sampleRate: 44100, |
||||
|
numberOfChannels: 1, |
||||
|
encodeBitRate: 192000, |
||||
|
format: 'aac', |
||||
|
frameSize: 50 |
||||
|
}) |
||||
|
this.recorderManager.onStart(() => { |
||||
|
console.log('开始录音') |
||||
|
wx.showLoading({ |
||||
|
title: '录音中', |
||||
|
}) |
||||
|
}) |
||||
|
|
||||
|
this.recorderManager.onStop(async (res) => { |
||||
|
wx.showLoading({ |
||||
|
title: '识别中', |
||||
|
mask: true |
||||
|
}) |
||||
|
uni.uploadFile({ |
||||
|
url: 'https://des.js-dyyj.com/xcx/system/oss/upload', |
||||
|
filePath: res.tempFilePath, |
||||
|
name: 'file', |
||||
|
success: async (res) => { |
||||
|
let data = JSON.parse(res.data); |
||||
|
console.log('上传成功', res, data); |
||||
|
if (!this.asrStatus) { return } |
||||
|
if (data.code == 200) { |
||||
|
uni.request({ |
||||
|
method: "get", |
||||
|
dataType: "json", |
||||
|
url: `https://des.js-dyyj.com/xcx/api/voice/asr?audioFilePath=${encodeURI(data.data.url)}`, |
||||
|
success: (res) => { |
||||
|
console.log("【init msg-------res---->】", res); |
||||
|
if (!this.asrStatus) { return } |
||||
|
if (res.data.code == 200) { |
||||
|
wx.hideLoading() |
||||
|
if (res.data.msg) { |
||||
|
this.onSendQuestion(res.data.msg); |
||||
|
this.scrollToBottom() |
||||
|
} else { |
||||
|
wx.showToast({ |
||||
|
title: '没有听清,请再说一遍', |
||||
|
icon: 'none' |
||||
|
}); |
||||
|
|
||||
|
} |
||||
|
} else { |
||||
|
wx.showToast({ |
||||
|
title: '识别失败', |
||||
|
icon: 'error' |
||||
|
}); |
||||
|
} |
||||
|
}, |
||||
|
fail: (err) => { |
||||
|
wx.showToast({ |
||||
|
title: '识别失败', |
||||
|
icon: 'error' |
||||
|
}); |
||||
|
}, |
||||
|
complete: () => { |
||||
|
this.delAudioFile(data.data.url) |
||||
|
} |
||||
|
}); |
||||
|
} |
||||
|
}, |
||||
|
fail: (err) => { |
||||
|
console.log('上传失败', err); |
||||
|
wx.showToast({ |
||||
|
title: '识别失败', |
||||
|
icon: 'error' |
||||
|
}); |
||||
|
} |
||||
|
}); |
||||
|
}) |
||||
|
this.recorderManager.onFrameRecorded((res) => { |
||||
|
const { frameBuffer } = res |
||||
|
console.log('frameBuffer', frameBuffer) |
||||
|
// 实时处理音频帧数据 |
||||
|
this.getVideoText(frameBuffer) |
||||
|
}) |
||||
|
}, |
||||
|
|
||||
|
delAudioFile(path) { |
||||
|
if (path) { |
||||
|
uni.request({ |
||||
|
method: "get", |
||||
|
dataType: "json", |
||||
|
url: `https://des.js-dyyj.com/xcx/api/voice/deleteSingleFile?audioFilePath=${encodeURI(path)}`, |
||||
|
success: (res) => { |
||||
|
console.log("【删除文件msg-------res---->】", res); |
||||
|
|
||||
|
}, |
||||
|
fail: (err) => { |
||||
|
console.log("【init msg-------getDemoToken---->】", err); |
||||
|
|
||||
|
}, |
||||
|
}); |
||||
|
} |
||||
|
}, |
||||
|
|
||||
|
getVideoText(frameBuffer) { |
||||
|
const params = { |
||||
|
Action: 'SentenceRecognition', |
||||
|
Version: '2019-06-14', |
||||
|
EngineModelType: '8k_zh', |
||||
|
ChannelNum: 1, |
||||
|
VoiceFormat: 'acc', |
||||
|
ResTextFormat: 3, |
||||
|
ResTextFormat: 1 |
||||
|
} |
||||
|
}, |
||||
|
stopVideo() { |
||||
|
console.log('结束录音') |
||||
|
this.recorderManager && this.recorderManager.stop && this.recorderManager.stop() |
||||
|
this.recorderManager = null |
||||
|
}, |
||||
|
inputSend() { |
||||
|
console.log('【inputSend------>】',); |
||||
|
this.onSendQuestion(this.inputValue) |
||||
|
this.scrollToBottom() |
||||
|
}, |
||||
|
inputFocus() { |
||||
|
console.log('inputFocus------>】',); |
||||
|
this.scrollToBottom() |
||||
|
}, |
||||
|
inputChange() { |
||||
|
console.log('inputChange------>】',); |
||||
|
}, |
||||
|
// *发送图片 |
||||
|
async tapChooseImage() { |
||||
|
wx.chooseMedia({ |
||||
|
count: 1, |
||||
|
mediaType: ['image'], |
||||
|
sourceType: ['album', 'camera'], |
||||
|
success: (res) => { |
||||
|
uni.uploadFile({ |
||||
|
url: 'https://des.js-dyyj.com/xcx/system/oss/upload', |
||||
|
filePath: res.tempFiles[0].tempFilePath, |
||||
|
name: 'file', |
||||
|
success: (res) => { |
||||
|
console.log('上传成功', res); |
||||
|
let data = JSON.parse(res.data); |
||||
|
if (data.code == 200) { |
||||
|
const img = data.data.url |
||||
|
this.$ClientData.triggerSendMsg(img, 'img'); |
||||
|
const postData = { |
||||
|
content: img, |
||||
|
type: 'send', |
||||
|
contentType: 'img', |
||||
|
timestamp: +new Date(), |
||||
|
chatId: this.agentId |
||||
|
} |
||||
|
console.log('postData', postData) |
||||
|
this.msgList.push(postData) |
||||
|
// 加入恢复空信息 |
||||
|
this.inputTmpReply() |
||||
|
this.scrollToBottom() |
||||
|
|
||||
|
this.disabledStatus = true |
||||
|
} |
||||
|
|
||||
|
|
||||
|
}, |
||||
|
fail: (err) => { |
||||
|
console.log('上传失败', err); |
||||
|
} |
||||
|
}); |
||||
|
} |
||||
|
}) |
||||
|
}, |
||||
|
onSendQuestion(e) { |
||||
|
let self = this |
||||
|
if (e === '') { |
||||
|
return wx.showToast({ title: '不能发送空白消息', icon: 'none' }) |
||||
|
} |
||||
|
this.disabledStatus = true |
||||
|
console.log('发送问题', e) |
||||
|
this.$ClientData.triggerSendMsg(e, 'text'); |
||||
|
const postData = { |
||||
|
content: e, |
||||
|
type: 'send', |
||||
|
contentType: 'text', |
||||
|
timestamp: +new Date(), |
||||
|
chatId: this.agentId |
||||
|
} |
||||
|
|
||||
|
self.msgList.push(postData) |
||||
|
self.inputValue = '' |
||||
|
|
||||
|
this.inputTmpReply() |
||||
|
}, |
||||
|
inputTmpReply() { |
||||
|
// 加入恢复空信息 |
||||
|
const reData = { |
||||
|
content: '', |
||||
|
pending: true, |
||||
|
type: 'reply', |
||||
|
contentType: 'text', |
||||
|
// timestamp: +new Date(), |
||||
|
chatId: this.agentId, |
||||
|
nickName: this.socketObj.robotObj.name, |
||||
|
headImage: this.socketObj.robotObj.headImage, |
||||
|
} |
||||
|
this.msgList.push(reData) |
||||
|
}, |
||||
|
// !滑倒最底部 |
||||
|
scrollToBottom(time = 300) { |
||||
|
this.$nextTick(() => { |
||||
|
setTimeout(() => { |
||||
|
this.bottom = '' |
||||
|
this.$nextTick(function () { |
||||
|
this.bottom = 'bottom' |
||||
|
}) |
||||
|
}, time) |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
} |
||||
|
} |
||||
|
</script> |
||||
|
<style scoped lang="scss"> |
||||
|
.chat-wrap__main { |
||||
|
display: flex; |
||||
|
flex-direction: column; |
||||
|
width: 100%; |
||||
|
height: 100%; |
||||
|
min-height: 700px; |
||||
|
overflow: hidden; |
||||
|
background: var(--color-bg-0); |
||||
|
border-radius: 12px; |
||||
|
|
||||
|
&-chat-content { |
||||
|
flex: 1; |
||||
|
display: flex; |
||||
|
justify-content: flex-end; |
||||
|
align-items: flex-end; |
||||
|
} |
||||
|
|
||||
|
&-content { |
||||
|
height: calc(100% - 80px); |
||||
|
display: flex; |
||||
|
justify-content: flex-end; |
||||
|
align-items: flex-end; |
||||
|
} |
||||
|
&-footer { |
||||
|
position: relative; |
||||
|
z-index: 3; |
||||
|
} |
||||
|
} |
||||
|
.chat-wrap__main-content { |
||||
|
flex: 1; |
||||
|
} |
||||
|
.chat-wrap__main-footer { |
||||
|
height: 140rpx; |
||||
|
padding: 10rpx; |
||||
|
padding-bottom: 10px; |
||||
|
padding-bottom: constant(safe-area-inset-bottom); |
||||
|
padding-bottom: env(safe-area-inset-bottom); |
||||
|
background-color: #f8f8f8; |
||||
|
position: relative; |
||||
|
|
||||
|
.disabled-loadding { |
||||
|
position: absolute; |
||||
|
top: 0; |
||||
|
left: 0; |
||||
|
bottom: 0; |
||||
|
right: 0; |
||||
|
background-color: rgba($color: #ffffff, $alpha: 0.4); |
||||
|
z-index: 9; |
||||
|
} |
||||
|
} |
||||
|
// 输入框 |
||||
|
.chatinput-wrapper { |
||||
|
width: 100%; |
||||
|
// border-top: 1px solid #ddd; |
||||
|
// border-bottom: 1px solid #ddd; |
||||
|
.chatinput-content { |
||||
|
height: 100rpx; |
||||
|
padding: 10rpx 0 20rpx; |
||||
|
display: flex; |
||||
|
align-items: center; |
||||
|
.chatinput-input-wrap { |
||||
|
flex: 1; |
||||
|
height: 80rpx; |
||||
|
padding: 0 20rpx; |
||||
|
box-sizing: border-box; |
||||
|
display: flex; |
||||
|
align-items: center; |
||||
|
background: #fff; |
||||
|
input { |
||||
|
height: 76rpx; |
||||
|
font-size: 16px; |
||||
|
display: inline-block; |
||||
|
width: 100%; |
||||
|
margin: 10px; |
||||
|
} |
||||
|
} |
||||
|
.chatinput-btn-wrap { |
||||
|
width: 50px; |
||||
|
display: flex; |
||||
|
align-items: center; |
||||
|
justify-content: center; |
||||
|
image { |
||||
|
width: 30px; |
||||
|
height: 30px; |
||||
|
} |
||||
|
.chatinput-send { |
||||
|
height: 35px; |
||||
|
width: 43px; |
||||
|
border-radius: 4px; |
||||
|
font-size: 12px; |
||||
|
line-height: 35px; |
||||
|
padding: 0; |
||||
|
margin: 0 8px; |
||||
|
color: #fff; |
||||
|
background: #0097ff; |
||||
|
&::after { |
||||
|
border: none; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
.chat-wrapper { |
||||
|
height: 100%; |
||||
|
.chat-list { |
||||
|
padding: 10px 0; |
||||
|
.msg-container { |
||||
|
display: flex; |
||||
|
.ava { |
||||
|
width: 35px; |
||||
|
height: 35px; |
||||
|
border-radius: 4px; |
||||
|
margin: 0 10px; |
||||
|
} |
||||
|
.msg-nickname { |
||||
|
color: #999; |
||||
|
line-height: 1; |
||||
|
margin-bottom: 4px; |
||||
|
} |
||||
|
.msg-content { |
||||
|
box-sizing: border-box; |
||||
|
word-wrap: break-word; |
||||
|
max-width: 60vw; |
||||
|
padding: 8px; |
||||
|
border-radius: 5rpx; |
||||
|
background: #fff; |
||||
|
border: 1px solid #e7e7e7; |
||||
|
max-width: "calc(100vw - 110px)"; |
||||
|
|
||||
|
.msg-btns { |
||||
|
border-top: 1px solid #e7e7e7; |
||||
|
margin-top: 5px; |
||||
|
padding-top: 10px; |
||||
|
|
||||
|
.btn-img { |
||||
|
margin: 0 10px; |
||||
|
width: 20px; |
||||
|
height: 20px; |
||||
|
cursor: pointer; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
&.self { |
||||
|
justify-content: flex-end; |
||||
|
padding-right: 10px; |
||||
|
.msg { |
||||
|
display: flex; |
||||
|
flex-direction: column; |
||||
|
justify-content: flex-end; |
||||
|
align-items: flex-end; |
||||
|
} |
||||
|
.msg-nickname { |
||||
|
text-align: right; |
||||
|
} |
||||
|
.msg-content { |
||||
|
width: auto; |
||||
|
color: #fff; |
||||
|
background-color: #0097ff; |
||||
|
border-color: #0097ff; |
||||
|
} |
||||
|
} |
||||
|
&.other { |
||||
|
.msg { |
||||
|
display: flex; |
||||
|
flex-direction: column; |
||||
|
justify-content: flex-end; |
||||
|
align-items: flex-start; |
||||
|
} |
||||
|
} |
||||
|
.msg-content.msg-content-img { |
||||
|
background: none; |
||||
|
border: none; |
||||
|
padding: 0; |
||||
|
image { |
||||
|
max-width: 120px; |
||||
|
border-radius: 2px; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
</style> |
@ -0,0 +1,347 @@ |
|||||
|
import Vue from "vue"; |
||||
|
import { MESSAGE_TYPE } from "./chat_constant"; |
||||
|
import { getQueryVariable, generateRequestId, arrayUnique } from "./util"; |
||||
|
import { v4 as uuidv4 } from "uuid"; |
||||
|
import GLOBAL from "./global"; |
||||
|
import Observer from "./observer"; |
||||
|
const $e = Observer; |
||||
|
// const $s = this.$s;
|
||||
|
|
||||
|
let cache = null; // 缓存
|
||||
|
let timeoutTasks = {}; // 超时任务管理
|
||||
|
const msgSendTimeout = 2 * 60 * 1000; // 发送消息超时ms,此处超时默认为2min
|
||||
|
|
||||
|
class ClientData { |
||||
|
constructor(option) { |
||||
|
cache = { |
||||
|
session_id: "", // 会话ID
|
||||
|
configInfo: null, // 配置信息
|
||||
|
chatsContent: [], // 会话聊天内容
|
||||
|
systemEvents: [], // 系统事件栈
|
||||
|
transferInfo: { |
||||
|
transferStatus: false, |
||||
|
transferAvatar: "", |
||||
|
}, // 当前转人工状态
|
||||
|
}; |
||||
|
|
||||
|
this.$s = null |
||||
|
|
||||
|
} |
||||
|
init() { |
||||
|
// 获取基础配置
|
||||
|
this.queryConfigInfo(); |
||||
|
} |
||||
|
setAttr(options) { |
||||
|
this.$s =options.socketObj |
||||
|
} |
||||
|
// 获取基础配置
|
||||
|
async queryConfigInfo() { |
||||
|
try { |
||||
|
const seatBizId = ''// getQueryVariable("seat_biz_id");
|
||||
|
console.log("seatBizId", seatBizId); |
||||
|
const sessionInfo = await this.createSession(); |
||||
|
console.log("createsession, res", sessionInfo); |
||||
|
if (sessionInfo.code === 0) { |
||||
|
cache.seat_biz_id = seatBizId; |
||||
|
cache.session_id = sessionInfo.data.session_id; |
||||
|
} else { |
||||
|
uni.showModal({ |
||||
|
title: "获取会话ID失败,请重试", |
||||
|
icon: "none", |
||||
|
}); |
||||
|
} |
||||
|
// 接着获取机器人基础信息
|
||||
|
const botInfo = { |
||||
|
code: 0, |
||||
|
data: { |
||||
|
name: "测试机器人", |
||||
|
avatar: |
||||
|
"https://qbot-1251316161.cos.ap-nanjing.myqcloud.com/avatar.png", |
||||
|
is_available: true, |
||||
|
bot_biz_id: "1664519736704069632", |
||||
|
}, |
||||
|
}; |
||||
|
if (botInfo.data) { |
||||
|
cache.configInfo = botInfo.data; |
||||
|
cache.configInfo.session_id = sessionInfo.data.session_id; |
||||
|
$e.$emit("client_configChange", cache.configInfo); |
||||
|
} else { |
||||
|
uni.showModal({ |
||||
|
title: "获取机器人信息失败", |
||||
|
icon: "none", |
||||
|
}); |
||||
|
} |
||||
|
} catch (e) { |
||||
|
console.log("获取机器人信息失败", e); |
||||
|
uni.showModal({ |
||||
|
title: "获取会话信息失败,请刷新页面重试", |
||||
|
icon: "none", |
||||
|
}); |
||||
|
} |
||||
|
} |
||||
|
async createSession() { |
||||
|
const session_id = uuidv4(); |
||||
|
return { code: 0, data: { session_id: session_id } }; |
||||
|
} |
||||
|
// 消息上行事件(用户端)
|
||||
|
triggerSendMsg =async (msg, type='text', status= true) => { |
||||
|
console.log("[triggerSendMsg]", msg, wx); |
||||
|
if (!cache.configInfo || !cache.configInfo.session_id) { |
||||
|
await this.queryConfigInfo(); |
||||
|
} |
||||
|
const requestId = generateRequestId(); |
||||
|
const params = { |
||||
|
request_id: requestId, |
||||
|
session_id: cache.configInfo ? cache.configInfo.session_id : 0, |
||||
|
is_msg_status: status |
||||
|
}; |
||||
|
if (type == "text") { |
||||
|
params.content = msg; |
||||
|
} else if (type == "img") { |
||||
|
params.content = ``; |
||||
|
params.realContent = msg; |
||||
|
} |
||||
|
|
||||
|
this.$s.emit("send", params, type); |
||||
|
} |
||||
|
|
||||
|
// 监听token用量和详情事件
|
||||
|
listenTokenStat() { |
||||
|
this.$s.on("token_stat", (data) => { |
||||
|
$e.$emit("token_state_change", data); |
||||
|
if (data.session_id !== cache.session_id) return; // 若新消息不属于当前机器人时,则不做处理
|
||||
|
let loadingMsg = cache.chatsContent.find((el) => el.loading_message); |
||||
|
let loadingText = "思考中"; |
||||
|
if (loadingMsg) { |
||||
|
if (data.procedures && data.procedures.length > 0) { |
||||
|
loadingText = |
||||
|
data.procedures[data.procedures.length - 1].title || "思考中"; |
||||
|
} |
||||
|
let currentList = cache.chatsContent; |
||||
|
currentList.forEach((el) => { |
||||
|
if (el.loading_message) { |
||||
|
el.text = loadingText; |
||||
|
el.record_id = data.record_id; |
||||
|
el.tokens_msg = data; |
||||
|
// 只有标准模式加这个
|
||||
|
if (GLOBAL.webimToken[0].pattern === "standard") { |
||||
|
el.is_final = false; |
||||
|
} |
||||
|
} |
||||
|
}); |
||||
|
$e.$emit("client_msgContentChange", { |
||||
|
chatsContent: cache.chatsContent, |
||||
|
type: MESSAGE_TYPE.ANSWER, |
||||
|
}); |
||||
|
} else { |
||||
|
let findedMsg = cache.chatsContent.find( |
||||
|
(el) => el.record_id === data.record_id |
||||
|
); |
||||
|
if (!findedMsg) return; |
||||
|
findedMsg.tokens_msg = data; |
||||
|
|
||||
|
$e.$emit("client_msgContentChange", { |
||||
|
chatsContent: cache.chatsContent, |
||||
|
type: MESSAGE_TYPE.ANSWER, |
||||
|
}); |
||||
|
} |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
// 组装消息队列数据
|
||||
|
// 问题确认消息:根据request_id关联覆盖(服务端收到问题后的确认消息)
|
||||
|
// 答案消息:倒序遍历插入(服务端答案消息)
|
||||
|
assembleMsgContent(msgList, type) { |
||||
|
console.log("assembleMsgContent", msgList, type); |
||||
|
let newMsg = msgList; |
||||
|
|
||||
|
if (type === MESSAGE_TYPE.QUESTION) { |
||||
|
// 发送的问题消息由前端临时插入消息队列
|
||||
|
cache.chatsContent.push(newMsg); |
||||
|
} else if (type === MESSAGE_TYPE.ANSWER) { |
||||
|
if (cache.chatsContent.length < 1) { |
||||
|
cache.chatsContent.push(newMsg); |
||||
|
} else { |
||||
|
let currentList = cache.chatsContent; |
||||
|
|
||||
|
timeoutTasks[newMsg.request_id] && |
||||
|
clearTimeout(timeoutTasks[newMsg.request_id]); |
||||
|
|
||||
|
if (currentList.length === 2 && newMsg.can_rating) { |
||||
|
currentList[0].transferRobot = true; |
||||
|
} |
||||
|
if (newMsg.transfer && newMsg.loading_message) { |
||||
|
currentList.pop(); |
||||
|
currentList[currentList.length - 1].loading_message = false; |
||||
|
currentList[currentList.length - 1] = { |
||||
|
...newMsg, |
||||
|
...currentList[currentList.length - 1], |
||||
|
transfer: true, |
||||
|
transferRobot: false, |
||||
|
}; |
||||
|
} else { |
||||
|
for (let i = currentList.length - 1; i >= 0; i--) { |
||||
|
const { transfer, quit, transferRobot } = currentList[i]; |
||||
|
let tmp = { |
||||
|
...newMsg, |
||||
|
transfer, |
||||
|
quit, |
||||
|
transferRobot, |
||||
|
}; |
||||
|
// 保留tokens_msg,防止覆盖
|
||||
|
if (currentList[i].tokens_msg) { |
||||
|
tmp = { ...tmp, tokens_msg: currentList[i].tokens_msg }; |
||||
|
} |
||||
|
// 保留thought 放置被覆盖
|
||||
|
if (currentList[i].agent_thought) { |
||||
|
tmp = { ...tmp, agent_thought: currentList[i].agent_thought }; |
||||
|
} |
||||
|
// 保留reference
|
||||
|
if (currentList[i].references) { |
||||
|
tmp = { ...tmp, references: currentList[i].references }; |
||||
|
} |
||||
|
// 答案消息流式输出覆盖(record_id)
|
||||
|
if (newMsg.record_id === currentList[i].record_id) { |
||||
|
currentList[i] = tmp; |
||||
|
break; |
||||
|
} |
||||
|
// 服务端问题消息确认数据,覆盖前端插入的临时问题消息数据(request_id匹配 & 自己发出的问题消息)
|
||||
|
if ( |
||||
|
newMsg.request_id && |
||||
|
newMsg.request_id === currentList[i].request_id && |
||||
|
newMsg.is_from_self |
||||
|
) { |
||||
|
newMsg.is_loading = false; // 服务端确认收到问题消息,则去除”发送中“状态
|
||||
|
currentList[i] = tmp; |
||||
|
// 非人工状态时, 并且用户发送的不是敏感消息。插入临时[正在思考中...]消息
|
||||
|
if (!newMsg.is_evil && !cache.transferInfo.transferStatus) { |
||||
|
currentList.push({ |
||||
|
loading_message: true, |
||||
|
is_from_self: false, |
||||
|
content: "", |
||||
|
from_avatar: cache.configInfo.avatar, |
||||
|
timestamp: Number(currentList[i].timestamp), // 精确到秒
|
||||
|
}); |
||||
|
} |
||||
|
break; |
||||
|
} |
||||
|
// 插入最新答案消息
|
||||
|
if (Number(newMsg.timestamp) >= Number(currentList[i].timestamp)) { |
||||
|
if (currentList[i].loading_message) { |
||||
|
// 删除原来的[正在思考中...]消息
|
||||
|
currentList[currentList.length - 1] = newMsg; |
||||
|
} else { |
||||
|
currentList.splice(i + 1, 0, newMsg); |
||||
|
} |
||||
|
break; |
||||
|
} |
||||
|
if ( |
||||
|
i === 0 && |
||||
|
Number(newMsg.timestamp) < Number(currentList[i].timestamp) |
||||
|
) { |
||||
|
currentList.splice(0, 0, newMsg); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} else if (type === MESSAGE_TYPE.HISTORY) { |
||||
|
let currentList = cache.chatsContent; |
||||
|
// 历史数据打上标签,无需展示”重新生成“和”停止生成“操作
|
||||
|
msgList = msgList.map((r) => { |
||||
|
return { |
||||
|
...r, |
||||
|
is_history: true, |
||||
|
is_final: true, |
||||
|
}; |
||||
|
}); |
||||
|
|
||||
|
if (currentList.length === 0) { |
||||
|
// 若消息队列为空(用户端,初始拉取历史记录,用做判断欢迎页展示场景)
|
||||
|
cache.chatsContent = [].concat(msgList); |
||||
|
} else { |
||||
|
// 若消息队列不为空
|
||||
|
let oldMsgCurrent = currentList[0]; |
||||
|
let newMsgHistory = msgList[msgList.length - 1]; |
||||
|
|
||||
|
// 将历史数据拼装到消息队列中(按照时间戳重排数据)
|
||||
|
if (Number(newMsgHistory.timestamp) < Number(oldMsgCurrent.timestamp)) { |
||||
|
cache.chatsContent = [].concat(msgList).concat(cache.chatsContent); |
||||
|
} else { |
||||
|
msgList.reverse().forEach((msg) => { |
||||
|
for (let i = 0; i < cache.chatsContent.length; i++) { |
||||
|
if (msg.record_id === cache.chatsContent[i].record_id) { |
||||
|
// 重复覆盖
|
||||
|
cache.chatsContent[i] = msg; |
||||
|
break; |
||||
|
} else if ( |
||||
|
Number(msg.timestamp) <= Number(cache.chatsContent[i].timestamp) |
||||
|
) { |
||||
|
cache.chatsContent.splice(i, 0, msg); |
||||
|
break; |
||||
|
} else if ( |
||||
|
i === cache.chatsContent.length - 1 && |
||||
|
Number(msg.timestamp) > Number(cache.chatsContent[i].timestamp) |
||||
|
) { |
||||
|
cache.chatsContent.splice(i + 1, 0, msg); |
||||
|
} |
||||
|
} |
||||
|
}); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 消息去重。同一record_id取最新,同时保留消息最早的时间戳
|
||||
|
cache.chatsContent = arrayUnique( |
||||
|
cache.chatsContent, |
||||
|
"record_id", |
||||
|
"timestamp" |
||||
|
); |
||||
|
|
||||
|
// 消息队列变更通知事件
|
||||
|
$e.$emit("client_msgContentChange", { |
||||
|
chatsContent: cache.chatsContent, |
||||
|
type, |
||||
|
}); |
||||
|
} |
||||
|
// 修改指定msgId的消息内容
|
||||
|
modifyMsgContent(msgId) { |
||||
|
const findedMsg = this.getMsgById(msgId); |
||||
|
|
||||
|
if (findedMsg) { |
||||
|
findedMsg.is_final = true; |
||||
|
findedMsg.content = findedMsg.content.concat( |
||||
|
`<span class="stop-ws">| 已停止生成</span>` |
||||
|
); |
||||
|
|
||||
|
$e.$emit("client_msgContentChange", { |
||||
|
chatsContent: cache.chatsContent, |
||||
|
type: MESSAGE_TYPE.STOP, // ”停止生成“事件
|
||||
|
}); |
||||
|
} |
||||
|
} |
||||
|
// 根据msgId获取消息
|
||||
|
getMsgById(msgId) { |
||||
|
const findedMsg = cache.chatsContent.find((r) => r.record_id === msgId); |
||||
|
return findedMsg; |
||||
|
} |
||||
|
// 根据msgId获取其关联问题消息
|
||||
|
getQmsgById(msgId) { |
||||
|
let findedQmsg = null; |
||||
|
const findedMsg = this.getMsgById(msgId); |
||||
|
|
||||
|
if (findedMsg) { |
||||
|
findedQmsg = cache.chatsContent.find( |
||||
|
(r) => r.record_id === findedMsg.related_record_id |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
return findedQmsg; |
||||
|
} |
||||
|
releaseCache() {} |
||||
|
destroy() { |
||||
|
// be careful to clear the cache to avoid errors
|
||||
|
this.releaseCache(); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
export default ClientData; |
@ -0,0 +1,50 @@ |
|||||
|
import Vue from 'vue'; |
||||
|
|
||||
|
class EventHub { |
||||
|
constructor (vm) { |
||||
|
this.vm = vm; |
||||
|
this.curVm = null; |
||||
|
this.events = {}; |
||||
|
this.eventMapUid = {}; |
||||
|
} |
||||
|
$register (vm) { |
||||
|
this.curVm = vm; |
||||
|
} |
||||
|
setEventMapUid (uid, type) { |
||||
|
if (!this.eventMapUid[uid]) { |
||||
|
this.eventMapUid[uid] = []; |
||||
|
} |
||||
|
this.eventMapUid[uid].push(type); |
||||
|
} |
||||
|
$on (type, fn) { |
||||
|
if (!this.events[type]) { |
||||
|
this.events[type] = []; |
||||
|
} |
||||
|
this.events[type].push(fn); |
||||
|
if (this.curVm instanceof this.vm) { |
||||
|
this.setEventMapUid(this.curVm._uid, type); |
||||
|
} |
||||
|
} |
||||
|
$emit (type, ...args) { |
||||
|
if (this.events[type]) { |
||||
|
this.events[type].forEach(fn => fn(...args)); |
||||
|
} |
||||
|
} |
||||
|
$off (type, fn) { |
||||
|
if (fn && this.events[type]) { |
||||
|
const index = this.events[type].findIndex(f => f === fn); |
||||
|
if (index !== -1) { |
||||
|
this.events[type].splice(index, 1); |
||||
|
} |
||||
|
return; |
||||
|
} |
||||
|
delete this.events[type]; |
||||
|
} |
||||
|
$offAll (uid) { |
||||
|
const curAllTypes = this.eventMapUid[uid] || []; |
||||
|
curAllTypes.forEach(type => this.$off(type)); |
||||
|
delete this.eventMapUid[uid]; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
export default new EventHub(Vue);; |
@ -0,0 +1,12 @@ |
|||||
|
// type: Q-问题,A-答案,H-历史消息,S-停止生成,C-结束会话,T-转接会话,R-参考来源,F-点赞/点踩回执
|
||||
|
export const MESSAGE_TYPE = { |
||||
|
QUESTION: 'Q', |
||||
|
ANSWER: 'A', |
||||
|
HISTORY: 'H', |
||||
|
STOP: 'S', |
||||
|
CLOSE: 'C', |
||||
|
TRANSFER: 'T', |
||||
|
REFERENCE: 'R', |
||||
|
FEEDBACK: 'F', |
||||
|
WORKBENCH_HISTORY: 'WH' |
||||
|
}; |
@ -0,0 +1,8 @@ |
|||||
|
wx.GLOBE = { |
||||
|
webimToken: [], |
||||
|
SOCKET: null, |
||||
|
} |
||||
|
|
||||
|
|
||||
|
|
||||
|
export default wx.GLOBE |
@ -0,0 +1,56 @@ |
|||||
|
/** |
||||
|
* !插入数据 |
||||
|
* @param roomId |
||||
|
* @param sourceId |
||||
|
* @param orderId |
||||
|
* @param type |
||||
|
* @param fileId |
||||
|
* @param txt |
||||
|
* @param time |
||||
|
* @param userId |
||||
|
* @param nickName |
||||
|
* @param sendOrReceive |
||||
|
* @param address |
||||
|
*/ |
||||
|
export const setMsgData = data => { |
||||
|
console.log('setMsgData', data) |
||||
|
const resData = data |
||||
|
const session_id = resData.chatId // 群id
|
||||
|
|
||||
|
let msgData = wx.getStorageSync('imMsgData') || {} |
||||
|
// * 插入群数据
|
||||
|
if (msgData[session_id]) { |
||||
|
if (resData.timestamp > msgData[session_id].timestamp) { |
||||
|
msgData[session_id].timestamp = resData.timestamp |
||||
|
} |
||||
|
msgData[session_id].listMsg.push(resData) |
||||
|
} else { |
||||
|
msgData[session_id] = { |
||||
|
listMsg: [resData], |
||||
|
timestamp: resData.timestamp, |
||||
|
session_id: resData.session_id, |
||||
|
} |
||||
|
} |
||||
|
wx.setStorageSync('imMsgData', msgData) |
||||
|
return resData |
||||
|
} |
||||
|
|
||||
|
|
||||
|
// ! 获取群消息
|
||||
|
export const getHistroyMsg = id => { |
||||
|
let msgData = wx.getStorageSync('imMsgData') || {} |
||||
|
let msgList = [] |
||||
|
if (msgData[id]) { |
||||
|
const data = msgData[id] |
||||
|
// *处理历史消息并按时间排序
|
||||
|
const compare = property => { |
||||
|
return function(a, b) { |
||||
|
var value1 = a[property] |
||||
|
var value2 = b[property] |
||||
|
return value1 - value2 |
||||
|
} |
||||
|
} |
||||
|
msgList = data.listMsg.sort(compare('time')) |
||||
|
} |
||||
|
return { msgList } |
||||
|
} |
@ -0,0 +1,45 @@ |
|||||
|
import GLOBAL from './global'; |
||||
|
class Observer { |
||||
|
constructor() { |
||||
|
this._event = {} |
||||
|
} |
||||
|
$on(eventName, handler) { |
||||
|
if (this._event[eventName]) { |
||||
|
this._event[eventName].push(handler) |
||||
|
} else { |
||||
|
this._event[eventName] = [handler] |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
$emit(eventName) { |
||||
|
let events = this._event[eventName] |
||||
|
let otherArgs = Array.prototype.slice.call(arguments, 1) |
||||
|
let that = this |
||||
|
if (events) { |
||||
|
events.forEach(event => { |
||||
|
event.apply(that, otherArgs) |
||||
|
}) |
||||
|
} |
||||
|
} |
||||
|
$off(eventName, handler) { |
||||
|
let events = this._event[eventName] |
||||
|
if (events) { |
||||
|
this._event[eventName] = events.filter(event => { |
||||
|
return event !== handler |
||||
|
}) |
||||
|
} |
||||
|
} |
||||
|
$once(eventName, handler) { |
||||
|
let that = this |
||||
|
function func() { |
||||
|
let args = Array.prototype.slice.call(arguments, 0) |
||||
|
handler.apply(that, args) |
||||
|
this.off(eventName, func) |
||||
|
} |
||||
|
this.on(eventName, func) |
||||
|
} |
||||
|
} |
||||
|
if (!GLOBAL.observer) { |
||||
|
GLOBAL.observer = new Observer() |
||||
|
} |
||||
|
export default GLOBAL.observer |
@ -0,0 +1,283 @@ |
|||||
|
import Vue from "vue"; |
||||
|
import GLOBAL_OBJ from "./global"; |
||||
|
import "./EventHub"; |
||||
|
import { setMsgData } from "./message"; |
||||
|
// 心跳间隔
|
||||
|
const HEART_BEAT_TIME = 20000; |
||||
|
// 心跳最大失败次数(超过此次数重连)
|
||||
|
const HEART_BEAT_FAIL_NUM = 1; |
||||
|
// 重连间隔
|
||||
|
const RECONNECT_TIME = 1000; |
||||
|
|
||||
|
export default class Socket { |
||||
|
constructor(option) { |
||||
|
this.socket = null; |
||||
|
this._options = option; |
||||
|
this.timeoutObj = null; |
||||
|
this.robotObj = {}; |
||||
|
this.selfCloseStatus = false |
||||
|
this.reconnectLock = false |
||||
|
console.log("Socket init", GLOBAL_OBJ); |
||||
|
} |
||||
|
getToken() { |
||||
|
return new Promise((resolve, reject) => { |
||||
|
uni.request({ |
||||
|
method: "GET", |
||||
|
dataType: "json", |
||||
|
// url: `http://192.168.124.118:8083/xcx/framework/agent/${this._options.agentId}`,
|
||||
|
url: `https://des.js-dyyj.com/getDemoToken?id=${this._options.agentId}`, |
||||
|
success: (res) => { |
||||
|
console.log("请求token成功", res); |
||||
|
resolve(res); |
||||
|
}, |
||||
|
fail: (err) => { |
||||
|
wx.showToast({ |
||||
|
title: "创建失败", |
||||
|
icon: "none", |
||||
|
}); |
||||
|
console.log("请求token失败", err); |
||||
|
reject() |
||||
|
}, |
||||
|
}); |
||||
|
}); |
||||
|
} |
||||
|
async init() { |
||||
|
return this.createSocket(); |
||||
|
} |
||||
|
async createSocket(options) { |
||||
|
return new Promise(async (resolve, reject) => { |
||||
|
const origin = "wss://wss.lke.cloud.tencent.com"; |
||||
|
let path = "/v1/qbot/chat/conn/"; |
||||
|
let initSocket = 1; |
||||
|
let mainToken |
||||
|
const res = await this.getToken(); |
||||
|
if ( |
||||
|
res && |
||||
|
res.data && |
||||
|
res.data.apiResponse && |
||||
|
res.data.apiResponse.Token |
||||
|
) { |
||||
|
mainToken = res.data.apiResponse.Token; |
||||
|
} |
||||
|
// 机器人信息
|
||||
|
this.robotObj = res.data.requestInfo; |
||||
|
console.log("获取token:", mainToken); |
||||
|
|
||||
|
// 建立连接
|
||||
|
const socket = wx.connectSocket({ |
||||
|
url: `${origin}${path}?EIO=4&transport=websocket`, |
||||
|
success: (e) => { |
||||
|
console.log("创建链接成功", e, socket); |
||||
|
}, |
||||
|
complete: (e) => { |
||||
|
console.log("socket - complete", e); |
||||
|
|
||||
|
}, |
||||
|
}); |
||||
|
this.socket = socket; |
||||
|
GLOBAL_OBJ.SOCKET = this; |
||||
|
|
||||
|
let systemEventEmit = (eventName, data) => { |
||||
|
Vue.prototype.$eventHub.$emit(eventName, data); |
||||
|
}; |
||||
|
|
||||
|
socket.onOpen((e) => { |
||||
|
// 监听发送
|
||||
|
if (initSocket === 1) { |
||||
|
const token = mainToken || ""; |
||||
|
if (token) { |
||||
|
// cb({ token: token });
|
||||
|
this.send({ |
||||
|
data: |
||||
|
"40" + |
||||
|
JSON.stringify({ |
||||
|
token: token, |
||||
|
}), |
||||
|
}); |
||||
|
} else { |
||||
|
// cb({ token: '' });
|
||||
|
this.send({ |
||||
|
data: JSON.stringify({ |
||||
|
token: "", |
||||
|
}), |
||||
|
}); |
||||
|
} |
||||
|
initSocket++; |
||||
|
} else { |
||||
|
const token = mainToken || ""; |
||||
|
// cb({ token: token });
|
||||
|
this.send({ |
||||
|
data: JSON.stringify({ |
||||
|
token: token, |
||||
|
}), |
||||
|
}); |
||||
|
initSocket++; |
||||
|
} |
||||
|
|
||||
|
options && options.complete && options.complete() |
||||
|
}); |
||||
|
|
||||
|
socket.onMessage((e) => { |
||||
|
// console.log("socket.onMessage", e);
|
||||
|
const { data } = e; |
||||
|
if (data == 2) { |
||||
|
// 触发浪涌
|
||||
|
this.send({ |
||||
|
data: 3, |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
const params = this.getMsgData(data); |
||||
|
const { type } = params ?? {}; |
||||
|
this.on(type, params); |
||||
|
if (params && params.type === "reply" && !params.is_from_self) { |
||||
|
// 回复消息
|
||||
|
this._options.onMessage && this._options.onMessage(params); |
||||
|
} |
||||
|
const num = "" + data.substring(0, 2); |
||||
|
if (num == "40") { |
||||
|
resolve(); |
||||
|
} |
||||
|
|
||||
|
this.createInter(); |
||||
|
}); |
||||
|
// 失败
|
||||
|
socket.onError((e) => { |
||||
|
console.log("websocket error 长链接报错", e); |
||||
|
// 失败重建
|
||||
|
this.doConnectTimeout(); |
||||
|
//
|
||||
|
}); |
||||
|
// 关闭
|
||||
|
socket.onClose((e) => { |
||||
|
console.log("websocket close 长链接关闭", e); |
||||
|
this.connectSocketTimeOut && clearTimeout(this.connectSocketTimeOut); |
||||
|
//非自动关闭重连
|
||||
|
if (e.code == 1006) { |
||||
|
this.doConnectTimeout(); |
||||
|
} |
||||
|
}); |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
doConnectTimeout() { |
||||
|
// 重连一次
|
||||
|
if (!this.reconnectLock) { |
||||
|
this.reconnectLock = true; |
||||
|
this.connectSocketTimeOut = setTimeout(() => { |
||||
|
this.createSocket({ |
||||
|
complete: function (res) { |
||||
|
this.reconnectLock = false; |
||||
|
}, |
||||
|
}); |
||||
|
}, RECONNECT_TIME); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
onConnect(e) { |
||||
|
console.log("websocket connect", e); |
||||
|
} |
||||
|
|
||||
|
send(e, t) { |
||||
|
this.socket && this.socket.send(e); |
||||
|
this.createInter(); |
||||
|
} |
||||
|
|
||||
|
getMsgData(e) { |
||||
|
if (e && typeof e == "string") { |
||||
|
const status = e.indexOf("42"); |
||||
|
const index = e.indexOf("["); |
||||
|
if (status > -1 && index > -1) { |
||||
|
const txt = e.substring(index); |
||||
|
const item = JSON.parse(txt); |
||||
|
const [type, obj] = item; |
||||
|
const payload = obj.payload ? obj.payload : {}; |
||||
|
const params = { |
||||
|
chatId: this._options.agentId, |
||||
|
type, |
||||
|
contentType: "text", //默认文字
|
||||
|
timestamp: new Date().getTime(), |
||||
|
...payload, |
||||
|
}; |
||||
|
// 缓存聊天记录
|
||||
|
return params; |
||||
|
} |
||||
|
} |
||||
|
return null; |
||||
|
} |
||||
|
|
||||
|
emit(type, params, contentType) { |
||||
|
console.log("emit", type, params); |
||||
|
const data = { |
||||
|
payload: params, |
||||
|
}; |
||||
|
switch (type) { |
||||
|
case "send": |
||||
|
// 发送消息
|
||||
|
const socketParams = { data: "42" + JSON.stringify(["send", data]) }; |
||||
|
this.send(socketParams, contentType); |
||||
|
|
||||
|
console.log(params.request_id + '发送内容', data) |
||||
|
if (!params.is_msg_status) { |
||||
|
// 不写入缓存中
|
||||
|
return; |
||||
|
} |
||||
|
const msgParams = { |
||||
|
chatId: this._options.agentId, |
||||
|
contentType, |
||||
|
type, |
||||
|
timestamp: new Date().getTime(), |
||||
|
...params, |
||||
|
content: params.realContent ? params.realContent : params.content, |
||||
|
}; |
||||
|
msgParams && setMsgData(msgParams); |
||||
|
|
||||
|
break; |
||||
|
} |
||||
|
} |
||||
|
// 监听
|
||||
|
on(type, params) { |
||||
|
switch (type) { |
||||
|
case "reply": |
||||
|
// 回复结束
|
||||
|
const tmpParams = { |
||||
|
...params, |
||||
|
name: this.robotObj.name, |
||||
|
headImage: this.robotObj.headImage, |
||||
|
}; |
||||
|
if ( |
||||
|
params && |
||||
|
params.type === "reply" && |
||||
|
!params.is_from_self && |
||||
|
params.can_rating |
||||
|
) { |
||||
|
// 回复消息
|
||||
|
this._options.onMessage && this._options.onMessage(tmpParams); |
||||
|
} |
||||
|
|
||||
|
if (!params.is_from_self && params.is_final) { |
||||
|
// 回复结束 写入缓存
|
||||
|
setMsgData(tmpParams); |
||||
|
} |
||||
|
// 回复
|
||||
|
break; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 关闭socket
|
||||
|
destroy() { |
||||
|
if (this.socket && this.socket.readyState == 1) { |
||||
|
this.socket && this.socket.close(); |
||||
|
this.socket = null; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
createInter() { |
||||
|
if (this.timeoutObj) { |
||||
|
clearTimeout(this.timeoutObj); |
||||
|
} |
||||
|
this.timeoutObj = setTimeout(() => { |
||||
|
this.socket && this.socket.send && this.socket.send({ data: 3 }); |
||||
|
}, HEART_BEAT_TIME); |
||||
|
} |
||||
|
} |
@ -0,0 +1,119 @@ |
|||||
|
/** |
||||
|
* 获取 location hash 参数 |
||||
|
* @param {string} variable 参数名 |
||||
|
* @returns {string} 参数值 |
||||
|
*/ |
||||
|
export const getQueryVariable = (variable) => { |
||||
|
const query = window.location.hash.split('?'); |
||||
|
|
||||
|
if (query.length < 2) { |
||||
|
return ''; |
||||
|
} |
||||
|
|
||||
|
const vars = query[1].split('&'); |
||||
|
|
||||
|
// eslint-disable-next-line @typescript-eslint/prefer-for-of
|
||||
|
for (let i = 0; i < vars.length; i++) { |
||||
|
const pair = vars[i].split('='); |
||||
|
if (pair[0] === variable) { |
||||
|
return decodeURI(pair[1]); |
||||
|
} |
||||
|
} |
||||
|
return ''; |
||||
|
}; |
||||
|
|
||||
|
/** |
||||
|
* 滚动至指定dom底部 |
||||
|
*/ |
||||
|
export const scrollToBottom = (sDom, sTop) => { |
||||
|
if (!sDom) return; |
||||
|
sDom.scrollTo({ |
||||
|
top: sTop |
||||
|
// behavior: 'smooth'
|
||||
|
}); |
||||
|
}; |
||||
|
|
||||
|
/** |
||||
|
* 数组去重 |
||||
|
*/ |
||||
|
export const arrayUnique = (arr, replaceKey, holdKey) => { |
||||
|
let temp = {}; |
||||
|
|
||||
|
return arr.reduce((prev, cur) => { |
||||
|
if (!temp[cur[replaceKey]]) { |
||||
|
temp[cur[replaceKey]] = {index: prev.length}; |
||||
|
prev.push(cur); |
||||
|
} else { |
||||
|
const oldItem = temp[cur[replaceKey]]; |
||||
|
cur[holdKey] = oldItem[holdKey]; |
||||
|
prev.splice(oldItem['index'], 1, cur); |
||||
|
} |
||||
|
|
||||
|
return prev; |
||||
|
}, []); |
||||
|
}; |
||||
|
|
||||
|
export const generateRequestId = (length = 10) => { |
||||
|
const data = |
||||
|
['0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'A', 'B', 'C', 'D', 'E', 'F', |
||||
|
'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', |
||||
|
'Z', 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', |
||||
|
's', 't', 'u', 'v', 'w', 'x', 'y', 'z']; |
||||
|
let nums = ''; |
||||
|
for (let i = 0; i < length; i++) { |
||||
|
const r = parseInt(Math.random() * 61, 10); |
||||
|
nums += data[r]; |
||||
|
} |
||||
|
return nums + '-' + parseInt(Math.random() * 10000000000, 10); |
||||
|
}; |
||||
|
|
||||
|
function escapeHtml (str) { |
||||
|
return str.replace(/[&<>"'/]/g, function (match) { |
||||
|
return { |
||||
|
'&': '&', |
||||
|
'<': '<', |
||||
|
'>': '>', |
||||
|
'"': '"', |
||||
|
"'": ''', |
||||
|
'/': '/' |
||||
|
}[match]; |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
|
||||
|
export const splitTextForTTS = (text, maxLength = 30) => { |
||||
|
// 定义优先分割的标点符号
|
||||
|
const punctuation = ['。', ',', ';', '?', '!', ',', ';', '?', '!', '、']; |
||||
|
let segments = []; |
||||
|
let segment = ''; |
||||
|
let tempSegment = ''; |
||||
|
|
||||
|
for (let i = 0; i < text.length; i++) { |
||||
|
tempSegment += text[i]; // 如果超过最大长度,则尝试在上一个标点符号处分割
|
||||
|
|
||||
|
if (tempSegment.length > maxLength) { |
||||
|
let lastPunctuationIndex = -1; |
||||
|
for (let j = tempSegment.length - 1; j >= 0; j--) { |
||||
|
if (punctuation.includes(tempSegment[j])) { |
||||
|
lastPunctuationIndex = j; |
||||
|
break; |
||||
|
} |
||||
|
} // 如果找到标点符号,则在标点符号后分割
|
||||
|
|
||||
|
if (lastPunctuationIndex !== -1) { |
||||
|
segments.push(tempSegment.slice(0, lastPunctuationIndex + 1).trim()); |
||||
|
tempSegment = tempSegment.slice(lastPunctuationIndex + 1).trim(); |
||||
|
} else { |
||||
|
// 如果没有找到标点符号,则在最大长度处分割
|
||||
|
segments.push(tempSegment.slice(0, maxLength).trim()); |
||||
|
tempSegment = tempSegment.slice(maxLength).trim(); |
||||
|
} |
||||
|
} |
||||
|
} // 添加最后一个段落
|
||||
|
|
||||
|
if (tempSegment.length > 0) { |
||||
|
segments.push(tempSegment.trim()); |
||||
|
} |
||||
|
|
||||
|
return segments; |
||||
|
} |
@ -1,320 +1,299 @@ |
|||||
<template> |
<template> |
||||
<view class="waterfall-layout"> |
<view class="waterfall-layout"> |
||||
<view class="waterfall-container"> |
<view class="waterfall-container"> |
||||
<!-- 左列 --> |
<!-- 左列 --> |
||||
<view class="column"> |
<view class="column"> |
||||
<view |
<view v-for="(item, index) in leftItems" :key="item.id || index" class="waterfall-item" |
||||
v-for="(item, index) in leftItems" |
@click="handleItemClick(item)"> |
||||
:key="item.id || index" |
<image v-if="item.image" :src="showImg(item.image)" class="item-image" mode="aspectFill" /> |
||||
class="waterfall-item" |
<view class="item-content"> |
||||
@click="handleItemClick(item)" |
<text v-if="item.title" class="item-title">{{ item.title }}</text> |
||||
> |
<view class="item-footer"> |
||||
<image |
<view class="user-info"> |
||||
v-if="item.image" |
<image |
||||
:src="showImg(item.image)" |
src="https://images.unsplash.com/photo-1535713875002-d1d0cf377fde?auto=format&fit=crop&w=100" |
||||
class="item-image" |
class="user-avatar" mode="aspectFill" /> |
||||
mode="aspectFill" |
<text class="username">风景之旅</text> |
||||
/> |
</view> |
||||
<view class="item-content"> |
<view class="like-info"> |
||||
<text v-if="item.title" class="item-title">{{ item.title }}</text> |
<image :src="showImg('/uploads/20250731/0260884d7a44a483885a026da524e0b8.png')" |
||||
<view class="item-footer"> |
style="height: 22rpx;width: 25rpx;"></image> |
||||
<view class="user-info"> |
<text class="like-count">100</text> |
||||
<image |
</view> |
||||
src="https://images.unsplash.com/photo-1535713875002-d1d0cf377fde?auto=format&fit=crop&w=100" |
</view> |
||||
class="user-avatar" |
</view> |
||||
mode="aspectFill" |
</view> |
||||
/> |
</view> |
||||
<text class="username">风景之旅</text> |
|
||||
</view> |
|
||||
<view class="like-info"> |
|
||||
<image :src="showImg('/uploads/20250731/0260884d7a44a483885a026da524e0b8.png')" style="height: 22rpx;width: 25rpx;"></image> |
|
||||
<text class="like-count">100</text> |
|
||||
</view> |
|
||||
</view> |
|
||||
</view> |
|
||||
</view> |
|
||||
</view> |
|
||||
|
|
||||
<!-- 右列 --> |
<!-- 右列 --> |
||||
<view class="column"> |
<view class="column"> |
||||
<view |
<view v-for="(item, index) in rightItems" :key="item.id || index" class="waterfall-item" |
||||
v-for="(item, index) in rightItems" |
@click="handleItemClick(item)"> |
||||
:key="item.id || index" |
<image v-if="item.image" :src="showImg(item.image)" class="item-image" mode="aspectFill" /> |
||||
class="waterfall-item" |
<view class="item-content"> |
||||
@click="handleItemClick(item)" |
<text v-if="item.title" class="item-title">{{ item.title }}</text> |
||||
|
<view class="item-footer"> |
||||
> |
<view class="user-info"> |
||||
<image |
<image |
||||
v-if="item.image" |
src="https://images.unsplash.com/photo-1535713875002-d1d0cf377fde?auto=format&fit=crop&w=100" |
||||
:src="showImg(item.image)" |
class="user-avatar" mode="aspectFill" /> |
||||
class="item-image" |
<text class="username">风景之旅</text> |
||||
mode="aspectFill" |
</view> |
||||
/> |
<view class="like-info"> |
||||
<view class="item-content"> |
<image :src="showImg('/uploads/20250731/0260884d7a44a483885a026da524e0b8.png')" |
||||
<text v-if="item.title" class="item-title">{{ item.title }}</text> |
style="height: 22rpx;width: 25rpx;"></image> |
||||
<view class="item-footer"> |
<text class="like-count">120</text> |
||||
<view class="user-info"> |
</view> |
||||
<image |
</view> |
||||
src="https://images.unsplash.com/photo-1535713875002-d1d0cf377fde?auto=format&fit=crop&w=100" |
</view> |
||||
class="user-avatar" |
</view> |
||||
mode="aspectFill" |
</view> |
||||
/> |
</view> |
||||
<text class="username">风景之旅</text> |
</view> |
||||
</view> |
|
||||
<view class="like-info"> |
|
||||
<image :src="showImg('/uploads/20250731/0260884d7a44a483885a026da524e0b8.png')" style="height: 22rpx;width: 25rpx;"></image> |
|
||||
<text class="like-count">120</text> |
|
||||
</view> |
|
||||
</view> |
|
||||
</view> |
|
||||
</view> |
|
||||
</view> |
|
||||
</view> |
|
||||
</view> |
|
||||
</template> |
</template> |
||||
|
|
||||
<script> |
<script> |
||||
export default { |
export default { |
||||
name: "WaterfallLayout", |
name: "WaterfallLayout", |
||||
props: { |
props: { |
||||
// 数据源 |
// 数据源 |
||||
items: { |
items: { |
||||
type: Array, |
type: Array, |
||||
default: () => [], |
default: () => [], |
||||
}, |
}, |
||||
// 列数(固定为2列) |
// 列数(固定为2列) |
||||
columnCount: { |
columnCount: { |
||||
type: Number, |
type: Number, |
||||
default: 2, |
default: 2, |
||||
}, |
}, |
||||
// 列间距(rpx) |
// 列间距(rpx) |
||||
columnGap: { |
columnGap: { |
||||
type: Number, |
type: Number, |
||||
default: 16, |
default: 16, |
||||
}, |
}, |
||||
// 项目间距(rpx) |
// 项目间距(rpx) |
||||
itemGap: { |
itemGap: { |
||||
type: Number, |
type: Number, |
||||
default: 16, |
default: 16, |
||||
}, |
}, |
||||
}, |
}, |
||||
data() { |
data() { |
||||
return { |
return { |
||||
leftItems: [], |
leftItems: [], |
||||
rightItems: [], |
rightItems: [], |
||||
}; |
}; |
||||
}, |
}, |
||||
watch: { |
watch: { |
||||
items: { |
items: { |
||||
handler(newItems) { |
handler(newItems) { |
||||
this.calculateLayout(newItems); |
this.calculateLayout(newItems); |
||||
}, |
}, |
||||
immediate: true, |
immediate: true, |
||||
deep: true, |
deep: true, |
||||
}, |
}, |
||||
}, |
}, |
||||
mounted() { |
mounted() { |
||||
this.calculateLayout(this.items); |
this.calculateLayout(this.items); |
||||
}, |
}, |
||||
methods: { |
methods: { |
||||
// 获取列的实际高度(通过DOM查询) |
// 获取列的实际高度(通过DOM查询) |
||||
getColumnHeight(columnRef) { |
getColumnHeight(columnRef) { |
||||
if (!columnRef) return 0; |
if (!columnRef) return 0; |
||||
const query = uni.createSelectorQuery().in(this); |
const query = uni.createSelectorQuery().in(this); |
||||
return new Promise((resolve) => { |
return new Promise((resolve) => { |
||||
query.select(columnRef).boundingClientRect((data) => { |
query.select(columnRef).boundingClientRect((data) => { |
||||
resolve(data ? data.height : 0); |
resolve(data ? data.height : 0); |
||||
}).exec(); |
}).exec(); |
||||
}); |
}); |
||||
}, |
}, |
||||
|
|
||||
// 计算布局 |
// 计算布局 |
||||
calculateLayout(items) { |
calculateLayout(items) { |
||||
if (!items || !items.length) { |
if (!items || !items.length) { |
||||
this.leftItems = []; |
this.leftItems = []; |
||||
this.rightItems = []; |
this.rightItems = []; |
||||
return; |
return; |
||||
} |
} |
||||
|
|
||||
// 清空现有数据 |
// 清空现有数据 |
||||
this.leftItems = []; |
this.leftItems = []; |
||||
this.rightItems = []; |
this.rightItems = []; |
||||
|
|
||||
// 逐个添加项目 |
// 逐个添加项目 |
||||
for (let i = 0; i < items.length; i++) { |
for (let i = 0; i < items.length; i++) { |
||||
this.addItem(items[i]); |
this.addItem(items[i]); |
||||
} |
} |
||||
}, |
}, |
||||
|
|
||||
// 添加单个项目到合适的列 |
// 添加单个项目到合适的列 |
||||
addItem(item) { |
addItem(item) { |
||||
// 简单的交替分配逻辑:比较两列的项目数量 |
// 简单的交替分配逻辑:比较两列的项目数量 |
||||
if (this.leftItems.length <= this.rightItems.length) { |
if (this.leftItems.length <= this.rightItems.length) { |
||||
this.leftItems.push(item); |
this.leftItems.push(item); |
||||
} else { |
} else { |
||||
this.rightItems.push(item); |
this.rightItems.push(item); |
||||
} |
} |
||||
}, |
}, |
||||
|
|
||||
// 清空所有项目 |
// 清空所有项目 |
||||
clearItems() { |
clearItems() { |
||||
this.leftItems = []; |
this.leftItems = []; |
||||
this.rightItems = []; |
this.rightItems = []; |
||||
this.$emit("items-cleared"); |
this.$emit("items-cleared"); |
||||
}, |
}, |
||||
|
|
||||
// 处理项目点击 |
// 处理项目点击 |
||||
handleItemClick(item) { |
handleItemClick(item) { |
||||
this.$emit("item-click", item); |
this.$emit("item-click", item); |
||||
}, |
}, |
||||
|
|
||||
// 获取所有项目 |
// 获取所有项目 |
||||
getAllItems() { |
getAllItems() { |
||||
return [...this.leftItems, ...this.rightItems]; |
return [...this.leftItems, ...this.rightItems]; |
||||
}, |
}, |
||||
|
|
||||
// 移除项目 |
// 移除项目 |
||||
removeItem(itemId) { |
removeItem(itemId) { |
||||
// 从左列移除 |
// 从左列移除 |
||||
let index = this.leftItems.findIndex(item => item.id === itemId); |
let index = this.leftItems.findIndex(item => item.id === itemId); |
||||
if (index !== -1) { |
if (index !== -1) { |
||||
this.leftItems.splice(index, 1); |
this.leftItems.splice(index, 1); |
||||
this.$emit("item-removed", itemId); |
this.$emit("item-removed", itemId); |
||||
return; |
return; |
||||
} |
} |
||||
|
|
||||
// 从右列移除 |
// 从右列移除 |
||||
index = this.rightItems.findIndex(item => item.id === itemId); |
index = this.rightItems.findIndex(item => item.id === itemId); |
||||
if (index !== -1) { |
if (index !== -1) { |
||||
this.rightItems.splice(index, 1); |
this.rightItems.splice(index, 1); |
||||
this.$emit("item-removed", itemId); |
this.$emit("item-removed", itemId); |
||||
} |
} |
||||
}, |
}, |
||||
}, |
}, |
||||
}; |
}; |
||||
</script> |
</script> |
||||
|
|
||||
<style scoped> |
<style scoped> |
||||
.waterfall-layout { |
.waterfall-layout { |
||||
width: 100%; |
width: 100%; |
||||
box-sizing: border-box; |
box-sizing: border-box; |
||||
} |
} |
||||
|
|
||||
.waterfall-container { |
.waterfall-container { |
||||
display: flex; |
display: flex; |
||||
gap: 16rpx; |
gap: 16rpx; |
||||
padding: 0 20rpx; |
padding: 0 20rpx; |
||||
box-sizing: border-box; |
box-sizing: border-box; |
||||
} |
} |
||||
|
|
||||
.column { |
.column { |
||||
flex: 1; |
flex: 1; |
||||
display: flex; |
display: flex; |
||||
flex-direction: column; |
flex-direction: column; |
||||
gap: 16rpx; |
gap: 16rpx; |
||||
} |
} |
||||
|
|
||||
.waterfall-item { |
.waterfall-item { |
||||
box-sizing: border-box; |
box-sizing: border-box; |
||||
border-radius: 12rpx; |
border-radius: 12rpx; |
||||
background: #fff; |
background: #fff; |
||||
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.08); |
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.08); |
||||
overflow: hidden; |
overflow: hidden; |
||||
transition: transform 0.2s ease; |
transition: transform 0.2s ease; |
||||
} |
} |
||||
|
|
||||
.waterfall-item:active { |
.waterfall-item:active { |
||||
transform: scale(0.98); |
transform: scale(0.98); |
||||
} |
} |
||||
|
|
||||
.item-image { |
.item-image { |
||||
width: 100%; |
width: 100%; |
||||
height: 476rpx; |
height: 476rpx; |
||||
object-fit: cover; |
object-fit: cover; |
||||
} |
} |
||||
|
|
||||
.item-content { |
.item-content { |
||||
padding: 16rpx; |
padding: 16rpx; |
||||
} |
} |
||||
|
|
||||
.item-title { |
.item-title { |
||||
font-size: 28rpx; |
font-size: 28rpx; |
||||
font-weight: 600; |
font-weight: 600; |
||||
color: #333; |
color: #333; |
||||
line-height: 1.3; |
line-height: 1.3; |
||||
margin-bottom: 12rpx; |
margin-bottom: 12rpx; |
||||
display: -webkit-box; |
display: -webkit-box; |
||||
-webkit-box-orient: vertical; |
-webkit-box-orient: vertical; |
||||
-webkit-line-clamp: 2; |
-webkit-line-clamp: 2; |
||||
overflow: hidden; |
overflow: hidden; |
||||
text-overflow: ellipsis; |
text-overflow: ellipsis; |
||||
} |
} |
||||
|
|
||||
.item-desc { |
.item-desc { |
||||
font-size: 24rpx; |
font-size: 24rpx; |
||||
color: #666; |
color: #666; |
||||
line-height: 1.4; |
line-height: 1.4; |
||||
margin-bottom: 16rpx; |
margin-bottom: 16rpx; |
||||
display: -webkit-box; |
display: -webkit-box; |
||||
-webkit-box-orient: vertical; |
-webkit-box-orient: vertical; |
||||
-webkit-line-clamp: 2; |
-webkit-line-clamp: 2; |
||||
overflow: hidden; |
overflow: hidden; |
||||
text-overflow: ellipsis; |
text-overflow: ellipsis; |
||||
} |
} |
||||
|
|
||||
.item-tags { |
.item-tags { |
||||
display: flex; |
display: flex; |
||||
flex-wrap: wrap; |
flex-wrap: wrap; |
||||
gap: 8rpx; |
gap: 8rpx; |
||||
margin-bottom: 16rpx; |
margin-bottom: 16rpx; |
||||
} |
} |
||||
|
|
||||
.tag { |
.tag { |
||||
padding: 4rpx 12rpx; |
padding: 4rpx 12rpx; |
||||
background: #f5f5f5; |
background: #f5f5f5; |
||||
color: #666; |
color: #666; |
||||
font-size: 20rpx; |
font-size: 20rpx; |
||||
border-radius: 12rpx; |
border-radius: 12rpx; |
||||
white-space: nowrap; |
white-space: nowrap; |
||||
} |
} |
||||
|
|
||||
.item-footer { |
.item-footer { |
||||
display: flex; |
display: flex; |
||||
justify-content: space-between; |
justify-content: space-between; |
||||
align-items: center; |
align-items: center; |
||||
margin-top: 16rpx; |
margin-top: 16rpx; |
||||
} |
} |
||||
|
|
||||
.user-info { |
.user-info { |
||||
display: flex; |
display: flex; |
||||
align-items: center; |
align-items: center; |
||||
gap: 12rpx; |
gap: 12rpx; |
||||
} |
} |
||||
|
|
||||
.user-avatar { |
.user-avatar { |
||||
width: 32rpx; |
width: 32rpx; |
||||
height: 32rpx; |
height: 32rpx; |
||||
border-radius: 50%; |
border-radius: 50%; |
||||
} |
} |
||||
|
|
||||
.username { |
.username { |
||||
font-size: 22rpx; |
font-size: 22rpx; |
||||
color: #666; |
color: #666; |
||||
} |
} |
||||
|
|
||||
.like-info { |
.like-info { |
||||
display: flex; |
display: flex; |
||||
align-items: center; |
align-items: center; |
||||
gap: 6rpx; |
gap: 6rpx; |
||||
} |
} |
||||
|
|
||||
.like-icon { |
.like-icon { |
||||
font-size: 24rpx; |
font-size: 24rpx; |
||||
color: #ff6b6b; |
color: #ff6b6b; |
||||
} |
} |
||||
|
|
||||
.like-count { |
.like-count { |
||||
font-size: 22rpx; |
font-size: 22rpx; |
||||
color: #666; |
color: #666; |
||||
} |
} |
||||
</style> |
</style> |
@ -1,8 +1,45 @@ |
|||||
<template> |
<template> |
||||
|
<view class="content"> |
||||
|
<GPT :agentId="agentId"></GPT> |
||||
|
</view> |
||||
</template> |
</template> |
||||
|
|
||||
<script> |
<script> |
||||
|
import GPT from '@/components/GPT/index.vue' |
||||
|
export default { |
||||
|
components: { |
||||
|
GPT, |
||||
|
}, |
||||
|
|
||||
|
data() { |
||||
|
return { |
||||
|
agentId:1 |
||||
|
} |
||||
|
}, |
||||
|
onLoad(option) { |
||||
|
const app = getApp(); |
||||
|
const bgMusic = app.globalData.bgMusic; |
||||
|
bgMusic.pause(); |
||||
|
if(option){ |
||||
|
this.agentId = option.id |
||||
|
} |
||||
|
}, |
||||
|
onLaunch: options => { |
||||
|
this.options = options |
||||
|
}, |
||||
|
onShow() { |
||||
|
|
||||
|
console.log('【init message connect type------>】', ); |
||||
|
}, |
||||
|
methods: { |
||||
|
}, |
||||
|
} |
||||
</script> |
</script> |
||||
|
|
||||
<style> |
<style scoped lang="scss"> |
||||
|
.content { |
||||
|
height: 100vh; |
||||
|
} |
||||
|
|
||||
</style> |
</style> |
||||
|
|
After Width: | Height: | Size: 1.8 KiB |
After Width: | Height: | Size: 1.8 KiB |
After Width: | Height: | Size: 4.5 KiB |
After Width: | Height: | Size: 5.7 KiB |
After Width: | Height: | Size: 8.6 KiB |
After Width: | Height: | Size: 10 KiB |
After Width: | Height: | Size: 1.8 KiB |