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
5e75750d
Commit
5e75750d
authored
Dec 01, 2025
by
水玉婷
Browse files
feat:优化md中有序、无序列表输出格式问题
parent
f8edf3b5
Changes
4
Hide whitespace changes
Inline
Side-by-side
src/views/Home.vue
View file @
5e75750d
...
...
@@ -40,8 +40,8 @@
stage
:
'
wechat-demo
'
,
};
//
const dialogSessionId = '20251127180914709-00043912';
const
dialogSessionId
=
''
;
const
dialogSessionId
=
'
20251127180914709-00043912
'
;
//
const dialogSessionId = '';
const
detailData
=
ref
({
title
:
'
国械小智
'
,
});
...
...
src/views/components/AiChat.vue
View file @
5e75750d
...
...
@@ -478,6 +478,7 @@ const getChatRecord = async (dialogSessionId: string) => {
const
response
=
await
fetch
(
`
${
props
.
apiBaseUrl
}
/aiService/ask/list/chat/
${
dialogSessionId
}
`
,
{
method
:
'
GET
'
,
headers
:
{
'
Content-Type
'
:
'
application/json
'
,
'
token
'
:
props
.
token
,
'
x-session-id
'
:
props
.
token
,
'
x-app-code
'
:
props
.
appCode
||
''
...
...
src/views/components/style.less
View file @
5e75750d
...
...
@@ -1060,8 +1060,7 @@ li {
// 表格样式(如果Markdown中包含表格)
table {
width: 100%;
border-collapse: collapse;
margin-bottom: 8px;
border-collapse: collapse;
font-size: 13px;
background-color: @white;
...
...
@@ -1093,7 +1092,6 @@ li {
width: 100%;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
border-radius: 8px;
font-size:0;
// 滚动条样式
...
...
src/views/components/utils/markdownTemplate.ts
View file @
5e75750d
...
...
@@ -21,13 +21,13 @@ const processMarkdownFormat = (text: string): string => {
processedText
=
processedText
.
replace
(
/
\*(
.*
?)\*
/gim
,
'
<em>$1</em>
'
);
processedText
=
processedText
.
replace
(
/__
(
.*
?)
__/gim
,
'
<strong>$1</strong>
'
);
processedText
=
processedText
.
replace
(
/_
(
.*
?)
_/gim
,
'
<em>$1</em>
'
);
// 处理删除线
processedText
=
processedText
.
replace
(
/~~
(
.*
?)
~~/gim
,
'
<del>$1</del>
'
);
// 处理行内代码
processedText
=
processedText
.
replace
(
/`
([^
`
]
+
)
`/gim
,
'
<code>$1</code>
'
);
return
processedText
;
};
...
...
@@ -41,7 +41,7 @@ const processMarkdownFormat = (text: string): string => {
const
generateCompleteTable
=
(
header
:
string
[],
alignments
:
string
[],
dataRows
:
string
[][]):
string
=>
{
let
tableHtml
=
'
<div class="table-container">
\n
'
;
tableHtml
+=
'
<table class="markdown-table" style="border-collapse: collapse; width: auto; min-width: 100%; border: 1px solid #ddd;">
\n
'
;
// 表头
tableHtml
+=
'
<thead>
\n
<tr>
\n
'
;
header
.
forEach
((
cell
,
index
)
=>
{
...
...
@@ -50,7 +50,7 @@ const generateCompleteTable = (header: string[], alignments: string[], dataRows:
tableHtml
+=
` <th style="text-align:
${
align
}
; border: 1px solid #ddd; padding: 8px; background-color: #f5f5f5; font-weight: bold;">
${
processedCell
}
</th>\n`
;
});
tableHtml
+=
'
</tr>
\n
</thead>
\n
'
;
// 表体
if
(
dataRows
.
length
>
0
)
{
tableHtml
+=
'
<tbody>
\n
'
;
...
...
@@ -65,7 +65,7 @@ const generateCompleteTable = (header: string[], alignments: string[], dataRows:
});
tableHtml
+=
'
</tbody>
\n
'
;
}
tableHtml
+=
'
</table>
\n
'
;
tableHtml
+=
'
</div>
'
;
return
tableHtml
;
...
...
@@ -98,16 +98,16 @@ export const parseMarkdown = (text: string): string => {
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
<
2
)
return
match
;
try
{
// 解析表头
const
headerRow
=
lines
[
0
];
const
headerCells
=
headerRow
.
split
(
'
|
'
).
slice
(
1
,
-
1
).
map
(
cell
=>
cell
.
trim
());
// 解析对齐方式
let
alignments
:
string
[]
=
[];
let
dataStartIndex
=
1
;
// 查找分隔线
for
(
let
i
=
1
;
i
<
lines
.
length
;
i
++
)
{
const
line
=
lines
[
i
];
...
...
@@ -122,12 +122,12 @@ export const parseMarkdown = (text: string): string => {
break
;
}
}
// 如果没有找到分隔线,使用默认对齐方式
if
(
alignments
.
length
===
0
)
{
alignments
=
headerCells
.
map
(()
=>
'
left
'
);
}
// 解析数据行
const
dataRows
:
string
[][]
=
[];
for
(
let
i
=
dataStartIndex
;
i
<
lines
.
length
;
i
++
)
{
...
...
@@ -135,7 +135,7 @@ export const parseMarkdown = (text: string): string => {
const
cells
=
row
.
split
(
'
|
'
).
slice
(
1
,
-
1
).
map
(
cell
=>
cell
.
trim
());
dataRows
.
push
(
cells
);
}
// 生成完整表格HTML
return
generateCompleteTable
(
headerCells
,
alignments
,
dataRows
);
}
catch
(
error
)
{
...
...
@@ -159,11 +159,6 @@ export const parseMarkdown = (text: string): string => {
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
'
);
...
...
@@ -286,7 +281,7 @@ export const isTableSeparator = (text: string): boolean => {
if
(
!
trimmedText
.
startsWith
(
'
|
'
)
||
!
trimmedText
.
endsWith
(
'
|
'
))
{
return
false
;
}
const
cells
=
trimmedText
.
split
(
'
|
'
).
slice
(
1
,
-
1
).
map
(
cell
=>
cell
.
trim
());
return
cells
.
every
(
cell
=>
/^:
?
-+:
?
$/
.
test
(
cell
));
};
...
...
@@ -311,7 +306,7 @@ export const isTableStart = (text: string): boolean => {
class
StreamingTableProcessor
{
private
tableData
:
string
[]
=
[];
private
isInTable
:
boolean
=
false
;
/**
* 处理新的文本内容
* @param text 新的文本内容
...
...
@@ -321,9 +316,9 @@ class StreamingTableProcessor {
if
(
!
text
||
typeof
text
!==
'
string
'
)
{
return
null
;
}
const
trimmedText
=
text
.
trim
();
// 检查是否是表格行
if
(
isTableRow
(
trimmedText
))
{
if
(
!
this
.
isInTable
)
{
...
...
@@ -334,7 +329,7 @@ class StreamingTableProcessor {
}
else
{
// 继续当前表格,收集所有表格行
this
.
tableData
.
push
(
trimmedText
);
// 不立即生成表格,继续收集直到遇到非表格行
return
null
;
// 继续收集表格行
}
...
...
@@ -343,7 +338,7 @@ class StreamingTableProcessor {
if
(
this
.
isInTable
&&
this
.
tableData
.
length
>
0
)
{
// 表格结束,处理缓存的表格数据
let
result
:
string
|
null
=
null
;
// 检查表格是否至少包含表头和数据行(至少2行)
if
(
this
.
tableData
.
length
>=
2
)
{
// 表格完整,生成完整表格
...
...
@@ -352,16 +347,16 @@ class StreamingTableProcessor {
// 表格不完整,显示加载提示
result
=
'
<div class="table-loading" style="border: 1px solid #ddd; padding: 10px; background-color: #f9f9f9; margin: 10px 0;">表格数据加载中...</div>
'
;
}
this
.
reset
();
return
result
;
}
// 普通文本,直接返回
return
text
;
}
}
/**
* 生成完整表格HTML
* @returns 表格HTML
...
...
@@ -370,7 +365,7 @@ class StreamingTableProcessor {
if
(
this
.
tableData
.
length
<
2
)
{
return
''
;
}
try
{
// 解析表头
const
headerRow
=
this
.
tableData
[
0
];
...
...
@@ -378,11 +373,11 @@ class StreamingTableProcessor {
// 对表头单元格进行Markdown格式处理
return
processMarkdownFormat
(
cell
.
trim
());
});
// 解析对齐方式(从第二行)
let
alignments
:
string
[]
=
[];
let
dataStartIndex
=
1
;
// 查找分隔线
for
(
let
i
=
1
;
i
<
this
.
tableData
.
length
;
i
++
)
{
if
(
isTableSeparator
(
this
.
tableData
[
i
]))
{
...
...
@@ -397,13 +392,13 @@ class StreamingTableProcessor {
break
;
}
}
// 如果没有找到分隔线,使用默认对齐方式,数据从第二行开始
if
(
alignments
.
length
===
0
)
{
alignments
=
headerCells
.
map
(()
=>
'
left
'
);
dataStartIndex
=
1
;
// 对于无分隔线表格,数据从第二行开始
}
// 解析数据行
const
dataRows
:
string
[][]
=
[];
for
(
let
i
=
dataStartIndex
;
i
<
this
.
tableData
.
length
;
i
++
)
{
...
...
@@ -414,7 +409,7 @@ class StreamingTableProcessor {
});
dataRows
.
push
(
cells
);
}
// 生成完整表格HTML
return
generateCompleteTable
(
headerCells
,
alignments
,
dataRows
);
}
catch
(
error
)
{
...
...
@@ -422,7 +417,7 @@ class StreamingTableProcessor {
return
'
<div class="table-error" style="border: 1px solid #ff6b6b; padding: 10px; background-color: #ffeaea; margin: 10px 0;">表格渲染失败</div>
'
;
}
}
/**
* 重置表格处理器状态
*/
...
...
@@ -430,7 +425,7 @@ class StreamingTableProcessor {
this
.
tableData
=
[];
this
.
isInTable
=
false
;
}
/**
* 获取当前表格状态
*/
...
...
@@ -445,6 +440,175 @@ class StreamingTableProcessor {
// 创建全局表格处理器实例
const
streamingTableProcessor
=
new
StreamingTableProcessor
();
/**
* 检查文本是否是列表项(有序或无序)
* @param text 要检查的文本
* @returns 是否是列表项
*/
export
const
isListItem
=
(
text
:
string
):
boolean
=>
{
if
(
!
text
||
typeof
text
!==
'
string
'
)
{
return
false
;
}
const
trimmedText
=
text
.
trim
();
// 匹配有序列表(1. 2. 等)或无序列表(- * +)
return
/^
\d
+
\.\s
+/
.
test
(
trimmedText
)
||
/^
[
-*+
]\s
+/
.
test
(
trimmedText
);
};
/**
* 检查文本是否是有序列表项
* @param text 要检查的文本
* @returns 是否是有序列表项
*/
export
const
isOrderedListItem
=
(
text
:
string
):
boolean
=>
{
if
(
!
text
||
typeof
text
!==
'
string
'
)
{
return
false
;
}
const
trimmedText
=
text
.
trim
();
return
/^
\d
+
\.\s
+/
.
test
(
trimmedText
);
};
/**
* 检查文本是否是无序列表项
* @param text 要检查的文本
* @returns 是否是无序列表项
*/
export
const
isUnorderedListItem
=
(
text
:
string
):
boolean
=>
{
if
(
!
text
||
typeof
text
!==
'
string
'
)
{
return
false
;
}
const
trimmedText
=
text
.
trim
();
return
/^
[
-*+
]\s
+/
.
test
(
trimmedText
);
};
/**
* 流式列表处理器
* 用于处理SSE逐条返回的列表数据
*/
class
StreamingListProcessor
{
private
listItems
:
string
[]
=
[];
private
isInList
:
boolean
=
false
;
private
listType
:
'
ordered
'
|
'
unordered
'
|
null
=
null
;
private
orderedListStartNumber
:
number
=
1
;
// 记录有序列表的起始序号
/**
* 处理新的文本内容
* @param text 新的文本内容
* @returns 处理结果:如果是列表项返回null,列表完成时返回完整列表HTML
*/
public
processText
(
text
:
string
):
string
|
null
{
if
(
!
text
||
typeof
text
!==
'
string
'
)
{
return
null
;
}
const
trimmedText
=
text
.
trim
();
// 检查是否是列表项
if
(
isListItem
(
trimmedText
))
{
const
isOrdered
=
isOrderedListItem
(
trimmedText
);
const
currentListType
=
isOrdered
?
'
ordered
'
:
'
unordered
'
;
if
(
!
this
.
isInList
)
{
// 开始新的列表
this
.
isInList
=
true
;
this
.
listType
=
currentListType
;
this
.
listItems
=
[
trimmedText
];
// 如果是有序列表,记录起始序号
if
(
isOrdered
)
{
const
match
=
trimmedText
.
match
(
/^
(\d
+
)\.\s
+/
);
this
.
orderedListStartNumber
=
match
?
parseInt
(
match
[
1
])
:
1
;
}
return
null
;
// 列表开始,不立即渲染
}
else
if
(
this
.
listType
===
currentListType
)
{
// 继续当前列表,收集所有列表项
this
.
listItems
.
push
(
trimmedText
);
return
null
;
// 继续收集列表项
}
else
{
// 列表类型改变,结束当前列表并开始新的列表
const
result
=
this
.
generateCompleteList
();
this
.
isInList
=
true
;
this
.
listType
=
currentListType
;
this
.
listItems
=
[
trimmedText
];
// 如果是有序列表,记录起始序号
if
(
isOrdered
)
{
const
match
=
trimmedText
.
match
(
/^
(\d
+
)\.\s
+/
);
this
.
orderedListStartNumber
=
match
?
parseInt
(
match
[
1
])
:
1
;
}
return
result
;
}
}
else
{
// 非列表项
if
(
this
.
isInList
&&
this
.
listItems
.
length
>
0
)
{
// 列表结束,处理缓存的列表数据
const
result
=
this
.
generateCompleteList
();
this
.
reset
();
return
result
;
}
// 普通文本,直接返回
return
text
;
}
}
/**
* 生成完整列表HTML
* @returns 列表HTML
*/
private
generateCompleteList
():
string
{
if
(
this
.
listItems
.
length
===
0
)
{
return
''
;
}
try
{
// 处理每个列表项的内容
const
processedItems
=
this
.
listItems
.
map
((
item
,
index
)
=>
{
if
(
this
.
listType
===
'
ordered
'
)
{
// 有序列表:移除数字标记,保留内容,但保持正确的序号
const
content
=
item
.
replace
(
/^
\d
+
\.\s
+/
,
''
).
trim
();
const
processedContent
=
processMarkdownFormat
(
content
);
return
`<li value="
${
this
.
orderedListStartNumber
+
index
}
">
${
processedContent
}
</li>`
;
}
else
{
// 无序列表:移除标记,保留内容
const
content
=
item
.
replace
(
/^
[
-*+
]\s
+/
,
''
).
trim
();
const
processedContent
=
processMarkdownFormat
(
content
);
return
`<li>
${
processedContent
}
</li>`
;
}
});
// 根据列表类型生成完整列表
const
listTag
=
this
.
listType
===
'
ordered
'
?
'
ol
'
:
'
ul
'
;
return
`<
${
listTag
}
>
${
processedItems
.
join
(
''
)}
</
${
listTag
}
>`
;
}
catch
(
error
)
{
console
.
warn
(
'
列表生成失败:
'
,
error
);
return
'
<div class="list-error" style="border: 1px solid #ff6b6b; padding: 10px; background-color: #ffeaea; margin: 10px 0;">列表渲染失败</div>
'
;
}
}
/**
* 重置列表处理器状态
*/
private
reset
():
void
{
this
.
listItems
=
[];
this
.
isInList
=
false
;
this
.
listType
=
null
;
this
.
orderedListStartNumber
=
1
;
}
/**
* 获取当前列表状态
*/
public
getListState
():
{
isInList
:
boolean
;
listType
:
'
ordered
'
|
'
unordered
'
|
null
;
listItems
:
string
[];
orderedListStartNumber
:
number
}
{
return
{
isInList
:
this
.
isInList
,
listType
:
this
.
listType
,
listItems
:
[...
this
.
listItems
],
orderedListStartNumber
:
this
.
orderedListStartNumber
};
}
}
// 创建全局列表处理器实例
const
streamingListProcessor
=
new
StreamingListProcessor
();
/**
* 处理流式Markdown文本(专门用于SSE逐条返回的数据)
* @param text 新的文本内容
...
...
@@ -454,23 +618,36 @@ export const processStreamingMarkdown = (text: string): string => {
// 清理HTML标签,防止XSS攻击
text
=
text
.
replace
(
/<script
\b[^
<
]
*
(?:(?!
<
\/
script>
)
<
[^
<
]
*
)
*<
\/
script>/gi
,
''
);
text
=
text
.
replace
(
/<
[^
>
]
*>/g
,
''
);
// 使用流式列表处理器处理文本
const
listResult
=
streamingListProcessor
.
processText
(
text
);
if
(
listResult
===
null
)
{
// 列表项被收集,不立即渲染
return
''
;
}
// 如果列表处理器返回了完整列表,直接返回
if
(
listResult
&&
(
listResult
.
includes
(
'
<ol>
'
)
||
listResult
.
includes
(
'
<ul>
'
)))
{
return
listResult
;
}
// 使用流式表格处理器处理文本
const
r
esult
=
streamingTableProcessor
.
processText
(
text
);
if
(
r
esult
===
null
)
{
const
tableR
esult
=
streamingTableProcessor
.
processText
(
text
);
if
(
tableR
esult
===
null
)
{
// 表格行被收集,不立即渲染
return
''
;
}
// 如果表格处理器返回了完整表格,需要确保表格内容中的Markdown格式也被处理
if
(
r
esult
.
includes
(
'
<table
'
))
{
if
(
tableR
esult
.
includes
(
'
<table
'
))
{
// 对表格内容进行Markdown格式处理
return
processMarkdownFormat
(
r
esult
);
return
processMarkdownFormat
(
tableR
esult
);
}
// 否则,处理其他Markdown语法
return
parseMarkdown
(
resul
t
);
return
parseMarkdown
(
tableResult
||
tex
t
);
};
/**
...
...
@@ -525,11 +702,14 @@ export const mergeMarkdownContent = (existingContent: string, newContent: string
};
export
default
{
parseMarkdown
,
markdownTemplate
,
markdown
,
isMarkdownBlock
,
isLastBlockMarkdown
,
getLastMarkdownBlockIndex
,
mergeMarkdownContent
parseMarkdown
,
// 解析Markdown文本为HTML
markdownTemplate
,
// 应用Markdown模板
markdown
,
// 完整的Markdown处理函数
isMarkdownBlock
,
// 检查是否为Markdown块
isLastBlockMarkdown
,
// 检查最后一个块是否为Markdown
getLastMarkdownBlockIndex
,
// 获取最后一个Markdown块的索引
mergeMarkdownContent
,
// 合并Markdown内容
isListItem
,
// 检查是否为列表项
isOrderedListItem
,
// 检查是否为有序列表项
isUnorderedListItem
// 检查是否为无序列表项
};
\ No newline at end of file
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