main: 增加全局暗黑模式支持及 Markdown 渲染功能

- 实现暗黑模式切换逻辑,支持系统主题适配
- 优化全局样式,添加动态主题变量及暗黑样式
- 引入 `markdown-it` 和 `mermaid`,支持 Markdown 内容及流程图渲染
- 更新 ChatView 测试用例,验证 Markdown 和 Mermaid 渲染效果
- 修改 package.json,增加新依赖
This commit is contained in:
2025-12-22 12:37:59 +08:00
parent 9b48ff3e3b
commit 9280fbe762
13 changed files with 1846 additions and 165 deletions

View File

@@ -11,6 +11,8 @@ import {
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import axios from 'axios'
import MarkdownIt from 'markdown-it'
import mermaid from 'mermaid'
const route = useRoute()
const router = useRouter()
@@ -29,6 +31,49 @@ const sseRetryTimer = ref(null)
const archiveLoading = ref(false)
const sampleImage =
'data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="220" height="140" viewBox="0 0 220 140"><defs><linearGradient id="g" x1="0%" y1="0%" x2="100%" y2="100%"><stop stop-color="%23e0e9ff" offset="0%"/><stop stop-color="%23c7d8ff" offset="50%"/><stop stop-color="%23e8f1ff" offset="100%"/></linearGradient></defs><rect width="220" height="140" rx="12" fill="url(%23g)"/><text x="50%" y="52%" dominant-baseline="middle" text-anchor="middle" fill="%231d4ed8" font-family="Arial" font-size="16">mock image</text></svg>'
const markdown = new MarkdownIt({
html: false,
linkify: true,
breaks: true,
typographer: true,
})
const defaultFence = markdown.renderer.rules.fence
markdown.renderer.rules.fence = (tokens, idx, options, env, self) => {
const token = tokens[idx]
const lang = (token.info || '').trim().toLowerCase()
if (lang === 'mermaid') {
const code = markdown.utils.escapeHtml(token.content || '')
return `<div class="mermaid">${code}</div>`
}
return defaultFence
? defaultFence(tokens, idx, options, env, self)
: self.renderToken(tokens, idx, options)
}
const defaultLinkOpen = markdown.renderer.rules.link_open
markdown.renderer.rules.link_open = (tokens, idx, options, env, self) => {
tokens[idx].attrSet('target', '_blank')
tokens[idx].attrSet('rel', 'noreferrer noopener')
return defaultLinkOpen
? defaultLinkOpen(tokens, idx, options, env, self)
: self.renderToken(tokens, idx, options)
}
const renderMarkdown = (text = '') => markdown.render(text || '')
let mermaidInitialized = false
const setupMermaid = () => {
if (mermaidInitialized || typeof window === 'undefined') return
mermaid.initialize({ startOnLoad: false, securityLevel: 'strict' })
mermaidInitialized = true
}
const renderMermaid = () => {
if (typeof window === 'undefined') return
const nodes = document.querySelectorAll('.bubble-text .mermaid:not([data-processed])')
if (!nodes.length) return
mermaid
.run({ nodes })
.catch(() => {
/* ignore render errors */
})
}
const sessionId = ref(
route.params.id
@@ -759,9 +804,24 @@ watch(
{ immediate: true }
)
watch(
messages,
() => {
nextTick(() => {
setupMermaid()
renderMermaid()
})
},
{ deep: true }
)
onMounted(() => {
setupMermaid()
window.addEventListener('scroll', handleWindowScroll, { passive: true })
nextTick(() => handleWindowScroll())
nextTick(() => {
handleWindowScroll()
renderMermaid()
})
})
onBeforeUnmount(() => {
@@ -892,7 +952,10 @@ onBeforeUnmount(() => {
</div>
</template>
<template v-else>
<p class="bubble-text">{{ msg.text }}</p>
<div
class="bubble-text"
v-html="renderMarkdown(msg.text)"
/>
</template>
<div v-if="msg.attachments?.length" class="attachment-list">
<div
@@ -1006,6 +1069,7 @@ onBeforeUnmount(() => {
flex-direction: column;
gap: 16px;
min-height: 80vh;
color: var(--text-primary);
}
.session-hero {
@@ -1014,14 +1078,14 @@ onBeforeUnmount(() => {
gap: 18px;
padding: 18px 20px;
border-radius: 16px;
background: linear-gradient(135deg, #f5f8ff, #edf2ff);
border: 1px solid rgba(0, 0, 0, 0.04);
box-shadow: 0 18px 38px rgba(17, 24, 39, 0.08);
background: var(--panel-hero-bg);
border: 1px solid var(--panel-hero-border);
box-shadow: var(--shadow-1);
}
.hero-left h2 {
margin: 6px 0;
color: #0f172a;
color: var(--text-primary);
font-size: 24px;
}
@@ -1029,7 +1093,7 @@ onBeforeUnmount(() => {
margin: 0;
font-size: 13px;
letter-spacing: 0.08em;
color: #6b7280;
color: var(--text-muted);
text-transform: uppercase;
}
@@ -1037,12 +1101,12 @@ onBeforeUnmount(() => {
display: flex;
align-items: center;
gap: 10px;
color: #6b7280;
color: var(--text-muted);
font-size: 13px;
}
.muted {
color: #6b7280;
color: var(--text-muted);
}
.chips {
@@ -1055,8 +1119,8 @@ onBeforeUnmount(() => {
.chip {
padding: 6px 10px;
border-radius: 10px;
background: rgba(37, 99, 235, 0.08);
color: #1d4ed8;
background: var(--chip-bg);
color: var(--chip-text);
font-size: 12px;
letter-spacing: 0.01em;
}
@@ -1071,14 +1135,14 @@ onBeforeUnmount(() => {
.stat {
padding: 12px 14px;
border-radius: 12px;
background: rgba(255, 255, 255, 0.85);
border: 1px solid rgba(0, 0, 0, 0.04);
background: var(--surface);
border: 1px solid var(--border-soft);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.8);
}
.stat-label {
margin: 0 0 4px;
color: #6b7280;
color: var(--text-muted);
font-size: 13px;
}
@@ -1086,7 +1150,7 @@ onBeforeUnmount(() => {
margin: 0;
font-size: 20px;
font-weight: 700;
color: #111827;
color: var(--text-primary);
}
.chat-window {
@@ -1095,9 +1159,9 @@ onBeforeUnmount(() => {
gap: 12px;
padding: 16px;
border-radius: 16px;
background: rgba(255, 255, 255, 0.9);
border: 1px solid rgba(0, 0, 0, 0.04);
box-shadow: 0 18px 32px rgba(17, 24, 39, 0.08);
background: var(--surface);
border: 1px solid var(--border-soft);
box-shadow: var(--shadow-1);
align-items: center;
}
@@ -1105,8 +1169,8 @@ onBeforeUnmount(() => {
display: flex;
flex-direction: column;
gap: 12px;
width: min(980px, 100%);
padding: 0 12px;
width: min(1120px, 100%);
padding: 0 16px;
margin: 0 auto;
}
@@ -1127,13 +1191,13 @@ onBeforeUnmount(() => {
align-items: center;
gap: 8px;
padding: 8px 0;
background: linear-gradient(180deg, rgba(255, 255, 255, 0), rgba(255, 255, 255, 0.8));
background: linear-gradient(180deg, transparent, var(--overlay-weak));
}
.jump-down {
border: 1px solid rgba(37, 99, 235, 0.2);
background: rgba(255, 255, 255, 0.92);
color: #1d4ed8;
background: var(--surface-strong);
color: var(--accent);
border-radius: 999px;
padding: 6px 12px;
box-shadow: 0 10px 20px rgba(17, 24, 39, 0.08);
@@ -1149,28 +1213,27 @@ onBeforeUnmount(() => {
.bubble {
padding: 14px 16px;
border-radius: 16px;
border: 1px solid rgba(0, 0, 0, 0.05);
max-width: 820px;
box-shadow: 0 10px 20px rgba(17, 24, 39, 0.06);
width: calc(100% - 48px);
border: 1px solid var(--border-soft);
max-width: 1040px;
box-shadow: var(--shadow-2);
width: min(1040px, calc(100% - 24px));
align-self: center;
background: #ffffff;
background: var(--surface-strong);
word-break: break-word;
white-space: pre-wrap;
position: relative;
}
.bubble-user {
border-color: rgba(37, 99, 235, 0.18);
background: linear-gradient(135deg, #edf2ff, #e5edff);
border-color: var(--bubble-user-border);
background: var(--bubble-user-bg);
}
.bubble-agent {
border-color: rgba(0, 0, 0, 0.04);
border-color: var(--bubble-agent-border);
}
.bubble-system {
border-color: rgba(239, 68, 68, 0.18);
background: linear-gradient(135deg, #fff1f2, #ffe4e6);
border-color: var(--bubble-system-border);
background: var(--bubble-system-bg);
}
.bubble-head {
@@ -1182,29 +1245,93 @@ onBeforeUnmount(() => {
.author {
font-weight: 700;
color: #0f172a;
color: var(--text-primary);
}
.time {
font-size: 12px;
color: #9ca3af;
color: var(--text-muted);
}
.bubble-text {
margin: 0 0 8px;
color: #111827;
color: var(--text-primary);
line-height: 1.7;
font-size: 15px;
}
.bubble-text :deep(p) {
margin: 0 0 10px;
}
.bubble-text :deep(p:last-child) {
margin-bottom: 0;
}
.bubble-text :deep(ul),
.bubble-text :deep(ol) {
padding-left: 20px;
margin: 0 0 12px;
}
.bubble-text :deep(code) {
background: var(--surface-subtle);
border-radius: 6px;
padding: 2px 6px;
font-size: 13px;
}
.bubble-text :deep(pre) {
margin: 10px 0;
padding: 12px;
border-radius: 10px;
background: var(--code-bg);
color: var(--code-text);
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
overflow: auto;
border: 1px solid var(--code-border);
}
.bubble-text :deep(table) {
width: 100%;
margin: 12px 0;
border-collapse: separate;
border-spacing: 0;
border: 1.5px solid var(--table-border);
border-radius: 12px;
overflow: hidden;
background: var(--surface-strong);
}
.bubble-text :deep(th),
.bubble-text :deep(td) {
padding: 10px 12px;
border-bottom: 1px solid var(--table-divider);
border-right: 1px solid var(--table-divider);
color: var(--text-primary);
text-align: left;
}
.bubble-text :deep(tr:last-child td) {
border-bottom: none;
}
.bubble-text :deep(th:last-child),
.bubble-text :deep(td:last-child) {
border-right: none;
}
.bubble-text :deep(th) {
background: linear-gradient(180deg, var(--surface), var(--surface-subtle));
font-weight: 700;
}
.bubble-text :deep(.mermaid) {
margin: 10px 0;
padding: 12px;
border-radius: 12px;
background: linear-gradient(135deg, var(--mermaid-bg1), var(--mermaid-bg2));
border: 1px solid rgba(37, 99, 235, 0.14);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.8);
}
.error-card {
display: flex;
flex-direction: column;
gap: 8px;
padding: 10px 12px;
border-radius: 12px;
background: linear-gradient(135deg, rgba(239, 68, 68, 0.12), rgba(248, 113, 113, 0.1));
border: 1px solid rgba(239, 68, 68, 0.22);
box-shadow: 0 8px 16px rgba(239, 68, 68, 0.12);
background: var(--error-card-bg);
border: 1px solid var(--error-card-border);
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.12);
}
.error-title {
color: #b91c1c;
@@ -1214,15 +1341,15 @@ onBeforeUnmount(() => {
align-self: flex-start;
padding: 6px 10px;
border-radius: 10px;
border: 1px solid rgba(185, 28, 28, 0.4);
background: #fff5f5;
color: #b91c1c;
border: 1px solid var(--error-card-border);
background: var(--surface-subtle);
color: #f87171;
cursor: pointer;
transition: all 0.12s ease;
font-weight: 600;
}
.payload-toggle:hover {
background: #fee2e2;
background: rgba(248, 113, 113, 0.12);
transform: translateY(-1px);
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.08);
}
@@ -1230,8 +1357,8 @@ onBeforeUnmount(() => {
margin: 0;
padding: 12px;
border-radius: 10px;
background: #0f172a;
color: #e2e8f0;
background: var(--code-bg);
color: var(--code-text);
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
font-size: 13px;
overflow: auto;
@@ -1252,7 +1379,7 @@ onBeforeUnmount(() => {
align-items: center;
padding: 8px 10px;
border-radius: 10px;
background: rgba(15, 23, 42, 0.03);
background: var(--attachment-bg);
word-break: break-word;
}
@@ -1266,19 +1393,19 @@ onBeforeUnmount(() => {
border-radius: 12px;
background-size: cover;
background-position: center;
background-image: linear-gradient(135deg, #e5edff, #dbeafe);
border: 1px solid rgba(0, 0, 0, 0.04);
background-image: linear-gradient(135deg, var(--mermaid-bg1), var(--mermaid-bg2));
border: 1px solid var(--border-soft);
}
.attachment-meta .name {
display: block;
font-weight: 600;
color: #0f172a;
color: var(--text-primary);
}
.attachment-meta .size {
display: block;
color: #6b7280;
color: var(--text-muted);
font-size: 12px;
}
@@ -1289,21 +1416,21 @@ onBeforeUnmount(() => {
align-items: flex-start;
padding: 14px;
border-radius: 14px;
background: #f7f9fc;
border: 1px solid rgba(0, 0, 0, 0.04);
width: min(980px, 100%);
box-shadow: 0 12px 30px rgba(17, 24, 39, 0.12);
background: var(--composer-bg);
border: 1px solid var(--border-soft);
width: min(1120px, 100%);
box-shadow: var(--shadow-1);
}
.add-btn {
width: 44px;
height: 44px;
border-radius: 12px;
border: 1px solid rgba(0, 0, 0, 0.08);
background: #ffffff;
border: 1px solid var(--border-soft);
background: var(--surface-strong);
box-shadow: 0 8px 18px rgba(17, 24, 39, 0.08);
font-size: 24px;
color: #1d4ed8;
color: var(--accent);
cursor: pointer;
transition: transform 0.15s ease, box-shadow 0.15s ease;
}
@@ -1314,9 +1441,9 @@ onBeforeUnmount(() => {
}
.composer-input :deep(.el-textarea__inner) {
background: #ffffff;
background: var(--input-bg);
border-radius: 12px;
border: 1px solid rgba(0, 0, 0, 0.06);
border: 1px solid var(--border-soft);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.8);
min-height: 110px;
}
@@ -1337,7 +1464,7 @@ onBeforeUnmount(() => {
.send-hint {
font-size: 12px;
color: #9ca3af;
color: var(--text-muted);
letter-spacing: 0.02em;
}
@@ -1347,23 +1474,23 @@ onBeforeUnmount(() => {
justify-content: center;
padding: 2px 6px;
border-radius: 8px;
border: 1px solid rgba(15, 23, 42, 0.12);
background: #ffffff;
color: #6b7280;
border: 1px solid var(--border-soft);
background: var(--surface-strong);
color: var(--text-muted);
font-size: 11px;
font-weight: 600;
}
.pending-attachments {
padding: 10px 0 0;
border-top: 1px dashed rgba(0, 0, 0, 0.08);
border-top: 1px dashed var(--border-soft);
margin-top: 4px;
}
.pending-title {
margin: 0 0 8px;
font-size: 13px;
color: #6b7280;
color: var(--text-muted);
}
.pending-list {
@@ -1379,8 +1506,8 @@ onBeforeUnmount(() => {
align-items: center;
padding: 8px 10px;
border-radius: 12px;
background: rgba(37, 99, 235, 0.06);
border: 1px solid rgba(37, 99, 235, 0.15);
background: var(--pending-bg);
border: 1px solid var(--pending-border);
}
.pending-thumb {
@@ -1395,18 +1522,18 @@ onBeforeUnmount(() => {
.pending-meta .name {
display: block;
font-weight: 600;
color: #0f172a;
color: var(--text-primary);
}
.pending-meta .size {
display: block;
font-size: 12px;
color: #4b5563;
color: var(--text-subtle);
}
.remove {
cursor: pointer;
color: #111827;
color: var(--text-primary);
font-size: 16px;
display: inline-flex;
align-items: center;