feat(portal): 实现消息组件支持 Mermaid 图表渲染

- 添加 markdown-it-mermaid 插件支持 Mermaid 语法
- 集成 mermaid 库并初始化配置
- 实现打字机效果与 Mermaid 渲染结合
-优化案例引用展示逻辑与样式- 添加推荐问题展示功能
- 改进消息组件的响应式与样式细节
- 修复组件销毁时定时器未清除的问题
- 更新依赖包引入 Mermaid 相关库
This commit is contained in:
陈昱达
2025-10-14 15:32:20 +08:00
parent 72472979bd
commit 3720b5667d
3 changed files with 332 additions and 220 deletions

View File

@@ -15,6 +15,7 @@
"lint": "vue-cli-service lint" "lint": "vue-cli-service lint"
}, },
"dependencies": { "dependencies": {
"@mermaid-js/parser": "^0.6.3",
"axios": "^0.21.4", "axios": "^0.21.4",
"core-js": "^3.6.5", "core-js": "^3.6.5",
"driver.js": "^0.9.8", "driver.js": "^0.9.8",
@@ -30,6 +31,8 @@
"katex": "^0.16.25", "katex": "^0.16.25",
"markdown-it": "^14.1.0", "markdown-it": "^14.1.0",
"markdown-it-highlightjs": "^4.2.0", "markdown-it-highlightjs": "^4.2.0",
"markdown-it-mermaid": "^0.2.5",
"mermaid": "^8.13.10",
"mockjs": "^1.1.0", "mockjs": "^1.1.0",
"moment": "^2.29.1", "moment": "^2.29.1",
"nprogress": "^0.2.0", "nprogress": "^0.2.0",

View File

@@ -1,176 +1,218 @@
<!--消息渲染--> <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> <script>
import MarkdownIt from 'markdown-it'; import MarkdownIt from 'markdown-it';
import highlight from 'markdown-it-highlightjs' import highlight from 'markdown-it-highlightjs';
import "highlight.js/styles/a11y-dark.css" import 'highlight.js/styles/a11y-dark.css';
import katex from "katex" import markdownItMermaid from 'markdown-it-mermaid';
import mermaid from 'mermaid';
// 初始化 Mermaid
mermaid.initialize({ startOnLoad: false });
const md = new MarkdownIt({ const md = new MarkdownIt({
}).use(highlight); html: true,
linkify: true,
typographer: true,
});
md.use(highlight).use(markdownItMermaid);
export default { export default {
name: "message", name: 'Message',
props: { props: {
messageData: { messageData: {
type: Object, type: Object,
default: function () { required: true,
return {} default: () => ({}),
}
}, },
suggestions: { suggestions: {
type: Array, type: Array,
default: () => [] default: () => [],
} },
}, },
data() { data() {
return { return {
md, md,
displayText: '', displayText: '',
typingTimer: null, typingTimer: null,
typingSpeed: 30, // 打字机速度(毫秒/字符 typingSpeed: 30, // 毫秒/字符
showAllCaseRefers: false // 控制是否显示所有案例引用 showAllCaseRefers: false,
} };
}, },
computed: { computed: {
// 计算需要显示的案例引用
displayedCaseRefers() { displayedCaseRefers() {
if (this.showAllCaseRefers || !this.messageData.caseRefers) { if (this.showAllCaseRefers || !this.messageData.caseRefers) {
return this.messageData.caseRefers || []; return this.messageData.caseRefers || [];
} }
return this.messageData.caseRefers.slice(0, 3); return this.messageData.caseRefers.slice(0, 3);
}, },
// 判断是否需要显示"查看更多"按钮
shouldShowMoreButton() { shouldShowMoreButton() {
return this.messageData.caseRefers && this.messageData.caseRefers.length > 3; return this.messageData.caseRefers && this.messageData.caseRefers.length > 3;
} },
}, },
watch: { watch: {
'messageData.text': { 'messageData.text': {
handler(newVal) { handler(newVal) {
if (newVal && this.messageData.isBot && !this.messageData.typing) { if (!newVal) {
// this.startTyping(newVal) this.displayText = '';
this.displayText = newVal || '' return;
}
if (this.messageData.isBot && !this.messageData.typing) {
this.startTyping(newVal); // 启动打字机效果
} else { } else {
this.displayText = newVal || '' this.displayText = this.md.render(newVal);
this.$nextTick(this.renderMermaid); // 直接渲染 Mermaid
} }
}, },
immediate: true immediate: true,
} },
}, },
methods: { methods: {
toUrl(item) { toUrl(item) {
console.log(item);
this.$router.push({ this.$router.push({
path: '/case/detail', path: '/case/detail',
query: { query: { id: item.caseId },
id: item.caseId });
}
})
}, },
startTyping(text) {
// 清除之前的定时器 // 正确的打字机效果:先整体渲染 Markdown再逐字显示 HTML
startTyping(fullText) {
const renderedText = this.md.render(fullText);
this.displayText = '';
let index = 0;
if (this.typingTimer) { if (this.typingTimer) {
clearInterval(this.typingTimer) clearInterval(this.typingTimer);
this.typingTimer = null
} }
// 初始化
// this.displayText = ''
let index = 0
// 开始打字机效果
this.typingTimer = setInterval(() => { this.typingTimer = setInterval(() => {
if (index < text.length) { if (index < renderedText.length) {
this.displayText += md.render(text.charAt(index)) this.displayText += renderedText[index];
index++ index++;
} else { } else {
// 打字完成,清除定时器 clearInterval(this.typingTimer);
clearInterval(this.typingTimer) this.typingTimer = null;
this.typingTimer = null this.$nextTick(this.renderMermaid); // 渲染 Mermaid 图表
} }
}, this.typingSpeed) }, 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() { toggleShowAllCaseRefers() {
this.showAllCaseRefers = !this.showAllCaseRefers; this.showAllCaseRefers = !this.showAllCaseRefers;
} // 切换后重新渲染 Mermaid如果内容中有图表
this.$nextTick(this.renderMermaid);
},
}, },
beforeDestroy() { beforeDestroy() {
// 组件销毁前清除定时器
if (this.typingTimer) { if (this.typingTimer) {
clearInterval(this.typingTimer) clearInterval(this.typingTimer);
this.typingTimer = null this.typingTimer = null;
} }
} },
} };
</script> </script>
<template>
<div class="messages">
<!-- 机器人消息-->
<div v-if="messageData.isBot" class="bot-message">
<div class="bot-think" v-if="messageData.thinkText" v-katex:auto v-html="md.render(messageData.thinkText)"></div>
<div v-katex:auto v-html="md.render(displayText)" ></div>
<div v-if="messageData.caseRefers && messageData.caseRefers.length > 0 && messageData.textCompleted">
<div 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 class="case-refers-item" v-for="item in displayedCaseRefers" :key="item.caseId">
<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">{{item.content}}</div>
</div>
</div>
</div>
</div>
</div>
<!-- 非机器人消息-->
<div v-else class="user-message">
<div class="message-text">
<div v-html="messageData.text"></div>
</div>
</div>
<!-- 推荐问题-->
<!-- <div v-if="suggestions && suggestions.length > 0">-->
<!-- <div class="suggestions">-->
<!-- <div class="suggestions-title">-->
<!-- <span>推荐问题</span>-->
<!-- </div>-->
<!-- <div class="suggestions-list">-->
<!-- <div class="suggestions-item" v-for="item in suggestions">-->
<!-- <div class="suggestions-item-title">-->
<!-- {{item}}-->
<!-- </div>-->
<!-- </div>-->
<!-- </div>-->
<!-- </div>-->
<!-- </div>-->
</div>
</template>
<style scoped lang="scss"> <style scoped lang="scss">
::v-deep .mermaid{
width: 100%;
height: auto;
}
::v-deep svg[id^="mermaid-"]{
width: 100%;
height: 100%;
}
.messages { .messages {
width: 100%; width: 100%;
word-wrap: break-word;
.bot-message { .bot-message {
background-color: #fff; background-color: #fff;
@@ -192,125 +234,185 @@ export default {
left: 0; left: 0;
top: -3px; top: -3px;
transform: scaleX(0.5); transform: scaleX(0.5);
margin-right: 5px;
} }
} }
.case-refers { .message-content {
margin-top: 10px; font-size: 14px;
line-height: 1.6;
}
.case-refers-title { .case-content {
font-weight: bold; font-size: 12px !important;
margin-bottom: 5px; margin-top: 8px;
display: flex; padding: 6px 10px;
align-items: center; background-color: #f9f9f9;
justify-content: space-between; border-radius: 4px;
.icon-think { border: 1px solid #eee;
background-image: url("./map.svg") ;
width: 15px;
height: 13px;
display: inline-block;
background-repeat: no-repeat;
background-size: 100% 100%;
}
.more{
font-size: 10px;
padding: 2px 6px;
background-color: #F4F7FD;
border-radius: 5px;
color:#577EE1;font-weight: unset;
cursor: pointer;
}
}
.case-refers-list {
display: flex;
flex-direction: column;
.case-refers-item {
//margin-right: 10px;
margin-bottom: 5px;
border: 1px solid rgba(144, 147, 153, 0.44);
padding: 5px;
border-radius: 5px;
.case-refers-item-title {
font-size: 14px;
margin-bottom: 5px;
font-weight: 600;
color: #000;
display: flex;
align-items: flex-end;
justify-content: space-between;
.title{
max-width: 70% ;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.case-refers-item-timer{
font-size: 10px;
margin-right: 20px;
color:#cecece;
font-weight: unset!important;
}
}
.case-refers-item-author{
display: flex;
align-items: center;
.user{
background-image: url("./user.svg");
width: 15px;
height: 15px;
display: inline-block;
background-repeat: no-repeat;
background-size: 100% 100%;
margin-right: 5px;
}
.case-inter-orginInfo{
font-size: 10px;
color: rgba(144, 147, 153, 0.44);margin-left: 5px;
}
}
.case-refers-item-author,
.case-refers-item-keywords {
font-size: 12px;
font-weight: 600;
color: #000;
}
}
}
} }
} }
.user-message { .user-message {
float: right; float: right;
padding: 5px 15px; padding: 8px 15px;
box-sizing: border-box; max-width: 80%;
background-color: rgba(228, 231, 237, 1); background-color: #e4e7ed;
border-radius: 5px; border-radius: 18px;
margin-bottom: 10px; margin-bottom: 10px;
font-size: 14px;
word-break: break-word;
} }
.case-refers-item-keywords{
margin-top: 5px; /* ========== 案例引用样式 ========== */
span{ .case-refers {
padding: 1px 4px; margin-top: 12px;
background-color: #F4F7FD;
border-radius: 5px; .case-refers-title {
font-size: 10px!important; font-weight: bold;
color:#577EE1 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;
}
} }
span + span{
margin-left: 8px; .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;
}
}
}
} }
} }
.message-content{
font-size: 12px!important; /* ========== 推荐问题 ========== */
margin-top: 5px; .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> </style>

View File

@@ -57,6 +57,13 @@ module.exports = {
// set svg-sprite-loader // set svg-sprite-loader
config.plugins.delete('preload') config.plugins.delete('preload')
config.plugins.delete('prefetch') config.plugins.delete('prefetch')
// 添加对 mathxyjax3 的处理规则
config.module
.rule('mathxyjax3')
.test(/node_modules[\/\\]mathxyjax3[\/\\].*\.js$/)
.use('null-loader')
.loader('null-loader')
.end()
config.module config.module
.rule('svg') .rule('svg')
.exclude.add(resolve('src/icons')) .exclude.add(resolve('src/icons'))