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