加入数字人头像
This commit is contained in:
@@ -2,7 +2,26 @@
|
||||
<div class="chat-popup">
|
||||
<!-- 导航栏 -->
|
||||
<div class="navbar">
|
||||
<div class="nav-title">智水AI辅导员</div>
|
||||
<div class="nav-left">
|
||||
<!-- 数字人头像 -->
|
||||
<div class="digital-avatar" :class="{ speaking: isAISpeaking, thinking: isAIThinking }">
|
||||
<div class="avatar-face">
|
||||
<div class="avatar-eyes">
|
||||
<div class="eye left-eye" :class="{ blink: isBlinking }"
|
||||
:style="{ transform: `translate(${eyeOffsetX}px, ${eyeOffsetY}px)` }">
|
||||
<div class="pupil"></div>
|
||||
</div>
|
||||
<div class="eye right-eye" :class="{ blink: isBlinking }"
|
||||
:style="{ transform: `translate(${eyeOffsetX}px, ${eyeOffsetY}px)` }">
|
||||
<div class="pupil"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="avatar-mouth" :class="{ talking: isAISpeaking }"></div>
|
||||
</div>
|
||||
<div class="avatar-glow"></div>
|
||||
</div>
|
||||
<div class="nav-title">智水AI辅导员</div>
|
||||
</div>
|
||||
<div class="nav-close" @click="closeChat">×</div>
|
||||
</div>
|
||||
|
||||
@@ -185,6 +204,20 @@ export default {
|
||||
|
||||
// 请求监控
|
||||
activeRequests: new Map(), // 存储活跃请求的信息
|
||||
|
||||
// 数字人相关状态
|
||||
isAISpeaking: false, // AI是否在说话
|
||||
isAIThinking: false, // AI是否在思考
|
||||
isBlinking: false, // 是否在眨眼
|
||||
blinkTimer: null, // 眨眼定时器
|
||||
speakingTimer: null, // 说话动画定时器
|
||||
|
||||
// 眼睛跟踪鼠标
|
||||
mouseX: 0, // 鼠标X坐标
|
||||
mouseY: 0, // 鼠标Y坐标
|
||||
eyeOffsetX: 0, // 眼睛X偏移
|
||||
eyeOffsetY: 0, // 眼睛Y偏移
|
||||
isMouseTracking: true, // 是否启用鼠标跟踪
|
||||
requestMonitorInterval: null,
|
||||
maxRequestDuration: 60000, // 最大请求持续时间60秒
|
||||
|
||||
@@ -260,6 +293,12 @@ export default {
|
||||
// 启动请求监控
|
||||
this.startRequestMonitoring()
|
||||
|
||||
// 启动数字人自动眨眼
|
||||
this.startAutoBlinking()
|
||||
|
||||
// 启动鼠标跟踪
|
||||
this.enableMouseTracking()
|
||||
|
||||
// 确保DOM完全渲染后再初始化聊天
|
||||
this.$nextTick(() => {
|
||||
setTimeout(async () => {
|
||||
@@ -299,6 +338,11 @@ export default {
|
||||
this.contentUpdateTimer = null
|
||||
}
|
||||
|
||||
// 清理数字人相关定时器和事件监听器
|
||||
this.stopAutoBlinking()
|
||||
this.stopAIAnimation()
|
||||
this.disableMouseTracking()
|
||||
|
||||
// 取消正在进行的请求
|
||||
if (this.currentCancel) {
|
||||
this.currentCancel()
|
||||
@@ -718,10 +762,6 @@ export default {
|
||||
this.inputMessage = ''
|
||||
this.sending = true
|
||||
|
||||
// 定义超时变量
|
||||
let streamTimeout = null
|
||||
let noDataTimeout = null
|
||||
|
||||
// 添加到请求队列
|
||||
const requestId = Date.now()
|
||||
this.requestQueue.push(requestId)
|
||||
@@ -735,9 +775,6 @@ export default {
|
||||
}
|
||||
this.messages.push(userMsg)
|
||||
|
||||
// 添加用户消息后立即滚动到底部
|
||||
this.scrollToBottom(false, true) // 立即滚动,强制执行
|
||||
|
||||
// 添加AI消息占位符
|
||||
const aiMsg = {
|
||||
sender: 'ai',
|
||||
@@ -750,7 +787,10 @@ export default {
|
||||
}
|
||||
this.messages.push(aiMsg)
|
||||
|
||||
this.scrollToBottom(true, true) // 添加消息后使用平滑滚动,强制执行
|
||||
// 启动AI思考动画
|
||||
this.startAIThinking()
|
||||
|
||||
this.scrollToBottom(true) // 添加消息后使用平滑滚动
|
||||
|
||||
try {
|
||||
// 创建流式聊天
|
||||
@@ -773,7 +813,7 @@ export default {
|
||||
})
|
||||
|
||||
// 添加额外的超时保护
|
||||
streamTimeout = setTimeout(() => {
|
||||
const streamTimeout = setTimeout(() => {
|
||||
if (cancel) {
|
||||
cancel('流式响应超时')
|
||||
}
|
||||
@@ -788,6 +828,7 @@ export default {
|
||||
const { reader, decoder } = response
|
||||
let buffer = ''
|
||||
let lastUpdateTime = Date.now()
|
||||
let noDataTimeout = null
|
||||
|
||||
while (true) {
|
||||
// 设置无数据超时检测
|
||||
@@ -830,6 +871,12 @@ export default {
|
||||
if (data.event === 'message' && data.answer) {
|
||||
const currentContent = aiMsg.content
|
||||
const newContent = (currentContent === '正在思考...' ? '' : currentContent) + data.answer
|
||||
|
||||
// 如果是第一次收到回复,启动说话动画
|
||||
if (currentContent === '正在思考...') {
|
||||
this.startAISpeaking()
|
||||
}
|
||||
|
||||
// 使用节流更新方法,减少频繁的DOM更新和滚动
|
||||
this.throttledContentUpdate(aiMsg, newContent)
|
||||
}
|
||||
@@ -855,6 +902,8 @@ export default {
|
||||
aiMsg.streamCompleted = true
|
||||
// 重置内容长度计数器
|
||||
this.lastContentLength = 0
|
||||
// 停止AI动画
|
||||
this.stopAIAnimation()
|
||||
// 流式响应结束后强制滚动到底部
|
||||
this.$nextTick(() => {
|
||||
this.scrollToBottom(true, true) // 使用平滑滚动并强制执行
|
||||
@@ -913,7 +962,7 @@ export default {
|
||||
// 从监控系统中移除请求
|
||||
this.activeRequests.delete(requestId)
|
||||
|
||||
this.scrollToBottom(true, true) // 发送完成后使用平滑滚动,强制执行
|
||||
this.scrollToBottom(true) // 发送完成后使用平滑滚动
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1103,6 +1152,139 @@ export default {
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 数字人控制方法
|
||||
*/
|
||||
// 开始AI思考动画
|
||||
startAIThinking() {
|
||||
this.isAIThinking = true
|
||||
this.isAISpeaking = false
|
||||
// 思考时启用鼠标跟踪
|
||||
this.enableMouseTracking()
|
||||
},
|
||||
|
||||
// 开始AI说话动画
|
||||
startAISpeaking() {
|
||||
this.isAIThinking = false
|
||||
this.isAISpeaking = true
|
||||
|
||||
// 说话时禁用鼠标跟踪,眼睛面向前方
|
||||
this.disableMouseTracking()
|
||||
|
||||
// 清除之前的说话定时器
|
||||
if (this.speakingTimer) {
|
||||
clearTimeout(this.speakingTimer)
|
||||
}
|
||||
},
|
||||
|
||||
// 停止AI动画
|
||||
stopAIAnimation() {
|
||||
this.isAIThinking = false
|
||||
this.isAISpeaking = false
|
||||
|
||||
// 停止动画后恢复鼠标跟踪
|
||||
this.enableMouseTracking()
|
||||
|
||||
if (this.speakingTimer) {
|
||||
clearTimeout(this.speakingTimer)
|
||||
this.speakingTimer = null
|
||||
}
|
||||
},
|
||||
|
||||
// 眨眼动画
|
||||
triggerBlink() {
|
||||
if (this.isBlinking) return
|
||||
|
||||
this.isBlinking = true
|
||||
setTimeout(() => {
|
||||
this.isBlinking = false
|
||||
}, 150)
|
||||
},
|
||||
|
||||
// 启动自动眨眼
|
||||
startAutoBlinking() {
|
||||
if (this.blinkTimer) {
|
||||
clearInterval(this.blinkTimer)
|
||||
}
|
||||
|
||||
this.blinkTimer = setInterval(() => {
|
||||
if (!this.isDestroyed) {
|
||||
this.triggerBlink()
|
||||
}
|
||||
}, 3000 + Math.random() * 2000) // 3-5秒随机眨眼
|
||||
},
|
||||
|
||||
// 停止自动眨眼
|
||||
stopAutoBlinking() {
|
||||
if (this.blinkTimer) {
|
||||
clearInterval(this.blinkTimer)
|
||||
this.blinkTimer = null
|
||||
}
|
||||
},
|
||||
|
||||
// 鼠标移动事件处理
|
||||
handleMouseMove(event) {
|
||||
if (!this.isMouseTracking || this.isDestroyed) return
|
||||
|
||||
this.mouseX = event.clientX
|
||||
this.mouseY = event.clientY
|
||||
this.updateEyePosition()
|
||||
},
|
||||
|
||||
// 更新眼睛位置
|
||||
updateEyePosition() {
|
||||
if (!this.isMouseTracking) return
|
||||
|
||||
// 获取数字人头像的位置
|
||||
const avatarElement = this.$el.querySelector('.digital-avatar')
|
||||
if (!avatarElement) return
|
||||
|
||||
const rect = avatarElement.getBoundingClientRect()
|
||||
const avatarCenterX = rect.left + rect.width / 2
|
||||
const avatarCenterY = rect.top + rect.height / 2
|
||||
|
||||
// 计算鼠标相对于头像中心的位置
|
||||
const deltaX = this.mouseX - avatarCenterX
|
||||
const deltaY = this.mouseY - avatarCenterY
|
||||
|
||||
// 计算距离和角度
|
||||
const distance = Math.sqrt(deltaX * deltaX + deltaY * deltaY)
|
||||
const maxDistance = 100 // 最大跟踪距离
|
||||
const maxOffset = 1.5 // 眼睛最大偏移量
|
||||
|
||||
// 限制跟踪范围
|
||||
const trackingFactor = Math.min(distance / maxDistance, 1)
|
||||
|
||||
// 计算眼睛偏移
|
||||
this.eyeOffsetX = (deltaX / distance) * maxOffset * trackingFactor
|
||||
this.eyeOffsetY = (deltaY / distance) * maxOffset * trackingFactor
|
||||
|
||||
// 如果距离太近,眼睛回到中心
|
||||
if (distance < 50) {
|
||||
this.eyeOffsetX = 0
|
||||
this.eyeOffsetY = 0
|
||||
}
|
||||
},
|
||||
|
||||
// 眼睛回到中心位置(说话时使用)
|
||||
resetEyePosition() {
|
||||
this.eyeOffsetX = 0
|
||||
this.eyeOffsetY = 0
|
||||
},
|
||||
|
||||
// 启用鼠标跟踪
|
||||
enableMouseTracking() {
|
||||
this.isMouseTracking = true
|
||||
document.addEventListener('mousemove', this.handleMouseMove)
|
||||
},
|
||||
|
||||
// 禁用鼠标跟踪
|
||||
disableMouseTracking() {
|
||||
this.isMouseTracking = false
|
||||
document.removeEventListener('mousemove', this.handleMouseMove)
|
||||
this.resetEyePosition()
|
||||
},
|
||||
|
||||
/**
|
||||
* 处理点赞
|
||||
*/
|
||||
@@ -1546,11 +1728,171 @@ export default {
|
||||
}
|
||||
}
|
||||
|
||||
/* 导航栏左侧容器 */
|
||||
.nav-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.nav-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* 数字人头像容器 */
|
||||
.digital-avatar {
|
||||
position: relative;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
transition: all 0.3s ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.digital-avatar:hover {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.digital-avatar.thinking {
|
||||
animation: thinking-pulse 2s infinite;
|
||||
}
|
||||
|
||||
.digital-avatar.speaking {
|
||||
animation: speaking-glow 0.5s infinite alternate;
|
||||
}
|
||||
|
||||
/* 头像发光效果 */
|
||||
.avatar-glow {
|
||||
position: absolute;
|
||||
top: -2px;
|
||||
left: -2px;
|
||||
right: -2px;
|
||||
bottom: -2px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);
|
||||
opacity: 0;
|
||||
z-index: -1;
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.digital-avatar.speaking .avatar-glow {
|
||||
opacity: 0.6;
|
||||
animation: glow-pulse 1s infinite;
|
||||
}
|
||||
|
||||
/* 头像面部 */
|
||||
.avatar-face {
|
||||
position: relative;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
background: #fff;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* 眼睛容器 */
|
||||
.avatar-eyes {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
/* 眼睛 */
|
||||
.eye {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background: #fff;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 50%;
|
||||
transition: all 0.15s ease;
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.eye.blink {
|
||||
height: 2px;
|
||||
background: #666;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.eye.blink .pupil {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* 瞳孔 */
|
||||
.pupil {
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
background: #333;
|
||||
border-radius: 50%;
|
||||
transition: transform 0.1s ease;
|
||||
}
|
||||
|
||||
/* 嘴巴 */
|
||||
.avatar-mouth {
|
||||
width: 8px;
|
||||
height: 3px;
|
||||
background: #333;
|
||||
border-radius: 0 0 8px 8px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.avatar-mouth.talking {
|
||||
animation: mouth-talk 0.3s infinite alternate;
|
||||
}
|
||||
|
||||
/* 动画定义 */
|
||||
@keyframes thinking-pulse {
|
||||
0%, 100% {
|
||||
box-shadow: 0 0 0 0 rgba(79, 172, 254, 0.4);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 0 8px rgba(79, 172, 254, 0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes speaking-glow {
|
||||
0% {
|
||||
box-shadow: 0 0 10px rgba(79, 172, 254, 0.5);
|
||||
}
|
||||
100% {
|
||||
box-shadow: 0 0 20px rgba(79, 172, 254, 0.8);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes glow-pulse {
|
||||
0%, 100% {
|
||||
transform: scale(1);
|
||||
opacity: 0.6;
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.1);
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes mouth-talk {
|
||||
0% {
|
||||
height: 3px;
|
||||
width: 8px;
|
||||
}
|
||||
100% {
|
||||
height: 6px;
|
||||
width: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.nav-close {
|
||||
font-size: 24px;
|
||||
cursor: pointer;
|
||||
|
Reference in New Issue
Block a user