|
|
|
@ -18,8 +18,8 @@ |
|
|
|
:class="['message', message.sender]" |
|
|
|
:class="['message', message.sender]" |
|
|
|
> |
|
|
|
> |
|
|
|
<div class="message-content"> |
|
|
|
<div class="message-content"> |
|
|
|
<div>{{ message.text }}</div> |
|
|
|
<div v-html="message.text"></div> |
|
|
|
<div class="timestamp">{{ message.time }}</div> |
|
|
|
<!-- <div class="timestamp">{{ message.time }}</div> --> |
|
|
|
</div> |
|
|
|
</div> |
|
|
|
</div> |
|
|
|
</div> |
|
|
|
</div> |
|
|
|
</div> |
|
|
|
@ -73,7 +73,7 @@ export default { |
|
|
|
] |
|
|
|
] |
|
|
|
}; |
|
|
|
}; |
|
|
|
},mounted() { |
|
|
|
},mounted() { |
|
|
|
// 初始化时滚动到底部 |
|
|
|
// 初始化时滚动到底部,符合大模型聊天体验 |
|
|
|
this.scrollToBottom(); |
|
|
|
this.scrollToBottom(); |
|
|
|
}, |
|
|
|
}, |
|
|
|
methods: { |
|
|
|
methods: { |
|
|
|
@ -86,33 +86,109 @@ export default { |
|
|
|
sender: 'customer', |
|
|
|
sender: 'customer', |
|
|
|
time: this.formatTime(new Date()) |
|
|
|
time: this.formatTime(new Date()) |
|
|
|
}); |
|
|
|
}); |
|
|
|
|
|
|
|
// 用户发送消息后滚动到底部,符合大模型聊天体验 |
|
|
|
|
|
|
|
this.scrollToBottom(); |
|
|
|
let newMessageContent = this.newMessage; |
|
|
|
let newMessageContent = this.newMessage; |
|
|
|
// 模拟自动回复 |
|
|
|
// 直接调用autoReply,不再延迟 |
|
|
|
setTimeout(() => { |
|
|
|
|
|
|
|
this.autoReply(newMessageContent); |
|
|
|
this.autoReply(newMessageContent); |
|
|
|
}, 1000); |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
this.newMessage = ''; |
|
|
|
this.newMessage = ''; |
|
|
|
}, |
|
|
|
}, |
|
|
|
async autoReply(userMessage) { |
|
|
|
async autoReply(userMessage) { |
|
|
|
console.log("%c 🇱🇺: autoReply -> userMessage ", "font-size:16px;background-color:#c70b31;color:white;", userMessage) |
|
|
|
console.log("%c 🇱🇺: autoReply -> userMessage ", "font-size:16px;background-color:#c70b31;color:white;", userMessage) |
|
|
|
let replyText = ''; |
|
|
|
let replyText = ''; |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// 测试模式:当用户输入"测试数据"时,返回格式化的虚拟数据 |
|
|
|
|
|
|
|
if (userMessage.includes('测试数据')) { |
|
|
|
|
|
|
|
// 模拟短暂延迟,让测试数据也有加载感觉 |
|
|
|
|
|
|
|
setTimeout(() => { |
|
|
|
|
|
|
|
replyText = this.getTestData(); |
|
|
|
|
|
|
|
// 添加一个空的回复消息,稍后通过打字效果填充内容 |
|
|
|
|
|
|
|
const messageIndex = this.messages.length; |
|
|
|
|
|
|
|
this.messages.push({ |
|
|
|
|
|
|
|
text: '', // 初始为空 |
|
|
|
|
|
|
|
sender: 'service', |
|
|
|
|
|
|
|
time: this.formatTime(new Date()) |
|
|
|
|
|
|
|
}); |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// 调用打字效果方法 |
|
|
|
|
|
|
|
this.typeWriter(messageIndex, replyText); |
|
|
|
|
|
|
|
}, 500); |
|
|
|
|
|
|
|
} else { |
|
|
|
|
|
|
|
// 设置loading状态 |
|
|
|
|
|
|
|
this.isLoading = true; |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
try { |
|
|
|
let res = await knowledgeAsk({question: userMessage }); |
|
|
|
let res = await knowledgeAsk({question: userMessage }); |
|
|
|
if (res.code == 200) { |
|
|
|
if (res.code == 200) { |
|
|
|
replyText = res.data.answer; |
|
|
|
replyText = this.formatResponse(res.data.answer); |
|
|
|
}else { |
|
|
|
} else { |
|
|
|
replyText = '很抱歉,暂时无法回答您的问题。您可以尝试其他常见问题或联系客服人员。'; |
|
|
|
replyText = '很抱歉,暂时无法回答您的问题。您可以尝试其他常见问题或联系客服人员。'; |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
// 添加客服回复 |
|
|
|
// 添加一个空的回复消息,稍后通过打字效果填充内容 |
|
|
|
|
|
|
|
const messageIndex = this.messages.length; |
|
|
|
this.messages.push({ |
|
|
|
this.messages.push({ |
|
|
|
text: replyText, |
|
|
|
text: '', // 初始为空 |
|
|
|
sender: 'service', |
|
|
|
sender: 'service', |
|
|
|
time: this.formatTime(new Date()) |
|
|
|
time: this.formatTime(new Date()) |
|
|
|
}); |
|
|
|
}); |
|
|
|
|
|
|
|
|
|
|
|
// 滚动到底部 |
|
|
|
// 调用打字效果方法 |
|
|
|
|
|
|
|
this.typeWriter(messageIndex, replyText); |
|
|
|
|
|
|
|
} finally { |
|
|
|
|
|
|
|
// 无论请求成功失败,都关闭loading |
|
|
|
|
|
|
|
setTimeout(() => { |
|
|
|
|
|
|
|
this.isLoading = false; |
|
|
|
|
|
|
|
}, 300); // 延迟一点关闭,让用户有完成感 |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
}, |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// 打字效果方法 |
|
|
|
|
|
|
|
typeWriter(messageIndex, fullText) { |
|
|
|
|
|
|
|
let currentIndex = 0; |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// 优化滚动:先滚动到底部 |
|
|
|
|
|
|
|
this.$nextTick(() => { |
|
|
|
|
|
|
|
this.scrollToBottom(); |
|
|
|
|
|
|
|
}); |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const typeInterval = setInterval(() => { |
|
|
|
|
|
|
|
if (currentIndex < fullText.length) { |
|
|
|
|
|
|
|
// 优化打字速度:根据文本长度动态调整速度 |
|
|
|
|
|
|
|
let typeSpeed = 15; // 加快速度到15毫秒/字符 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// 快速处理HTML标签字符,避免标签被拆分导致的闪烁 |
|
|
|
|
|
|
|
if (fullText[currentIndex] === '<' || (currentIndex > 0 && fullText[currentIndex-1] === '<')) { |
|
|
|
|
|
|
|
// 查找完整的HTML标签 |
|
|
|
|
|
|
|
const tagEndIndex = fullText.indexOf('>', currentIndex); |
|
|
|
|
|
|
|
if (tagEndIndex !== -1) { |
|
|
|
|
|
|
|
// 一次性添加整个标签 |
|
|
|
|
|
|
|
this.messages[messageIndex].text = fullText.substring(0, tagEndIndex + 1); |
|
|
|
|
|
|
|
currentIndex = tagEndIndex + 1; |
|
|
|
|
|
|
|
} else { |
|
|
|
|
|
|
|
// 正常逐字添加 |
|
|
|
|
|
|
|
this.messages[messageIndex].text = fullText.substring(0, currentIndex + 1); |
|
|
|
|
|
|
|
currentIndex++; |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
} else { |
|
|
|
|
|
|
|
// 普通字符正常添加 |
|
|
|
|
|
|
|
this.messages[messageIndex].text = fullText.substring(0, currentIndex + 1); |
|
|
|
|
|
|
|
currentIndex++; |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// 优化滚动:减少滚动频率,避免抖动 |
|
|
|
|
|
|
|
if (currentIndex % 10 === 0 || currentIndex === fullText.length) { |
|
|
|
this.scrollToBottom(); |
|
|
|
this.scrollToBottom(); |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
} else { |
|
|
|
|
|
|
|
// 打字完成 |
|
|
|
|
|
|
|
clearInterval(typeInterval); |
|
|
|
|
|
|
|
// 最终滚动到底部 |
|
|
|
|
|
|
|
this.scrollToBottom(); |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
}, 15); // 加快打字速度到15毫秒/字符 |
|
|
|
}, |
|
|
|
}, |
|
|
|
selectFaq(faq) { |
|
|
|
selectFaq(faq) { |
|
|
|
// 添加用户选择的常见问题 |
|
|
|
// 添加用户选择的常见问题 |
|
|
|
@ -122,14 +198,16 @@ export default { |
|
|
|
time: this.formatTime(new Date()) |
|
|
|
time: this.formatTime(new Date()) |
|
|
|
}); |
|
|
|
}); |
|
|
|
|
|
|
|
|
|
|
|
// 添加对应的回答 |
|
|
|
// 添加对应的回答,使用打字效果 |
|
|
|
setTimeout(() => { |
|
|
|
setTimeout(() => { |
|
|
|
|
|
|
|
const messageIndex = this.messages.length; |
|
|
|
this.messages.push({ |
|
|
|
this.messages.push({ |
|
|
|
text: faq.answer, |
|
|
|
text: '', // 初始为空 |
|
|
|
sender: 'service', |
|
|
|
sender: 'service', |
|
|
|
time: this.formatTime(new Date()) |
|
|
|
time: this.formatTime(new Date()) |
|
|
|
}); |
|
|
|
}); |
|
|
|
this.scrollToBottom(); |
|
|
|
// 使用打字效果显示回答 |
|
|
|
|
|
|
|
this.typeWriter(messageIndex, faq.answer); |
|
|
|
}, 1000); |
|
|
|
}, 1000); |
|
|
|
}, |
|
|
|
}, |
|
|
|
scrollToBottom() { |
|
|
|
scrollToBottom() { |
|
|
|
@ -138,6 +216,101 @@ export default { |
|
|
|
container.scrollTop = container.scrollHeight; |
|
|
|
container.scrollTop = container.scrollHeight; |
|
|
|
}); |
|
|
|
}); |
|
|
|
}, |
|
|
|
}, |
|
|
|
|
|
|
|
// 滚动到顶部方法 - 保留但不自动调用 |
|
|
|
|
|
|
|
scrollToTop() { |
|
|
|
|
|
|
|
this.$nextTick(() => { |
|
|
|
|
|
|
|
const container = this.$refs.messagesContainer; |
|
|
|
|
|
|
|
container.scrollTop = 0; |
|
|
|
|
|
|
|
}); |
|
|
|
|
|
|
|
}, |
|
|
|
|
|
|
|
// 格式化返回值的方法 - 将Markdown转换为HTML |
|
|
|
|
|
|
|
formatResponse(answer) { |
|
|
|
|
|
|
|
if (!answer) return ''; |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// 简单的Markdown解析实现 |
|
|
|
|
|
|
|
return this.markdownToHtml(answer); |
|
|
|
|
|
|
|
}, |
|
|
|
|
|
|
|
// 简单的Markdown转HTML函数 |
|
|
|
|
|
|
|
markdownToHtml(text) { |
|
|
|
|
|
|
|
if (!text) return ''; |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// 转换标题 |
|
|
|
|
|
|
|
text = text.replace(/^### (.*$)/gm, '<h3>$1</h3>'); |
|
|
|
|
|
|
|
text = text.replace(/^## (.*$)/gm, '<h2>$1</h2>'); |
|
|
|
|
|
|
|
text = text.replace(/^# (.*$)/gm, '<h1>$1</h1>'); |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// 转换分隔线 |
|
|
|
|
|
|
|
text = text.replace(/^---$/gm, '<hr>'); |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// 转换加粗文本 |
|
|
|
|
|
|
|
text = text.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>'); |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// 转换斜体文本 |
|
|
|
|
|
|
|
text = text.replace(/\*(.*?)\*/g, '<em>$1</em>'); |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// 转换引用块 |
|
|
|
|
|
|
|
text = text.replace(/^> (.*$)/gm, '<blockquote>$1</blockquote>'); |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// 转换无序列表项 |
|
|
|
|
|
|
|
text = text.replace(/^- (.*$)/gm, '<ul><li>$1</li></ul>'); |
|
|
|
|
|
|
|
// 合并连续的列表项 |
|
|
|
|
|
|
|
text = text.replace(/<\/li><\/ul>\s*<ul><li>/g, '</li><li>'); |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// 转换有序列表项 |
|
|
|
|
|
|
|
text = text.replace(/^(\d+)\. (.*$)/gm, '<ol><li>$2</li></ol>'); |
|
|
|
|
|
|
|
// 合并连续的有序列表项 |
|
|
|
|
|
|
|
text = text.replace(/<\/li><\/ol>\s*<ol><li>/g, '</li><li>'); |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// 转换段落 |
|
|
|
|
|
|
|
text = text.replace(/^(?!<h|<ul|<ol|<blockquote|<hr)(.*$)/gm, '<p>$1</p>'); |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
return text; |
|
|
|
|
|
|
|
}, |
|
|
|
|
|
|
|
// 获取测试数据 |
|
|
|
|
|
|
|
getTestData() { |
|
|
|
|
|
|
|
// 直接返回HTML格式,避免解析错误 |
|
|
|
|
|
|
|
return ` |
|
|
|
|
|
|
|
<h3>测试数据 - 课程安排信息</h3> |
|
|
|
|
|
|
|
<hr> |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
<h3>1. 选课时间安排</h3> |
|
|
|
|
|
|
|
<ul> |
|
|
|
|
|
|
|
<li><strong>预选时间</strong>:2025年8月26日9:00 — 8月27日16:00</li> |
|
|
|
|
|
|
|
<li><strong>正选时间</strong>:2025年8月29日9:00 — 8月30日16:00</li> |
|
|
|
|
|
|
|
</ul> |
|
|
|
|
|
|
|
<blockquote><em>说明:此为2025-2026学年秋季学期的固定选课窗口期</em></blockquote> |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
<h3>2. 选课方式与系统登录步骤</h3> |
|
|
|
|
|
|
|
<ul> |
|
|
|
|
|
|
|
<li><strong>选课方式</strong>:学生必须通过<strong>电脑端</strong>登录"一网通办"平台</li> |
|
|
|
|
|
|
|
<li> |
|
|
|
|
|
|
|
<strong>系统登录三步法</strong>: |
|
|
|
|
|
|
|
<ol> |
|
|
|
|
|
|
|
<li><strong>激活账号</strong>:完成账号激活</li> |
|
|
|
|
|
|
|
<li><strong>关注公众号</strong>:扫码关注</li> |
|
|
|
|
|
|
|
<li><strong>登录系统</strong>:进入平台进行选课</li> |
|
|
|
|
|
|
|
</ol> |
|
|
|
|
|
|
|
</li> |
|
|
|
|
|
|
|
</ul> |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
<h3>3. 注意事项</h3> |
|
|
|
|
|
|
|
<ul> |
|
|
|
|
|
|
|
<li> |
|
|
|
|
|
|
|
<strong>体育课规则</strong>: |
|
|
|
|
|
|
|
<ul> |
|
|
|
|
|
|
|
<li>公选课的体育课<strong>不计入</strong>体育必修课学分</li> |
|
|
|
|
|
|
|
<li>学生毕业前须修<strong>4次体育课</strong></li> |
|
|
|
|
|
|
|
</ul> |
|
|
|
|
|
|
|
</li> |
|
|
|
|
|
|
|
<li> |
|
|
|
|
|
|
|
<strong>课程号前缀要求</strong>: |
|
|
|
|
|
|
|
<ul> |
|
|
|
|
|
|
|
<li>校本部学生选课时,<strong>课程号必须以"T"开头</strong></li> |
|
|
|
|
|
|
|
</ul> |
|
|
|
|
|
|
|
</li> |
|
|
|
|
|
|
|
</ul> |
|
|
|
|
|
|
|
`; |
|
|
|
|
|
|
|
}, |
|
|
|
formatTime(date) { |
|
|
|
formatTime(date) { |
|
|
|
const hours = date.getHours().toString().padStart(2, '0'); |
|
|
|
const hours = date.getHours().toString().padStart(2, '0'); |
|
|
|
const minutes = date.getMinutes().toString().padStart(2, '0'); |
|
|
|
const minutes = date.getMinutes().toString().padStart(2, '0'); |
|
|
|
@ -147,6 +320,7 @@ export default { |
|
|
|
watch: { |
|
|
|
watch: { |
|
|
|
messages: { |
|
|
|
messages: { |
|
|
|
handler() { |
|
|
|
handler() { |
|
|
|
|
|
|
|
// 当消息数组发生变化时,滚动到底部,确保最新消息可见 |
|
|
|
this.scrollToBottom(); |
|
|
|
this.scrollToBottom(); |
|
|
|
}, |
|
|
|
}, |
|
|
|
deep: true |
|
|
|
deep: true |
|
|
|
@ -202,17 +376,28 @@ export default { |
|
|
|
justify-content: flex-end; |
|
|
|
justify-content: flex-end; |
|
|
|
} |
|
|
|
} |
|
|
|
.message-content { |
|
|
|
.message-content { |
|
|
|
max-width: 70%; |
|
|
|
|
|
|
|
padding: 10px 15px; |
|
|
|
padding: 10px 15px; |
|
|
|
border-radius: 4px; |
|
|
|
border-radius: 4px; |
|
|
|
|
|
|
|
/* 固定最小高度,避免内容变化时高度抖动 */ |
|
|
|
|
|
|
|
min-height: 20px; |
|
|
|
} |
|
|
|
} |
|
|
|
.message.customer .message-content { |
|
|
|
.message.customer .message-content { |
|
|
|
background-color: #ecf5ff; |
|
|
|
background:#f5f5f5; |
|
|
|
border: 1px solid #d9ecff; |
|
|
|
border-radius: 26px; |
|
|
|
|
|
|
|
font-family: PingFang SC; |
|
|
|
|
|
|
|
font-size: 16px; |
|
|
|
|
|
|
|
font-weight: 400; |
|
|
|
|
|
|
|
justify-self: end; |
|
|
|
|
|
|
|
line-height: 28px; |
|
|
|
|
|
|
|
max-width: 100%; |
|
|
|
|
|
|
|
padding: 10px 20px; |
|
|
|
|
|
|
|
white-space: pre-wrap; |
|
|
|
|
|
|
|
word-break: break-all; |
|
|
|
} |
|
|
|
} |
|
|
|
.message.service .message-content { |
|
|
|
.message.service .message-content { |
|
|
|
background-color: #f4f4f5; |
|
|
|
background-color: transparent; /* 移除背景色 */ |
|
|
|
border: 1px solid #e9e9eb; |
|
|
|
border: none; /* 移除边框 */ |
|
|
|
|
|
|
|
width: 100%; /* 设置宽度为100% */ |
|
|
|
} |
|
|
|
} |
|
|
|
.chat-input { |
|
|
|
.chat-input { |
|
|
|
padding: 15px; |
|
|
|
padding: 15px; |
|
|
|
@ -238,4 +423,43 @@ export default { |
|
|
|
margin-top: 5px; |
|
|
|
margin-top: 5px; |
|
|
|
text-align: right; |
|
|
|
text-align: right; |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
/* Loading样式 */ |
|
|
|
|
|
|
|
.loading-indicator { |
|
|
|
|
|
|
|
display: flex; |
|
|
|
|
|
|
|
justify-content: flex-start; |
|
|
|
|
|
|
|
padding: 10px 0; |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
.loading-dots { |
|
|
|
|
|
|
|
display: flex; |
|
|
|
|
|
|
|
align-items: center; |
|
|
|
|
|
|
|
gap: 4px; |
|
|
|
|
|
|
|
background-color: #f4f4f5; |
|
|
|
|
|
|
|
padding: 8px 16px; |
|
|
|
|
|
|
|
border-radius: 4px; |
|
|
|
|
|
|
|
border: 1px solid #e9e9eb; |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
.loading-dots .dot { |
|
|
|
|
|
|
|
width: 8px; |
|
|
|
|
|
|
|
height: 8px; |
|
|
|
|
|
|
|
background-color: #409EFF; |
|
|
|
|
|
|
|
border-radius: 50%; |
|
|
|
|
|
|
|
animation: loading 1.4s infinite ease-in-out both; |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
.loading-dots .dot:nth-child(1) { |
|
|
|
|
|
|
|
animation-delay: -0.32s; |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
.loading-dots .dot:nth-child(2) { |
|
|
|
|
|
|
|
animation-delay: -0.16s; |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
@keyframes loading { |
|
|
|
|
|
|
|
0%, 80%, 100% { |
|
|
|
|
|
|
|
transform: scale(0); |
|
|
|
|
|
|
|
opacity: 0.5; |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
40% { |
|
|
|
|
|
|
|
transform: scale(1); |
|
|
|
|
|
|
|
opacity: 1; |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
} |
|
|
|
</style> |
|
|
|
</style> |