Skip to content
GitLab
Projects
Groups
Snippets
/
Help
Help
Support
Community forum
Keyboard shortcuts
?
Submit feedback
Sign in / Register
Toggle navigation
Menu
Open sidebar
水玉婷
ai-wechat
Commits
203ea4e2
Commit
203ea4e2
authored
Nov 27, 2025
by
水玉婷
Browse files
feat:把md跟audio 代码抽离
parent
7d03462d
Changes
5
Hide whitespace changes
Inline
Side-by-side
src/views/Home.vue
View file @
203ea4e2
...
...
@@ -40,8 +40,8 @@
stage
:
'
wechat-demo
'
,
};
//
const dialogSessionId = '20251
028143404893
-0004
5166
';
const
dialogSessionId
=
''
;
const
dialogSessionId
=
'
20251
127180914709
-0004
3912
'
;
//
const dialogSessionId = '';
const
detailData
=
ref
({
title
:
'
国械小智
'
,
});
...
...
src/views/components/AiChat.vue
View file @
203ea4e2
...
...
@@ -89,6 +89,8 @@ import { ref, onMounted, onBeforeUnmount, nextTick, watch } from 'vue';
import dayjs from 'dayjs';
import { post, get } from '@/utils/axios.js'; // 导入axios的post方法
import { tableTemplate } from './tableTemplate';
import { markdownTemplate, isLastBlockMarkdown, getLastMarkdownBlockIndex, mergeMarkdownContent } from './markdownTemplate';
import { audioTemplate, initAudioPlayers, pauseAllOtherAudios } from './audioTemplate';
import { SendOutlined, UserOutlined } from '@ant-design/icons-vue';
import defaultAvatar from '@/assets/logo.png';
import ChartComponent from './ChartComponent.vue'; // 导入独立的图表组件
...
...
@@ -167,6 +169,10 @@ const contentTemplates = {
table: (tableData: any) => {
return tableTemplate(tableData);
},
// Markdown模板 - 使用独立的markdown模板工具
markdown: (content: any) => {
return markdownTemplate(content);
},
// 选项数据模板 - 纯渲染,不允许点击
option: (optionData: any) => {
const { tips, options } = optionData;
...
...
@@ -223,153 +229,10 @@ const contentTemplates = {
></iframe>
</div>`;
},
// 音频消息模板
- 简化版本,移除audio-icon
// 音频消息模板
audio: (audioData: any) => {
const { audioUrl, audioBlob, durationTime } = audioData;
let src = audioUrl;
// 如果提供了Blob对象,创建对象URL
if (audioBlob && !audioUrl) {
src = URL.createObjectURL(audioBlob);
}
// 生成唯一ID用于音频播放器
const audioId = `audio_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
return `<div class="audio-message" data-audio-id="${audioId}">
<div class="audio-player" data-audio-src="${src}">
<div class="audio-wave">
<div class="wave-bar"></div>
<div class="wave-bar"></div>
<div class="wave-bar"></div>
<div class="wave-bar"></div>
<div class="wave-bar"></div>
</div>
<div class="audio-duration">${durationTime+'"' || '0"'}</div>
</div>
<audio id="${audioId}" src="${src}" preload="metadata" style="display: none;"></audio>
</div>`;
return audioTemplate(audioData);
},
// Markdown模板
markdown: (content: any) => {
// 类型检查和默认值处理
if (typeof content !== 'string') {
// 如果是对象,尝试转换为字符串
if (content && typeof content === 'object') {
content = JSON.stringify(content);
} else {
// 其他类型转换为字符串
content = String(content || '');
}
}
// 增强的Markdown解析器
const parseMarkdown = (text: string) => {
// 确保text是字符串
if (typeof text !== 'string') {
text = String(text || '');
}
// 转义HTML特殊字符,防止XSS攻击
text = text
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
// 处理标题(支持1-6级)
text = text.replace(/^######\s+(.*)$/gim, '<h6>$1</h6>');
text = text.replace(/^#####\s+(.*)$/gim, '<h5>$1</h5>');
text = text.replace(/^####\s+(.*)$/gim, '<h4>$1</h4>');
text = text.replace(/^###\s+(.*)$/gim, '<h3>$1</h3>');
text = text.replace(/^##\s+(.*)$/gim, '<h2>$1</h2>');
text = text.replace(/^#\s+(.*)$/gim, '<h1>$1</h1>');
// 处理粗体和斜体
text = text.replace(/\*\*(.*?)\*\*/gim, '<strong>$1</strong>');
text = text.replace(/\*(.*?)\*/gim, '<em>$1</em>');
text = text.replace(/__(.*?)__/gim, '<strong>$1</strong>');
text = text.replace(/_(.*?)_/gim, '<em>$1</em>');
// 处理删除线
text = text.replace(/~~(.*?)~~/gim, '<del>$1</del>');
// 处理代码块(支持语言标识)
text = text.replace(/```(\w+)?\n([\s\S]*?)```/gim, '<pre><code class="language-$1">$2</code></pre>');
text = text.replace(/```([\s\S]*?)```/gim, '<pre><code>$1</code></pre>');
text = text.replace(/`([^`]+)`/gim, '<code>$1</code>');
// 处理链接(支持相对路径和绝对路径)
text = text.replace(/\[([^\]]+)\]\(([^)]+)\)/gim, (match, text, url) => {
// 验证URL格式
const isValidUrl = /^(https?|ftp):\/\/[^\s/$.?#].[^\s]*$/i.test(url);
const target = isValidUrl ? 'target="_blank" rel="noopener noreferrer"' : '';
return `<a href="${url}" ${target}>${text}</a>`;
});
// 处理图片(添加加载失败处理)
text = text.replace(/!\[([^\]]*)\]\(([^)]+)\)/gim, (match, alt, src) => {
return `<img src="${src}" alt="${alt || '图片'}" style="max-width: 100%; height: auto;" onerror="this.style.display='none'" />`;
});
// 处理有序列表
text = text.replace(/^\d+\.\s+(.*)$/gim, '<li>$1</li>');
text = text.replace(/(<li>.*<\/li>)(?=\s*<li>)/gim, '$1');
text = text.replace(/(<li>.*<\/li>)/gim, '<ol>$1</ol>');
// 处理无序列表
text = text.replace(/^[-*+]\s+(.*)$/gim, '<li>$1</li>');
text = text.replace(/(<li>.*<\/li>)/gim, '<ul>$1</ul>');
// 处理引用块
text = text.replace(/^>\s+(.*)$/gim, '<blockquote>$1</blockquote>');
// 处理水平分割线
text = text.replace(/^\s*---\s*$/gim, '<hr />');
text = text.replace(/^\s*\*\*\*\s*$/gim, '<hr />');
// 处理换行(保留段落结构)
text = text.replace(/\n{3,}/g, '\n\n'); // 多个换行合并为两个
text = text.replace(/\n\n/g, '</p><p>');
text = text.replace(/\n/g, '<br>');
text = '<p>' + text + '</p>';
// 清理HTML结构
text = text.replace(/<p><(h[1-6]|ul|ol|blockquote|pre|img|hr)/gim, '</p><$1');
text = text.replace(/(<\/(h[1-6]|ul|ol|blockquote|pre|img|hr)>)<p>/gim, '$1</p><p>');
text = text.replace(/<p>\s*<\/p>/g, ''); // 移除空段落
return text;
};
const htmlContent = parseMarkdown(content);
// 清理HTML内容:移除br标签和空p段落
const cleanHtml = htmlContent
.trim()
// 移除所有<br>标签
.replace(/<br\s*\/?>/gi, '')
// 移除空的<p>段落(只包含空格或换行符)
.replace(/<p[^>]*>\s*<\/p>/gi, '')
// 移除只包含空格的<p>段落
.replace(/<p[^>]*>(\s| )*<\/p>/gi, '')
// 移除连续的空白段落
.replace(/(<\/p>\s*<p[^>]*>)+/gi, '')
// 移除开头和结尾的空白段落
.replace(/^\s*<p[^>]*>\s*<\/p>/gi, '')
.replace(/<p[^>]*>\s*<\/p>\s*$/gi, '')
.trim();
// 检查内容是否为空或只有空白
if (!cleanHtml || cleanHtml === '<p></p>' || cleanHtml === '<p> </p>') {
return ''; // 如果内容为空,返回空字符串不展示
}
return `<div class="message-markdown">
<div class="markdown-content">${cleanHtml}</div>
</div>`;
}
};
// 定义消息类型 - 更新接口添加图表相关字段
...
...
@@ -448,7 +311,6 @@ const handleVoiceAudio = (audioUrl: string, audioBlob?: Blob, durationTime?: num
if (audioUrl) {
sendAudioMessage(audioUrl, durationTime);
}
// 滚动到底部
nextTick(() => {
scrollToBottom();
...
...
@@ -599,44 +461,6 @@ const processSSEMessage = (
// 根据是否为历史数据设置默认展开状态
const defaultThinkBoxExpanded = !isHistoryData;
// 辅助函数:检查最后一个contentBlock是否是type为4的markdown块
const isLastBlockMarkdown = () => {
if (!updatedResponse || updatedResponse.contentBlocks.length === 0) {
return false;
}
const lastBlock = updatedResponse.contentBlocks[updatedResponse.contentBlocks.length - 1];
return lastBlock.content.includes('message-markdown');
};
// 辅助函数:获取最后一个markdown块的索引
const getLastMarkdownBlockIndex = () => {
if (!updatedResponse || updatedResponse.contentBlocks.length === 0) {
return -1;
}
for (let i = updatedResponse.contentBlocks.length - 1; i >= 0; i--) {
if (updatedResponse.contentBlocks[i].content.includes('message-markdown')) {
return i;
}
}
return -1;
};
// 辅助函数:合并markdown内容
const mergeMarkdownContent = (existingContent: string, newContent: string) => {
// 从现有的markdown内容中提取内部内容
const existingInnerContent = existingContent.replace(/<div class="message-markdown">\s*<div class="markdown-content">([\s\S]*?)<\/div>\s*<\/div>/, '$1');
// 从新的markdown内容中提取内部内容
const newInnerContent = newContent.replace(/<div class="message-markdown">\s*<div class="markdown-content">([\s\S]*?)<\/div>\s*<\/div>/, '$1');
// 合并内容并重新包裹
const mergedInnerContent = existingInnerContent + newInnerContent;
return `<div class="message-markdown">
<div class="markdown-content">${mergedInnerContent}</div>
</div>`;
};
switch (contentStatus) {
case -1: // 错误信息
if (updatedResponse) {
...
...
@@ -699,9 +523,9 @@ const processSSEMessage = (
const markdownContent = contentTemplates.markdown(messageContent || '');
// 检查最后一个块是否是markdown块
if (isLastBlockMarkdown()) {
if (isLastBlockMarkdown(
updatedResponse.contentBlocks
)) {
// 合并到现有的markdown块
const lastMarkdownIndex = getLastMarkdownBlockIndex();
const lastMarkdownIndex = getLastMarkdownBlockIndex(
updatedResponse.contentBlocks
);
if (lastMarkdownIndex !== -1) {
updatedResponse.contentBlocks[lastMarkdownIndex].content =
mergeMarkdownContent(
...
...
@@ -1012,7 +836,6 @@ const processHistoryData = (dataArray: any[]) => {
// 处理问题消息
if (data.question || data.audioPath) {
let questionContent = '';
// 检查是否为音频消息
if (data.audioPath) {
// 处理音频消息
...
...
@@ -1171,98 +994,30 @@ defineExpose({
isInThinkingMode // 是否在思考模式
});
// 生命周期
onMounted(() => {
console.log('组件挂载,初始 dialogSessionId:', props.dialogSessionId);
initSSE();
scrollToBottom();
if (props.dialogSessionId) {
getChatRecord(props.dialogSessionId);
}
// 初始化音频播放器事件监听
initAudioPlayers();
});
// 初始化音频播放器
const initAudioPlayers = () => {
const initAudioPlayers
Wrapper
= () => {
// 监听消息变化,为新的音频消息添加事件监听
watch(messages, () => {
nextTick(() => {
setup
AudioPlayers();
init
AudioPlayers();
});
}, { deep: true });
};
// 设置音频播放器事件
const setupAudioPlayers = () => {
const audioPlayers = document.querySelectorAll('.audio-player');
audioPlayers.forEach((player) => {
// 检查是否已经绑定过事件监听器
if (player.hasAttribute('data-audio-bound')) {
return; // 如果已经绑定过,跳过
}
// 标记为已绑定
player.setAttribute('data-audio-bound', 'true');
const audioMessage = player.closest('.audio-message');
const audioId = audioMessage?.getAttribute('data-audio-id');
const audioElement = audioId ? document.getElementById(audioId) : null;
if (!audioElement) {
console.warn('未找到音频元素,audioId:', audioId);
return;
}
// 音频播放结束,重置状态
audioElement.addEventListener('ended', () => {
player.classList.remove('playing');
});
// 设置播放/暂停事件
player.addEventListener('click', (e) => {
e.stopPropagation();
if (audioElement.paused) {
// 暂停其他正在播放的音频
pauseAllOtherAudios(audioElement);
audioElement.play().catch(error => {
console.error('播放音频失败:', error);
});
player.classList.add('playing');
} else {
audioElement.pause();
player.classList.remove('playing');
}
});
// 音频播放事件
audioElement.addEventListener('play', () => {
player.classList.add('playing');
});
// 音频暂停事件
audioElement.addEventListener('pause', () => {
player.classList.remove('playing');
});
});
};
// 生命周期
onMounted(() => {
console.log('组件挂载,初始 dialogSessionId:', props.dialogSessionId);
initSSE();
scrollToBottom();
if (props.dialogSessionId) {
getChatRecord(props.dialogSessionId);
}
// 暂停所有其他正在播放的音频
const pauseAllOtherAudios = (currentAudio: HTMLAudioElement) => {
const allAudios = document.querySelectorAll('audio');
allAudios.forEach((audio) => {
if (audio !== currentAudio && !audio.paused) {
audio.pause();
const player = audio.closest('.audio-message')?.querySelector('.audio-player');
if (player) {
player.classList.remove('playing');
}
}
});
};
// 初始化音频播放器事件监听
initAudioPlayersWrapper();
});
onBeforeUnmount(() => {
closeSSE();
...
...
src/views/components/audioTemplate.ts
0 → 100644
View file @
203ea4e2
/**
* 音频模板工具类
* 用于生成音频消息的HTML模板和音频播放器管理
*/
/**
* 音频消息模板 - 简化版本,移除audio-icon
* @param audioData 音频数据对象
* @returns 音频消息的HTML字符串
*/
export
const
audioTemplate
=
(
audioData
:
any
):
string
=>
{
const
{
audioUrl
,
audioBlob
,
durationTime
}
=
audioData
;
let
src
=
audioUrl
;
// 如果提供了Blob对象,创建对象URL
if
(
audioBlob
&&
!
audioUrl
)
{
src
=
URL
.
createObjectURL
(
audioBlob
);
}
// 生成唯一ID用于音频播放器
const
audioId
=
`audio_
${
Date
.
now
()}
_
${
Math
.
random
().
toString
(
36
).
substr
(
2
,
9
)}
`
;
return
`<div class="audio-message" data-audio-id="
${
audioId
}
">
<div class="audio-player" data-audio-src="
${
src
}
">
<div class="audio-wave">
<div class="wave-bar"></div>
<div class="wave-bar"></div>
<div class="wave-bar"></div>
<div class="wave-bar"></div>
<div class="wave-bar"></div>
</div>
<div class="audio-duration">
${
durationTime
+
'
"
'
||
'
0"
'
}
</div>
</div>
<audio id="
${
audioId
}
" src="
${
src
}
" preload="metadata" style="display: none;"></audio>
</div>`
;
};
/**
* 初始化音频播放器
* 设置音频播放器的事件监听
*/
export
const
initAudioPlayers
=
():
void
=>
{
const
audioPlayers
=
document
.
querySelectorAll
(
'
.audio-player
'
);
audioPlayers
.
forEach
((
player
)
=>
{
// 检查是否已经绑定过事件监听器
if
(
player
.
hasAttribute
(
'
data-audio-bound
'
))
{
return
;
// 如果已经绑定过,跳过
}
// 标记为已绑定
player
.
setAttribute
(
'
data-audio-bound
'
,
'
true
'
);
const
audioMessage
=
player
.
closest
(
'
.audio-message
'
);
const
audioId
=
audioMessage
?.
getAttribute
(
'
data-audio-id
'
);
const
audioElement
=
audioId
?
document
.
getElementById
(
audioId
)
:
null
;
if
(
!
audioElement
)
{
console
.
warn
(
'
未找到音频元素,audioId:
'
,
audioId
);
return
;
}
// 音频播放结束,重置状态
audioElement
.
addEventListener
(
'
ended
'
,
()
=>
{
player
.
classList
.
remove
(
'
playing
'
);
});
// 设置播放/暂停事件
player
.
addEventListener
(
'
click
'
,
(
e
)
=>
{
e
.
stopPropagation
();
if
(
audioElement
.
paused
)
{
// 暂停其他正在播放的音频
pauseAllOtherAudios
(
audioElement
);
audioElement
.
play
().
catch
(
error
=>
{
console
.
error
(
'
播放音频失败:
'
,
error
);
});
player
.
classList
.
add
(
'
playing
'
);
}
else
{
audioElement
.
pause
();
player
.
classList
.
remove
(
'
playing
'
);
}
});
// 音频播放事件
audioElement
.
addEventListener
(
'
play
'
,
()
=>
{
player
.
classList
.
add
(
'
playing
'
);
});
// 音频暂停事件
audioElement
.
addEventListener
(
'
pause
'
,
()
=>
{
player
.
classList
.
remove
(
'
playing
'
);
});
});
};
/**
* 暂停所有其他正在播放的音频
* @param currentAudio 当前正在播放的音频元素
*/
export
const
pauseAllOtherAudios
=
(
currentAudio
:
HTMLAudioElement
):
void
=>
{
const
allAudios
=
document
.
querySelectorAll
(
'
audio
'
);
allAudios
.
forEach
((
audio
)
=>
{
if
(
audio
!==
currentAudio
&&
!
audio
.
paused
)
{
audio
.
pause
();
const
player
=
audio
.
closest
(
'
.audio-message
'
)?.
querySelector
(
'
.audio-player
'
);
if
(
player
)
{
player
.
classList
.
remove
(
'
playing
'
);
}
}
});
};
/**
* 简化的音频模板函数(兼容旧版本)
* @param audioData 音频数据对象
* @returns 音频消息的HTML字符串
*/
export
const
audio
=
(
audioData
:
any
):
string
=>
{
return
audioTemplate
(
audioData
);
};
// 默认导出对象
export
default
{
audioTemplate
,
audio
,
initAudioPlayers
,
pauseAllOtherAudios
};
\ No newline at end of file
src/views/components/markdownTemplate.ts
0 → 100644
View file @
203ea4e2
/**
* Markdown模板工具类
* 用于解析和渲染Markdown内容
*/
/**
* 增强的Markdown解析器
* @param text Markdown文本内容
* @returns 解析后的HTML字符串
*/
export
const
parseMarkdown
=
(
text
:
string
):
string
=>
{
// 确保text是字符串
if
(
typeof
text
!==
'
string
'
)
{
text
=
String
(
text
||
''
);
}
// 先进行HTML转义,防止XSS攻击
text
=
text
.
replace
(
/&/g
,
'
&
'
)
.
replace
(
/</g
,
'
<
'
)
.
replace
(
/>/g
,
'
>
'
)
.
replace
(
/"/g
,
'
"
'
)
.
replace
(
/'/g
,
'
'
'
);
// 然后处理表格(在HTML转义之后)
text
=
text
.
replace
(
/
(\|[^\n]
+
\|(?:\r?\n
|
\r))
+/g
,
(
match
)
=>
{
const
lines
=
match
.
trim
().
split
(
/
\r?\n
/
).
filter
(
line
=>
line
.
trim
()
&&
line
.
includes
(
'
|
'
));
if
(
lines
.
length
<
1
)
return
match
;
try
{
// 检查是否有分隔线
let
hasSeparator
=
false
;
let
headerIndex
=
0
;
let
separatorIndex
=
-
1
;
// 查找分隔线(第二行通常是分隔线)
if
(
lines
.
length
>=
2
)
{
const
secondLine
=
lines
[
1
].
trim
();
// 分隔线通常只包含 |、-、: 和空格
if
(
/^
[\s
|:
\-]
+$/
.
test
(
secondLine
.
replace
(
/
[^
:
\-
|
]
/g
,
''
)))
{
hasSeparator
=
true
;
headerIndex
=
0
;
separatorIndex
=
1
;
}
}
// 解析表头
const
header
=
parseTableRow
(
lines
[
headerIndex
]);
// 确定列对齐方式
const
alignments
=
header
.
map
(()
=>
'
left
'
);
// 默认左对齐
if
(
hasSeparator
&&
separatorIndex
!==
-
1
)
{
const
separatorCells
=
parseTableRow
(
lines
[
separatorIndex
]);
separatorCells
.
forEach
((
cell
,
index
)
=>
{
const
content
=
cell
.
trim
();
if
(
content
.
startsWith
(
'
:
'
)
&&
content
.
endsWith
(
'
:
'
))
{
alignments
[
index
]
=
'
center
'
;
}
else
if
(
content
.
startsWith
(
'
:
'
))
{
alignments
[
index
]
=
'
left
'
;
}
else
if
(
content
.
endsWith
(
'
:
'
))
{
alignments
[
index
]
=
'
right
'
;
}
});
}
// 解析数据行
const
dataRows
=
[];
const
startIndex
=
hasSeparator
?
2
:
1
;
for
(
let
i
=
startIndex
;
i
<
lines
.
length
;
i
++
)
{
dataRows
.
push
(
parseTableRow
(
lines
[
i
]));
}
// 生成表格HTML
let
tableHtml
=
'
<table class="markdown-table" style="border-collapse: collapse; width: 100%; border: 1px solid #ddd; margin: 10px 0;">
\n
'
;
// 表头
tableHtml
+=
'
<thead>
\n
<tr>
\n
'
;
header
.
forEach
((
cell
,
index
)
=>
{
const
align
=
alignments
[
index
]
||
'
left
'
;
tableHtml
+=
` <th style="text-align:
${
align
}
; border: 1px solid #ddd; padding: 8px; background-color: #f5f5f5; font-weight: bold;">
${
cell
}
</th>\n`
;
});
tableHtml
+=
'
</tr>
\n
</thead>
\n
'
;
// 表体
if
(
dataRows
.
length
>
0
)
{
tableHtml
+=
'
<tbody>
\n
'
;
dataRows
.
forEach
(
row
=>
{
tableHtml
+=
'
<tr>
\n
'
;
row
.
forEach
((
cell
,
index
)
=>
{
const
align
=
alignments
[
index
]
||
'
left
'
;
tableHtml
+=
` <td style="text-align:
${
align
}
; border: 1px solid #ddd; padding: 8px;">
${
cell
}
</td>\n`
;
});
tableHtml
+=
'
</tr>
\n
'
;
});
tableHtml
+=
'
</tbody>
\n
'
;
}
tableHtml
+=
'
</table>
'
;
return
tableHtml
;
}
catch
(
error
)
{
console
.
warn
(
'
表格解析失败:
'
,
error
);
return
match
;
}
});
// 处理标题(支持1-6级)
text
=
text
.
replace
(
/^######
\s
+
(
.*
)
$/gim
,
'
<h6>$1</h6>
'
);
text
=
text
.
replace
(
/^#####
\s
+
(
.*
)
$/gim
,
'
<h5>$1</h5>
'
);
text
=
text
.
replace
(
/^####
\s
+
(
.*
)
$/gim
,
'
<h4>$1</h4>
'
);
text
=
text
.
replace
(
/^###
\s
+
(
.*
)
$/gim
,
'
<h3>$1</h3>
'
);
text
=
text
.
replace
(
/^##
\s
+
(
.*
)
$/gim
,
'
<h2>$1</h2>
'
);
text
=
text
.
replace
(
/^#
\s
+
(
.*
)
$/gim
,
'
<h1>$1</h1>
'
);
// 处理粗体和斜体
text
=
text
.
replace
(
/
\*\*(
.*
?)\*\*
/gim
,
'
<strong>$1</strong>
'
);
text
=
text
.
replace
(
/
\*(
.*
?)\*
/gim
,
'
<em>$1</em>
'
);
text
=
text
.
replace
(
/__
(
.*
?)
__/gim
,
'
<strong>$1</strong>
'
);
text
=
text
.
replace
(
/_
(
.*
?)
_/gim
,
'
<em>$1</em>
'
);
// 处理删除线
text
=
text
.
replace
(
/~~
(
.*
?)
~~/gim
,
'
<del>$1</del>
'
);
// 处理代码块(支持语言标识)
text
=
text
.
replace
(
/```
(\w
+
)?\n([\s\S]
*
?)
```/gim
,
'
<pre><code class="language-$1">$2</code></pre>
'
);
text
=
text
.
replace
(
/```
([\s\S]
*
?)
```/gim
,
'
<pre><code>$1</code></pre>
'
);
text
=
text
.
replace
(
/`
([^
`
]
+
)
`/gim
,
'
<code>$1</code>
'
);
// 处理链接(支持相对路径和绝对路径)
text
=
text
.
replace
(
/
\[([^\]]
+
)\]\(([^
)
]
+
)\)
/gim
,
(
match
,
text
,
url
)
=>
{
// 验证URL格式
const
isValidUrl
=
/^
(
https
?
|ftp
)
:
\/\/[^\s/
$.?#
]
.
[^\s]
*$/i
.
test
(
url
);
const
target
=
isValidUrl
?
'
target="_blank" rel="noopener noreferrer"
'
:
''
;
return
`<a href="
${
url
}
"
${
target
}
>
${
text
}
</a>`
;
});
// 处理图片(添加加载失败处理)
text
=
text
.
replace
(
/!
\[([^\]]
*
)\]\(([^
)
]
+
)\)
/gim
,
(
match
,
alt
,
src
)
=>
{
return
`<img src="
${
src
}
" alt="
${
alt
||
'
图片
'
}
" style="max-width: 100%; height: auto;" onerror="this.style.display='none'" />`
;
});
// 处理有序列表
text
=
text
.
replace
(
/^
\d
+
\.\s
+
(
.*
)
$/gim
,
'
<li>$1</li>
'
);
text
=
text
.
replace
(
/
(
<li>.*<
\/
li>
)(?=\s
*<li>
)
/gim
,
'
$1
'
);
text
=
text
.
replace
(
/
(
<li>.*<
\/
li>
)
/gim
,
'
<ol>$1</ol>
'
);
// 处理无序列表
text
=
text
.
replace
(
/^
[
-*+
]\s
+
(
.*
)
$/gim
,
'
<li>$1</li>
'
);
text
=
text
.
replace
(
/
(
<li>.*<
\/
li>
)
/gim
,
'
<ul>$1</ul>
'
);
// 处理引用块
text
=
text
.
replace
(
/^>
\s
+
(
.*
)
$/gim
,
'
<blockquote>$1</blockquote>
'
);
// 处理水平分割线
text
=
text
.
replace
(
/^
\s
*---
\s
*$/gim
,
'
<hr />
'
);
text
=
text
.
replace
(
/^
\s
*
\*\*\*\s
*$/gim
,
'
<hr />
'
);
// 完全删除段落处理逻辑,避免表格被p标签包裹
// 只处理换行,不添加p标签
text
=
text
.
replace
(
/
\n{3,}
/g
,
'
\n\n
'
);
// 多个换行合并为两个
text
=
text
.
replace
(
/
\n\n
/g
,
'
<br><br>
'
);
text
=
text
.
replace
(
/
\n
/g
,
'
<br>
'
);
// 清理HTML结构(移除空行)
text
=
text
.
replace
(
/
(
<br>
\s
*
)
+/g
,
'
<br>
'
);
return
text
;
};
/**
* 改进的表格行解析
* @param line 表格行文本
* @returns 单元格数组
*/
const
parseTableRow
=
(
line
:
string
):
string
[]
=>
{
// 移除行首尾的 | 和空格
const
trimmedLine
=
line
.
trim
().
replace
(
/^
\|
|
\|
$/g
,
''
);
// 按 | 分割,保留空单元格
const
cells
=
trimmedLine
.
split
(
'
|
'
).
map
(
cell
=>
cell
.
trim
());
return
cells
;
};
/**
* Markdown模板函数
* @param content 要处理的Markdown内容
* @returns 渲染后的HTML字符串
*/
export
const
markdownTemplate
=
(
content
:
any
):
string
=>
{
// 类型检查和默认值处理
if
(
typeof
content
!==
'
string
'
)
{
// 如果是对象,尝试转换为字符串
if
(
content
&&
typeof
content
===
'
object
'
)
{
content
=
JSON
.
stringify
(
content
);
}
else
{
// 其他类型转换为字符串
content
=
String
(
content
||
''
);
}
}
const
htmlContent
=
parseMarkdown
(
content
);
// 清理HTML内容:移除br标签和空p段落
const
cleanHtml
=
htmlContent
.
trim
()
// 移除所有<br>标签
.
replace
(
/<br
\s
*
\/?
>/gi
,
''
)
// 移除空的<p>段落(只包含空格或换行符)
.
replace
(
/<p
[^
>
]
*>
\s
*<
\/
p>/gi
,
''
)
// 移除只包含空格的<p>段落
.
replace
(
/<p
[^
>
]
*>
(\s
|
)
*<
\/
p>/gi
,
''
)
// 移除连续的空白段落
.
replace
(
/
(
<
\/
p>
\s
*<p
[^
>
]
*>
)
+/gi
,
''
)
// 移除开头和结尾的空白段落
.
replace
(
/^
\s
*<p
[^
>
]
*>
\s
*<
\/
p>/gi
,
''
)
.
replace
(
/<p
[^
>
]
*>
\s
*<
\/
p>
\s
*$/gi
,
''
)
.
trim
();
// 检查内容是否为空或只有空白
if
(
!
cleanHtml
||
cleanHtml
===
'
<p></p>
'
||
cleanHtml
===
'
<p> </p>
'
)
{
return
''
;
// 如果内容为空,返回空字符串不展示
}
return
`<div class="message-markdown">
<div class="markdown-content">
${
cleanHtml
}
</div>
</div>`
;
};
/**
* 简化的Markdown生成函数(兼容旧版本)
* @param content Markdown内容
* @returns 渲染后的HTML字符串
*/
export
const
markdown
=
(
content
:
any
):
string
=>
{
return
markdownTemplate
(
content
);
};
/**
* 检查内容块是否是markdown块
* @param contentBlock 内容块对象
* @returns 是否是markdown块
*/
export
const
isMarkdownBlock
=
(
contentBlock
:
any
):
boolean
=>
{
return
contentBlock
&&
contentBlock
.
content
&&
contentBlock
.
content
.
includes
(
'
message-markdown
'
);
};
/**
* 检查最后一个contentBlock是否是markdown块
* @param contentBlocks 内容块数组
* @returns 最后一个块是否是markdown块
*/
export
const
isLastBlockMarkdown
=
(
contentBlocks
:
any
[]):
boolean
=>
{
if
(
!
contentBlocks
||
contentBlocks
.
length
===
0
)
{
return
false
;
}
const
lastBlock
=
contentBlocks
[
contentBlocks
.
length
-
1
];
return
isMarkdownBlock
(
lastBlock
);
};
/**
* 获取最后一个markdown块的索引
* @param contentBlocks 内容块数组
* @returns 最后一个markdown块的索引,如果没有找到返回-1
*/
export
const
getLastMarkdownBlockIndex
=
(
contentBlocks
:
any
[]):
number
=>
{
if
(
!
contentBlocks
||
contentBlocks
.
length
===
0
)
{
return
-
1
;
}
for
(
let
i
=
contentBlocks
.
length
-
1
;
i
>=
0
;
i
--
)
{
if
(
isMarkdownBlock
(
contentBlocks
[
i
]))
{
return
i
;
}
}
return
-
1
;
};
/**
* 合并markdown内容
* @param existingContent 现有的markdown内容
* @param newContent 新的markdown内容
* @returns 合并后的markdown内容
*/
export
const
mergeMarkdownContent
=
(
existingContent
:
string
,
newContent
:
string
):
string
=>
{
// 从现有的markdown内容中提取内部内容
const
existingInnerContent
=
existingContent
.
replace
(
/<div class="message-markdown">
\s
*<div class="markdown-content">
([\s\S]
*
?)
<
\/
div>
\s
*<
\/
div>/
,
'
$1
'
);
// 从新的markdown内容中提取内部内容
const
newInnerContent
=
newContent
.
replace
(
/<div class="message-markdown">
\s
*<div class="markdown-content">
([\s\S]
*
?)
<
\/
div>
\s
*<
\/
div>/
,
'
$1
'
);
// 合并内容并重新包裹
const
mergedInnerContent
=
existingInnerContent
+
newInnerContent
;
return
`<div class="message-markdown">
<div class="markdown-content">
${
mergedInnerContent
}
</div>
</div>`
;
};
export
default
{
parseMarkdown
,
markdownTemplate
,
markdown
,
isMarkdownBlock
,
isLastBlockMarkdown
,
getLastMarkdownBlockIndex
,
mergeMarkdownContent
};
\ No newline at end of file
src/views/components/style.less
View file @
203ea4e2
...
...
@@ -1137,7 +1137,9 @@ li {
// 下划线
hr {
border: 0;
border-top: 1px solid @gray-2-3;
border-bottom: 1px solid @gray-2-3;
padding-top: 5px;
margin-bottom: 5px;
}
// 表格样式(如果Markdown中包含表格)
...
...
@@ -1145,22 +1147,79 @@ li {
width: 100%;
border-collapse: collapse;
margin-bottom: 8px;
font-size: 13px;
background-color: @white;
th,
td {
padding: 8px 12px;
border: 1px solid @gray-3;
text-align: left;
line-height: 1.4;
}
th {
background-color: @blue-light-2;
font-weight: 600;
color: @gray-7;
}
tr:nth-child(even) {
background-color: @gray-1;
}
tr:hover {
background-color: @blue-light-1;
}
}
// 专门为markdown表格添加的样式
.markdown-table {
width: 100%;
border-collapse: collapse;
margin: 12px 0;
font-size: 13px;
background-color: @white;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
th {
background-color: @blue-light-2;
font-weight: 600;
color: @gray-7;
border-bottom: 2px solid @primary-color;
padding: 10px 12px;
}
td {
padding: 8px 12px;
border: 1px solid @gray-3;
line-height: 1.4;
}
tr:nth-child(even) {
background-color: @gray-1;
}
tr:hover {
background-color: @blue-light-1;
transition: background-color 0.2s ease;
}
// 对齐方式样式
th[style*="text-align: center"],
td[style*="text-align: center"] {
text-align: center;
}
th[style*="text-align: right"],
td[style*="text-align: right"] {
text-align: right;
}
th[style*="text-align: left"],
td[style*="text-align: left"] {
text-align: left;
}
}
}
}
...
...
Write
Preview
Supports
Markdown
0%
Try again
or
attach a new file
.
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment