Files
learning-system-portal/src/views/portal/case/components/messages.vue
陈昱达 0afd733f47 feat(portal):优化AI对话框宽度与消息展示
- 将AI对话框宽度从65%调整为800px- 修改初始欢迎消息格式,使用HTML标签替换Markdown语法-修复机器人消息显示逻辑,确保displayText正确赋值- 优化消息渲染流程,提升用户体验
2025-10-14 15:43:13 +08:00

421 lines
10 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="messages">
<!-- 机器人消息 -->
<div v-if="messageData.isBot" class="bot-message">
<!-- 思考中提示 -->
<div v-if="messageData.thinkText" class="bot-think" v-katex:auto v-html="md.render(messageData.thinkText)"></div>
<!-- 主要回复内容 -->
<div
ref="contentContainer"
class="message-content"
v-katex:auto
v-html="displayText"
></div>
<!-- 引用案例 -->
<div v-if="messageData.caseRefers && messageData.caseRefers.length > 0 && messageData.textCompleted" class="case-refers">
<div class="case-refers-title">
<span><i class="iconfont icon-think"></i> 引用案例</span>
<span v-if="shouldShowMoreButton" class="more" @click="toggleShowAllCaseRefers">
{{ showAllCaseRefers ? '收起' : '查看更多' }}
</span>
</div>
<div class="case-refers-list">
<div
v-for="item in displayedCaseRefers"
:key="item.caseId"
class="case-refers-item"
>
<div class="case-refers-item-title">
<a @click="toUrl(item)" class="title">{{ item.title }}</a>
<span class="case-refers-item-timer">{{ item.uploadTime }}</span>
</div>
<div class="case-refers-item-author">
<span class="user"></span>
<span>{{ item.authorName }}</span>
<span class="case-inter-orginInfo">{{ item.orgInfo }}</span>
</div>
<div class="case-refers-item-keywords">
<span v-for="keyword in item.keywords" :key="keyword">{{ keyword }}</span>
</div>
<div class="message-content case-content" v-html="md.render(item.content)"></div>
</div>
</div>
</div>
</div>
<!-- 用户消息 -->
<div v-else class="user-message">
<div class="message-text" v-html="messageData.text"></div>
</div>
<!-- 推荐问题 -->
<div v-if="suggestions && suggestions.length > 0" class="suggestions">
<div class="suggestions-title">💡 推荐问题</div>
<div class="suggestions-list">
<button
v-for="(item, index) in suggestions"
:key="index"
class="suggestions-item"
@click="$emit('suggestion-click', item)"
>
{{ item }}
</button>
</div>
</div>
</div>
</template>
<script>
import MarkdownIt from 'markdown-it';
import highlight from 'markdown-it-highlightjs';
import 'highlight.js/styles/a11y-dark.css';
import markdownItMermaid from 'markdown-it-mermaid';
import mermaid from 'mermaid';
// 初始化 Mermaid
mermaid.initialize({ startOnLoad: false });
const md = new MarkdownIt({
html: true,
linkify: true,
typographer: true,
});
md.use(highlight).use(markdownItMermaid);
export default {
name: 'Message',
props: {
messageData: {
type: Object,
required: true,
default: () => ({}),
},
suggestions: {
type: Array,
default: () => [],
},
},
data() {
return {
md,
displayText: '',
typingTimer: null,
typingSpeed: 30, // 毫秒/字符
showAllCaseRefers: false,
};
},
computed: {
displayedCaseRefers() {
if (this.showAllCaseRefers || !this.messageData.caseRefers) {
return this.messageData.caseRefers || [];
}
return this.messageData.caseRefers.slice(0, 3);
},
shouldShowMoreButton() {
return this.messageData.caseRefers && this.messageData.caseRefers.length > 3;
},
},
watch: {
'messageData.text': {
handler(newVal) {
if (!newVal) {
this.displayText = '';
return;
}
if (this.messageData.isBot && !this.messageData.typing) {
this.startTyping(newVal); // 启动打字机效果
this.displayText = newVal || ''
} else {
this.displayText = this.md.render(newVal);
this.$nextTick(this.renderMermaid); // 直接渲染 Mermaid
}
},
immediate: true,
},
},
methods: {
toUrl(item) {
this.$router.push({
path: '/case/detail',
query: { id: item.caseId },
});
},
// 正确的打字机效果:先整体渲染 Markdown再逐字显示 HTML
startTyping(fullText) {
const renderedText = this.md.render(fullText);
this.displayText = '';
let index = 0;
if (this.typingTimer) {
clearInterval(this.typingTimer);
}
this.typingTimer = setInterval(() => {
if (index < renderedText.length) {
this.displayText += renderedText[index];
index++;
} else {
clearInterval(this.typingTimer);
this.typingTimer = null;
this.$nextTick(this.renderMermaid); // 渲染 Mermaid 图表
}
}, this.typingSpeed);
},
// 触发 Mermaid 渲染
renderMermaid() {
this.$nextTick(() => {
const mermaidEls = this.$el.querySelectorAll('.mermaid');
if (mermaidEls.length > 0) {
try {
// mermaid 8.x 版本使用 init 方法而不是 run
if (typeof mermaid.init === 'function') {
mermaid.init(undefined, '.mermaid');
} else if (mermaid.default && typeof mermaid.default.init === 'function') {
mermaid.default.init(undefined, '.mermaid');
}
} catch (e) {
console.warn('Mermaid 渲染失败:', e);
}
}
});
},
// 切换案例引用显示数量
toggleShowAllCaseRefers() {
this.showAllCaseRefers = !this.showAllCaseRefers;
// 切换后重新渲染 Mermaid如果内容中有图表
this.$nextTick(this.renderMermaid);
},
},
beforeDestroy() {
if (this.typingTimer) {
clearInterval(this.typingTimer);
this.typingTimer = null;
}
},
};
</script>
<style scoped lang="scss">
::v-deep .mermaid{
width: 100%;
height: auto;
}
::v-deep svg[id^="mermaid-"]{
width: 100%;
height: 100%;
}
.messages {
width: 100%;
word-wrap: break-word;
.bot-message {
background-color: #fff;
border-radius: 5px;
padding: 10px;
margin-bottom: 10px;
.bot-think {
color: #909399;
padding-bottom: 5px;
position: relative;
padding-left: 10px;
&:before {
content: ' ';
border-left: 0.5px solid #909399;
position: absolute;
height: 100%;
left: 0;
top: -3px;
transform: scaleX(0.5);
}
}
.message-content {
font-size: 14px;
line-height: 1.6;
}
.case-content {
font-size: 12px !important;
margin-top: 8px;
padding: 6px 10px;
background-color: #f9f9f9;
border-radius: 4px;
border: 1px solid #eee;
}
}
.user-message {
float: right;
padding: 8px 15px;
max-width: 80%;
background-color: #e4e7ed;
border-radius: 18px;
margin-bottom: 10px;
font-size: 14px;
word-break: break-word;
}
/* ========== 案例引用样式 ========== */
.case-refers {
margin-top: 12px;
.case-refers-title {
font-weight: bold;
margin-bottom: 8px;
display: flex;
align-items: center;
justify-content: space-between;
color: #333;
.icon-think {
background-image: url('./map.svg');
width: 15px;
height: 13px;
display: inline-block;
background-repeat: no-repeat;
background-size: 100% 100%;
margin-right: 6px;
}
.more {
font-size: 12px;
padding: 2px 8px;
background-color: #f4f7fd;
border-radius: 5px;
color: #577ee1;
font-weight: normal;
cursor: pointer;
}
}
.case-refers-list {
display: flex;
flex-direction: column;
gap: 8px;
.case-refers-item {
border: 1px solid rgba(144, 147, 153, 0.44);
padding: 8px;
border-radius: 6px;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
.case-refers-item-title {
font-size: 14px;
margin-bottom: 6px;
font-weight: 600;
color: #000;
display: flex;
align-items: center;
justify-content: space-between;
.title {
max-width: 70%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: #1a73e8;
cursor: pointer;
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
.case-refers-item-timer {
font-size: 11px;
color: #aaa;
font-weight: normal;
}
}
.case-refers-item-author {
display: flex;
align-items: center;
font-size: 12px;
color: #555;
margin-bottom: 5px;
.user {
background-image: url('./user.svg');
width: 15px;
height: 15px;
display: inline-block;
background-repeat: no-repeat;
background-size: 100% 100%;
margin-right: 6px;
}
.case-inter-orginInfo {
font-size: 11px;
color: #999;
margin-left: 6px;
}
}
.case-refers-item-keywords {
margin-top: 4px;
font-size: 12px;
span {
padding: 2px 6px;
background-color: #f4f7fd;
border-radius: 5px;
font-size: 11px !important;
color: #577ee1;
}
span + span {
margin-left: 8px;
}
}
}
}
}
/* ========== 推荐问题 ========== */
.suggestions {
margin-top: 10px;
font-size: 14px;
.suggestions-title {
font-weight: bold;
margin-bottom: 6px;
color: #333;
}
.suggestions-list {
display: flex;
flex-direction: column;
gap: 6px;
.suggestions-item {
padding: 6px 10px;
background-color: #f0f4fc;
border: none;
border-radius: 6px;
text-align: left;
color: #1a73e8;
cursor: pointer;
font-size: 13px;
transition: background-color 0.2s;
&:hover {
background-color: #e1e8f5;
}
}
}
}
}
</style>