支持markdown格式显示,效果

This commit is contained in:
zhonghua.li
2026-03-31 21:45:41 +08:00
parent 22f951b514
commit 99a71cdc69
5 changed files with 400 additions and 18 deletions

View File

@@ -74,7 +74,21 @@
<text class="qa-copy-btn-icon"></text>
</view>
</view>
<text class="qa-msg-text" selectable="true">{{ m.text }}</text>
<!-- 用户消息仍按纯文本展示 -->
<text
v-if="m.role === 'user'"
class="qa-msg-text"
selectable="true"
>{{ m.text }}</text>
<!-- AI 消息支持 Markdown 渲染保留纯文本兜底 -->
<view
v-else
class="qa-msg-text qa-msg-text-markdown"
>
<rich-text
:nodes="renderMarkdownNodes(m.text)"
></rich-text>
</view>
</view>
</view>
</scroll-view>
@@ -601,7 +615,16 @@ export default {
parsed.content ??
parsed.answer ??
parsed.text ??
parsed.data ??
parsed.result ??
parsed.message ??
parsed.output ??
parsed.token ??
'';
// 若对象结构未知,至少回退到原始行,避免“有返回但前端无内容”
if ((textToAppend === undefined || textToAppend === null || String(textToAppend) === '') && raw) {
textToAppend = raw;
}
}
} catch (e) {
// 非 JSON 分片按纯文本处理
@@ -850,6 +873,151 @@ export default {
return String(value);
}
},
/**
* 非常轻量的 Markdown 转换,仅支持:
* - 换行 => <br/>
* - **加粗** => <strong>
* - *斜体* => <em>
* - 无序列表行 "- " => <ul><li>
* - 代码块 ``` => <pre><code>
* 复杂 Markdown 语法暂不完全覆盖,但足够保证结构清晰。
*/
parseInlineMarkdownToNodes(line) {
const text = String(line || '');
const nodes = [];
const tokenRe = /(\*\*[^*]+\*\*|`[^`\n]+`|\*[^*\n]+\*)/g;
let lastIndex = 0;
let match = null;
while ((match = tokenRe.exec(text)) !== null) {
const idx = match.index;
if (idx > lastIndex) {
nodes.push({ type: 'text', text: text.slice(lastIndex, idx) });
}
const token = match[0];
if (token.startsWith('**') && token.endsWith('**')) {
nodes.push({
name: 'strong',
attrs: { style: 'font-weight:600;' },
children: [{ type: 'text', text: token.slice(2, -2) }]
});
} else if (token.startsWith('`') && token.endsWith('`')) {
nodes.push({
name: 'span',
attrs: {
style: 'font-family:monospace;background:#f2f2f4;padding:1px 4px;border-radius:3px;'
},
children: [{ type: 'text', text: token.slice(1, -1) }]
});
} else if (token.startsWith('*') && token.endsWith('*')) {
nodes.push({
name: 'em',
attrs: { style: 'font-style:italic;' },
children: [{ type: 'text', text: token.slice(1, -1) }]
});
} else {
nodes.push({ type: 'text', text: token });
}
lastIndex = idx + token.length;
}
if (lastIndex < text.length) {
nodes.push({ type: 'text', text: text.slice(lastIndex) });
}
if (nodes.length === 0) {
nodes.push({ type: 'text', text: '' });
}
return nodes;
},
renderMarkdownNodes(text) {
try {
const raw = String(text || '');
const lines = raw.replace(/\r\n/g, '\n').split('\n');
const nodes = [];
let i = 0;
let inCode = false;
let codeLines = [];
while (i < lines.length) {
const line = lines[i];
// 代码块围栏
if (line.trim().startsWith('```')) {
if (!inCode) {
inCode = true;
codeLines = [];
} else {
inCode = false;
nodes.push({
name: 'div',
attrs: {
style: 'background:#f7f7f9;border-radius:6px;padding:8px 10px;font-family:monospace;white-space:pre-wrap;word-break:break-word;'
},
children: [{ type: 'text', text: codeLines.join('\n') }]
});
}
i += 1;
continue;
}
if (inCode) {
codeLines.push(line);
i += 1;
continue;
}
// 无序列表
if (/^\s*-\s+/.test(line)) {
const ulChildren = [];
while (i < lines.length && /^\s*-\s+/.test(lines[i])) {
const content = lines[i].replace(/^\s*-\s+/, '');
ulChildren.push({
name: 'li',
attrs: { style: 'margin:4px 0;' },
children: this.parseInlineMarkdownToNodes(content)
});
i += 1;
}
nodes.push({
name: 'ul',
attrs: { style: 'padding-left:18px;margin:6px 0;' },
children: ulChildren
});
continue;
}
// 空行
if (!line.trim()) {
nodes.push({ name: 'div', children: [{ type: 'text', text: ' ' }] });
i += 1;
continue;
}
// 普通段落
nodes.push({
name: 'div',
attrs: { style: 'line-height:1.6;margin:2px 0;word-break:break-word;' },
children: this.parseInlineMarkdownToNodes(line)
});
i += 1;
}
// 兜底:如果代码块未闭合,也照样展示
if (inCode && codeLines.length > 0) {
nodes.push({
name: 'div',
attrs: {
style: 'background:#f7f7f9;border-radius:6px;padding:8px 10px;font-family:monospace;white-space:pre-wrap;word-break:break-word;'
},
children: [{ type: 'text', text: codeLines.join('\n') }]
});
}
return nodes;
} catch (e) {
return [{ type: 'text', text: String(text || '') }];
}
},
onSend() {
const text = (this.message || '').trim();
if (!text) {
@@ -1060,6 +1228,48 @@ export default {
user-select: text;
}
.qa-msg-text-markdown {
padding-top: 6rpx;
}
.md-root {
font-size: 26rpx;
line-height: 1.6;
color: #333333;
}
.md-root strong {
font-weight: 600;
}
.md-root em {
font-style: italic;
}
.md-root ul {
padding-left: 32rpx;
margin: 8rpx 0;
}
.md-root li {
margin: 4rpx 0;
}
.md-code-block {
background-color: #f7f7f9;
border-radius: 8rpx;
padding: 10rpx 14rpx;
font-family: monospace;
overflow-x: auto;
}
.md-inline-code {
font-family: monospace;
background-color: #f2f2f4;
padding: 2rpx 6rpx;
border-radius: 4rpx;
}
/* 复制按钮在角上绝对定位;气泡已有左右 padding再给正文加一点边距避免与 44rpx 按钮重叠 */
.qa-msg--ai .qa-msg-text {
padding-left: 40rpx;