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

1270
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -12,6 +12,8 @@
"dependencies": {
"axios": "^1.13.2",
"element-plus": "^2.12.0",
"markdown-it": "^14.1.0",
"mermaid": "^11.12.2",
"vue": "^3.5.24",
"vue-router": "^4.6.4"
},

View File

@@ -68,8 +68,8 @@ const miniMenuOpen = ref(false)
<style scoped>
.app-shell {
min-height: 100vh;
background: radial-gradient(circle at 20% 20%, #f3f6ff, #e7ebf5 40%, #dae0ec),
linear-gradient(135deg, rgba(255, 255, 255, 0.6), rgba(220, 226, 238, 0.9));
background: var(--app-bg);
color: var(--text-primary);
}
.auth-shell {
@@ -83,7 +83,7 @@ const miniMenuOpen = ref(false)
.auth-bg {
position: absolute;
inset: 0;
background: linear-gradient(180deg, #e9eef7, #f6f8fc);
background: var(--auth-bg);
filter: blur(0);
z-index: 0;
}
@@ -92,10 +92,10 @@ const miniMenuOpen = ref(false)
position: relative;
z-index: 1;
width: min(960px, 92vw);
background: rgba(255, 255, 255, 0.75);
border: 1px solid rgba(255, 255, 255, 0.6);
background: var(--surface);
border: 1px solid var(--border-soft);
border-radius: 18px;
box-shadow: 0 30px 80px rgba(36, 49, 89, 0.16);
box-shadow: var(--shadow-1);
backdrop-filter: blur(12px);
padding: 32px;
}
@@ -105,6 +105,7 @@ const miniMenuOpen = ref(false)
grid-template-columns: 300px 1fr;
min-height: 100vh;
position: relative;
background: var(--bg-page);
}
.main {
@@ -119,7 +120,7 @@ const miniMenuOpen = ref(false)
justify-content: space-between;
align-items: center;
padding-bottom: 6px;
border-bottom: 1px solid rgba(0, 0, 0, 0.04);
border-bottom: 1px solid var(--border-soft);
}
.page {
@@ -130,15 +131,15 @@ const miniMenuOpen = ref(false)
height: 46px;
padding: 0 12px;
border-radius: 12px;
border: 1px solid rgba(0, 0, 0, 0.06);
background: rgba(255, 255, 255, 0.9);
box-shadow: 0 8px 22px rgba(17, 24, 39, 0.14);
border: 1px solid var(--border-soft);
background: var(--surface-strong);
box-shadow: var(--shadow-2);
display: inline-flex;
align-items: center;
gap: 6px;
cursor: pointer;
transition: transform 0.18s ease, box-shadow 0.18s ease;
color: #111827;
color: var(--text-primary);
min-width: 104px;
}
@@ -149,13 +150,13 @@ const miniMenuOpen = ref(false)
.mini-icon {
font-size: 15px;
color: #1d4ed8;
color: var(--accent);
}
.mini-label {
font-weight: 700;
letter-spacing: 0.02em;
color: #111827;
color: var(--text-primary);
}
.mini-menu {
@@ -168,25 +169,25 @@ const miniMenuOpen = ref(false)
.mini-title {
margin: 0;
font-weight: 700;
color: #0f172a;
color: var(--text-primary);
font-size: 14px;
}
.mini-link {
padding: 8px 10px;
border-radius: 10px;
color: #1f2937;
color: var(--text-primary);
transition: all 0.16s ease;
}
.mini-link:hover {
background: rgba(37, 99, 235, 0.08);
color: #1d4ed8;
background: var(--chip-bg);
color: var(--accent);
}
.mini-popper {
border-radius: 12px;
box-shadow: 0 20px 60px rgba(17, 24, 39, 0.2) !important;
box-shadow: var(--shadow-1) !important;
}
@media (max-width: 900px) {

View File

@@ -109,19 +109,19 @@ const createSession = async () => {
gap: 8px;
padding: 8px 12px;
border-radius: 12px;
border: 1px solid rgba(0, 0, 0, 0.08);
background: rgba(255, 255, 255, 0.88);
box-shadow: 0 8px 20px rgba(17, 24, 39, 0.12);
border: 1px solid var(--border-soft);
background: var(--surface);
box-shadow: var(--shadow-2);
cursor: pointer;
transition: transform 0.18s ease, box-shadow 0.18s ease;
color: #111827;
color: var(--text-primary);
}
.new-chat-btn.inline {
width: 100%;
justify-content: center;
box-shadow: none;
border: 1px solid rgba(0, 0, 0, 0.04);
border: 1px solid var(--border-soft);
}
.new-chat-btn:hover {
@@ -148,7 +148,7 @@ const createSession = async () => {
.title {
margin: 0;
font-weight: 700;
color: #0f172a;
color: var(--text-primary);
}
.create-btn {
@@ -159,7 +159,7 @@ const createSession = async () => {
.last {
margin: 0;
font-size: 12px;
color: #4b5563;
color: var(--text-subtle);
word-break: break-all;
}
</style>

View File

@@ -219,16 +219,16 @@ onMounted(() => {
position: sticky;
top: 0;
padding: 18px;
background: rgba(255, 255, 255, 0.8);
border-right: 1px solid rgba(0, 0, 0, 0.06);
background: var(--surface-glass);
border-right: 1px solid var(--border-soft);
backdrop-filter: blur(10px);
box-shadow: 12px 0 30px rgba(17, 24, 39, 0.08);
box-shadow: 12px 0 30px rgba(0, 0, 0, 0.12);
display: flex;
flex-direction: column;
gap: 14px;
overflow-y: auto;
scrollbar-width: thin;
scrollbar-color: rgba(37, 99, 235, 0.3) rgba(255, 255, 255, 0.6);
scrollbar-color: rgba(37, 99, 235, 0.3) var(--surface-strong);
}
.sidebar::-webkit-scrollbar {
@@ -236,7 +236,11 @@ onMounted(() => {
}
.sidebar::-webkit-scrollbar-track {
background: linear-gradient(180deg, rgba(255, 255, 255, 0.9), rgba(236, 242, 255, 0.9));
background: linear-gradient(
180deg,
var(--surface-strong),
var(--surface-glass-strong)
);
border-radius: 12px;
}
@@ -261,7 +265,7 @@ onMounted(() => {
align-items: center;
gap: 8px;
font-weight: 700;
color: #111827;
color: var(--text-primary);
letter-spacing: 0.02em;
font-size: 18px;
}
@@ -288,9 +292,9 @@ onMounted(() => {
.session-item {
padding: 10px 12px;
border-radius: 12px;
border: 1px solid rgba(0, 0, 0, 0.05);
background: rgba(255, 255, 255, 0.9);
box-shadow: 0 6px 14px rgba(17, 24, 39, 0.06);
border: 1px solid var(--border-soft);
background: var(--surface);
box-shadow: var(--shadow-2);
margin-bottom: 10px;
cursor: pointer;
transition: all 0.16s ease;
@@ -315,12 +319,12 @@ onMounted(() => {
.name {
font-weight: 700;
color: #111827;
color: var(--text-primary);
}
.preview {
margin: 6px 0 4px;
color: #4b5563;
color: var(--text-subtle);
font-size: 13px;
line-height: 1.4;
max-height: 38px;
@@ -329,7 +333,7 @@ onMounted(() => {
.meta {
margin: 0;
color: #9ca3af;
color: var(--text-muted);
font-size: 12px;
}
@@ -338,7 +342,7 @@ onMounted(() => {
flex-direction: column;
gap: 6px;
font-size: 12px;
color: #6b7280;
color: var(--text-muted);
}
.error {
@@ -349,20 +353,20 @@ onMounted(() => {
width: 100%;
height: 34px;
border-radius: 10px;
border: 1px dashed rgba(0, 0, 0, 0.1);
color: #1f2937;
border: 1px dashed var(--border-contrast);
color: var(--text-primary);
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
letter-spacing: 0.02em;
background: rgba(255, 255, 255, 0.85);
background: var(--surface);
transition: all 0.15s ease;
}
.load-more:hover {
border-color: rgba(37, 99, 235, 0.35);
color: #1d4ed8;
color: var(--accent);
}
.load-more:disabled {
@@ -382,8 +386,8 @@ onMounted(() => {
position: static;
overflow: visible;
border-right: none;
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
box-shadow: 0 8px 24px rgba(17, 24, 39, 0.08);
border-bottom: 1px solid var(--border-soft);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
}
.session-item {

View File

@@ -0,0 +1,63 @@
import { onBeforeUnmount, onMounted, ref } from 'vue'
const THEME_KEY = 'ars-theme'
const getSystemTheme = () => {
if (typeof window === 'undefined') return 'light'
return window.matchMedia('(prefers-color-scheme: dark)').matches
? 'dark'
: 'light'
}
const applyThemeToDom = (resolved) => {
if (typeof document === 'undefined') return
document.documentElement.dataset.theme = resolved
document.documentElement.classList.toggle('dark', resolved === 'dark')
document.documentElement.style.colorScheme =
resolved === 'dark' ? 'dark' : 'light'
}
export const useTheme = () => {
const themeMode = ref('system')
const resolvedTheme = ref('light')
let mediaQuery = null
const apply = (mode = 'system') => {
const normalized = mode || 'system'
themeMode.value = normalized
if (typeof localStorage !== 'undefined') {
localStorage.setItem(THEME_KEY, normalized)
}
const resolved = normalized === 'system' ? getSystemTheme() : normalized
resolvedTheme.value = resolved
applyThemeToDom(resolved)
}
const handleMediaChange = () => {
if (themeMode.value === 'system') {
apply('system')
}
}
onMounted(() => {
const stored =
typeof localStorage !== 'undefined'
? localStorage.getItem(THEME_KEY)
: null
apply(stored || 'system')
if (typeof window !== 'undefined' && window.matchMedia) {
mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
mediaQuery.addEventListener('change', handleMediaChange)
}
})
onBeforeUnmount(() => {
mediaQuery?.removeEventListener('change', handleMediaChange)
})
return {
themeMode,
resolvedTheme,
setTheme: apply,
}
}

View File

@@ -1,8 +1,33 @@
import { createApp } from 'vue'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import 'element-plus/theme-chalk/dark/css-vars.css'
import './style.css'
import App from './App.vue'
import router from './router'
const initTheme = () => {
if (typeof document === 'undefined') return
const stored =
typeof localStorage !== 'undefined'
? localStorage.getItem('ars-theme')
: null
const prefersDark =
typeof window !== 'undefined' &&
window.matchMedia &&
window.matchMedia('(prefers-color-scheme: dark)').matches
const resolved =
stored === 'light' || stored === 'dark'
? stored
: prefersDark
? 'dark'
: 'light'
document.documentElement.dataset.theme = resolved
document.documentElement.classList.toggle('dark', resolved === 'dark')
document.documentElement.style.colorScheme =
resolved === 'dark' ? 'dark' : 'light'
}
initTheme()
createApp(App).use(ElementPlus).use(router).mount('#app')

View File

@@ -3,11 +3,107 @@
Arial, sans-serif;
line-height: 1.6;
font-weight: 400;
color: #111827;
background-color: #e9edf3;
color: var(--text-primary);
background-color: var(--bg-page);
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
--bg-page: #e9edf3;
--app-bg: radial-gradient(circle at 20% 20%, #f3f6ff, #e7ebf5 40%, #dae0ec),
linear-gradient(135deg, rgba(255, 255, 255, 0.6), rgba(220, 226, 238, 0.9));
--auth-bg: linear-gradient(180deg, #e9eef7, #f6f8fc);
--surface: rgba(255, 255, 255, 0.9);
--surface-strong: #ffffff;
--surface-subtle: rgba(15, 23, 42, 0.03);
--surface-glass: rgba(255, 255, 255, 0.8);
--surface-glass-strong: rgba(255, 255, 255, 0.92);
--border-soft: rgba(0, 0, 0, 0.05);
--border-contrast: rgba(0, 0, 0, 0.1);
--text-primary: #111827;
--text-muted: #6b7280;
--text-subtle: #4b5563;
--shadow-1: 0 18px 38px rgba(17, 24, 39, 0.08);
--shadow-2: 0 14px 35px rgba(17, 24, 39, 0.06);
--accent: #1d4ed8;
--accent-strong: #2563eb;
--chip-bg: rgba(37, 99, 235, 0.08);
--chip-text: #1d4ed8;
--panel-hero-bg: linear-gradient(135deg, #f5f8ff, #edf2ff);
--panel-hero-border: rgba(0, 0, 0, 0.04);
--bubble-user-bg: linear-gradient(135deg, #edf2ff, #e5edff);
--bubble-user-border: rgba(37, 99, 235, 0.18);
--bubble-agent-border: rgba(0, 0, 0, 0.04);
--bubble-system-bg: linear-gradient(135deg, #fff1f2, #ffe4e6);
--bubble-system-border: rgba(239, 68, 68, 0.18);
--composer-bg: #f7f9fc;
--input-bg: #ffffff;
--table-border: #d1d5db;
--table-divider: #e5e7eb;
--code-bg: #0f172a;
--code-border: rgba(255, 255, 255, 0.06);
--code-text: #e2e8f0;
--mermaid-bg1: #eef2ff;
--mermaid-bg2: #e0f2fe;
--error-card-bg: linear-gradient(
135deg,
rgba(239, 68, 68, 0.12),
rgba(248, 113, 113, 0.1)
);
--error-card-border: rgba(239, 68, 68, 0.22);
--pending-bg: rgba(37, 99, 235, 0.06);
--pending-border: rgba(37, 99, 235, 0.15);
--attachment-bg: rgba(15, 23, 42, 0.03);
--overlay-weak: rgba(255, 255, 255, 0.82);
}
:root.dark {
color-scheme: dark;
--bg-page: #0b1221;
--app-bg: radial-gradient(circle at 18% 18%, #0f172a, #0b1221 40%, #0a1020),
linear-gradient(135deg, rgba(10, 17, 33, 0.9), rgba(8, 12, 24, 0.95));
--auth-bg: linear-gradient(180deg, #0d1525, #0a0f1d);
--surface: rgba(15, 23, 42, 0.9);
--surface-strong: #111827;
--surface-subtle: rgba(255, 255, 255, 0.04);
--surface-glass: rgba(15, 23, 42, 0.75);
--surface-glass-strong: rgba(15, 23, 42, 0.82);
--border-soft: rgba(255, 255, 255, 0.08);
--border-contrast: rgba(255, 255, 255, 0.12);
--text-primary: #e5e7eb;
--text-muted: #9ca3af;
--text-subtle: #cbd5e1;
--shadow-1: 0 18px 38px rgba(0, 0, 0, 0.55);
--shadow-2: 0 14px 35px rgba(0, 0, 0, 0.45);
--accent: #60a5fa;
--accent-strong: #93c5fd;
--chip-bg: rgba(96, 165, 250, 0.14);
--chip-text: #bfdbfe;
--panel-hero-bg: linear-gradient(135deg, #0f172a, #0b1221);
--panel-hero-border: rgba(255, 255, 255, 0.06);
--bubble-user-bg: linear-gradient(135deg, #14203b, #0f172a);
--bubble-user-border: rgba(96, 165, 250, 0.35);
--bubble-agent-border: rgba(255, 255, 255, 0.06);
--bubble-system-bg: linear-gradient(135deg, #2b1b1b, #1c0f0f);
--bubble-system-border: rgba(248, 113, 113, 0.3);
--composer-bg: #0f172a;
--input-bg: #0b1020;
--table-border: #384152;
--table-divider: #2f3645;
--code-bg: #0b1020;
--code-border: rgba(255, 255, 255, 0.08);
--code-text: #e5e7eb;
--mermaid-bg1: #101a32;
--mermaid-bg2: #10243e;
--error-card-bg: linear-gradient(
135deg,
rgba(248, 113, 113, 0.14),
rgba(127, 29, 29, 0.28)
);
--error-card-border: rgba(248, 113, 113, 0.35);
--pending-bg: rgba(96, 165, 250, 0.08);
--pending-border: rgba(96, 165, 250, 0.3);
--attachment-bg: rgba(255, 255, 255, 0.05);
--overlay-weak: rgba(8, 12, 24, 0.82);
}
*,
@@ -19,8 +115,9 @@
body {
margin: 0;
min-height: 100vh;
background-color: #e9edf3;
color: #111827;
background-color: var(--bg-page);
color: var(--text-primary);
transition: background-color 0.25s ease, color 0.25s ease;
}
a {

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;

View File

@@ -115,6 +115,7 @@ const handleLogin = async () => {
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 32px;
align-items: center;
color: var(--text-primary);
}
.copy {
@@ -124,7 +125,7 @@ const handleLogin = async () => {
.eyebrow {
margin: 0 0 6px;
font-size: 13px;
color: #6b7280;
color: var(--text-muted);
letter-spacing: 0.08em;
text-transform: uppercase;
}
@@ -132,24 +133,25 @@ const handleLogin = async () => {
h1 {
margin: 0 0 8px;
font-size: 32px;
color: #0f172a;
color: var(--text-primary);
}
.lede {
margin: 0 0 12px;
color: #4b5563;
color: var(--text-subtle);
}
.hint {
margin: 0;
font-size: 13px;
color: #6b7280;
color: var(--text-muted);
}
.login-card {
border-radius: 14px;
border: 1px solid rgba(0, 0, 0, 0.05);
box-shadow: 0 18px 45px rgba(17, 24, 39, 0.08);
border: 1px solid var(--border-soft);
box-shadow: var(--shadow-1);
background: var(--surface);
}
.card-header {
@@ -157,7 +159,7 @@ h1 {
justify-content: space-between;
align-items: center;
font-weight: 700;
color: #0f172a;
color: var(--text-primary);
}
.dot {
@@ -178,7 +180,7 @@ h1 {
.tips {
margin-top: 16px;
font-size: 13px;
color: #6b7280;
color: var(--text-muted);
}
.mt {
@@ -190,17 +192,17 @@ h1 {
}
:deep(.el-form-item__label) {
color: #334155;
color: var(--text-primary);
letter-spacing: 0.02em;
}
:deep(.el-input__wrapper) {
background: #f8fafc;
border: 1px solid rgba(0, 0, 0, 0.06);
background: var(--surface-subtle);
border: 1px solid var(--border-soft);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.8);
}
:deep(.el-input__inner) {
color: #0f172a;
color: var(--text-primary);
}
</style>

View File

@@ -315,19 +315,20 @@ onMounted(() => {
<style scoped>
.users {
position: relative;
max-width: 1280px;
width: 100%;
max-width: 1100px;
margin: 0 auto;
display: flex;
flex-direction: column;
gap: 14px;
z-index: 1;
color: var(--text-primary);
}
.hero {
border-radius: 16px;
border: 1px solid rgba(0, 0, 0, 0.05);
box-shadow: 0 18px 45px rgba(17, 24, 39, 0.08);
border: 1px solid var(--border-soft);
box-shadow: var(--shadow-1);
background: var(--surface);
}
.hero-head {
@@ -341,7 +342,7 @@ onMounted(() => {
.eyebrow {
margin: 0 0 6px;
font-size: 12px;
color: #6b7280;
color: var(--text-muted);
letter-spacing: 0.08em;
text-transform: uppercase;
}
@@ -349,18 +350,18 @@ onMounted(() => {
h1 {
margin: 0 0 8px;
font-size: 30px;
color: #0f172a;
color: var(--text-primary);
}
.lede {
margin: 0 0 8px;
color: #4b5563;
color: var(--text-subtle);
line-height: 1.6;
}
.hint {
margin: 0;
color: #6b7280;
color: var(--text-muted);
font-size: 13px;
}
@@ -372,8 +373,9 @@ h1 {
.panel {
border-radius: 16px;
border: 1px solid rgba(0, 0, 0, 0.05);
box-shadow: 0 14px 35px rgba(17, 24, 39, 0.06);
border: 1px solid var(--border-soft);
box-shadow: var(--shadow-2);
background: var(--surface);
}
.card-header {
@@ -381,7 +383,7 @@ h1 {
justify-content: space-between;
align-items: center;
font-weight: 700;
color: #0f172a;
color: var(--text-primary);
}
.user-cell {
@@ -392,12 +394,12 @@ h1 {
.name {
font-weight: 600;
color: #0f172a;
color: var(--text-primary);
}
.email {
font-size: 13px;
color: #6b7280;
color: var(--text-muted);
}
.pagination {

View File

@@ -3,6 +3,7 @@ import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import axios from 'axios'
import { ElMessage } from 'element-plus'
import { useTheme } from '../composables/useTheme'
const router = useRouter()
const apiBase = import.meta.env.VITE_API_BASE || 'http://localhost:8000/api'
@@ -18,6 +19,7 @@ const loadingUser = ref(false)
const greeting = computed(
() => user.value?.name || user.value?.email || '访客'
)
const { themeMode, resolvedTheme, setTheme } = useTheme()
const logout = () => {
localStorage.removeItem('ars-token')
@@ -65,6 +67,18 @@ onMounted(() => {
</p>
</div>
<div class="actions">
<div class="theme-switch">
<p class="theme-label">主题 · {{ resolvedTheme === 'dark' ? '深色' : resolvedTheme === 'light' ? '浅色' : '系统' }}</p>
<el-radio-group
v-model="themeMode"
size="small"
@change="setTheme"
>
<el-radio-button label="system">跟随系统</el-radio-button>
<el-radio-button label="light">浅色</el-radio-button>
<el-radio-button label="dark">深色</el-radio-button>
</el-radio-group>
</div>
<el-button
color="#1d4ed8"
:loading="loadingUser"
@@ -126,12 +140,14 @@ onMounted(() => {
grid-template-columns: repeat(auto-fit, minmax(360px, 1fr));
gap: 18px;
z-index: 1;
color: var(--text-primary);
}
.hero {
border-radius: 16px;
border: 1px solid rgba(0, 0, 0, 0.05);
box-shadow: 0 18px 45px rgba(17, 24, 39, 0.08);
border: 1px solid var(--border-soft);
box-shadow: var(--shadow-1);
background: var(--surface);
}
.hero-header {
@@ -144,7 +160,7 @@ onMounted(() => {
.eyebrow {
margin: 0 0 6px;
font-size: 12px;
color: #6b7280;
color: var(--text-muted);
letter-spacing: 0.1em;
text-transform: uppercase;
}
@@ -152,12 +168,12 @@ onMounted(() => {
h1 {
margin: 0 0 8px;
font-size: 30px;
color: #0f172a;
color: var(--text-primary);
}
.lede {
margin: 0 0 12px;
color: #4b5563;
color: var(--text-subtle);
line-height: 1.6;
}
@@ -167,10 +183,27 @@ h1 {
flex-wrap: wrap;
}
.theme-switch {
display: flex;
flex-direction: column;
gap: 6px;
padding: 8px 10px;
border-radius: 12px;
border: 1px solid var(--border-soft);
background: var(--surface-subtle);
}
.theme-label {
margin: 0;
font-size: 13px;
color: var(--text-muted);
}
.panel {
border-radius: 16px;
border: 1px solid rgba(0, 0, 0, 0.05);
box-shadow: 0 14px 35px rgba(17, 24, 39, 0.06);
border: 1px solid var(--border-soft);
box-shadow: var(--shadow-2);
background: var(--surface);
}
.card-header {
@@ -178,7 +211,7 @@ h1 {
justify-content: space-between;
align-items: center;
font-weight: 700;
color: #0f172a;
color: var(--text-primary);
}
.links {
@@ -194,14 +227,14 @@ h1 {
justify-content: space-between;
gap: 10px;
padding: 12px;
background: #f8fafc;
border: 1px solid rgba(0, 0, 0, 0.04);
background: var(--surface-subtle);
border: 1px solid var(--border-soft);
border-radius: 10px;
color: #111827;
color: var(--text-primary);
}
.links code {
color: #475569;
color: var(--text-subtle);
font-size: 13px;
word-break: break-all;
text-align: right;
@@ -223,14 +256,14 @@ h1 {
.stat-label {
margin: 0 0 4px;
color: #6b7280;
color: var(--text-muted);
font-size: 13px;
}
.stat-value {
margin: 0;
font-weight: 700;
color: #0f172a;
color: var(--text-primary);
}
.mono {
@@ -239,7 +272,7 @@ h1 {
}
.link-accent {
color: #2563eb;
color: var(--accent-strong);
font-weight: 600;
}
</style>

View File

@@ -12,6 +12,12 @@ vi.mock('axios', () => ({
post: vi.fn(),
},
}))
vi.mock('mermaid', () => ({
default: {
initialize: vi.fn(),
run: vi.fn().mockResolvedValue(),
},
}))
const mockedAxios = axios
let latestEventSource = null
@@ -451,6 +457,67 @@ describe('ChatView message.delta handling', () => {
})
})
describe('ChatView markdown rendering', () => {
beforeEach(() => {
localStorage.clear()
vi.clearAllMocks()
latestEventSource = null
globalThis.EventSource = MockEventSource
globalThis.scrollTo = vi.fn()
})
it('renders markdown content and mermaid blocks', async () => {
localStorage.setItem('ars-token', 'token')
const router = makeRouter()
router.push('/chat/session-1')
await router.isReady()
const markdownText = ['Hello **world**', '', '```mermaid', 'graph TD;', 'A-->B;', '```'].join(
'\n'
)
const list = [
buildMessage({
seq: 1,
role: 'AGENT',
type: 'agent.message',
content: markdownText,
}),
]
mockedAxios.get.mockImplementation((url) => {
if (url.endsWith('/sessions/session-1')) {
return Promise.resolve({
data: { session_name: 'Demo', status: 'OPEN', last_seq: 1 },
})
}
if (url.endsWith('/sessions/session-1/messages')) {
return Promise.resolve({ data: { data: list } })
}
return Promise.resolve({ data: {} })
})
const wrapper = mount(ChatView, {
global: {
plugins: [router, ElementPlus],
},
})
await flushPromises()
await nextTick()
const bubble = wrapper.find('.bubble-agent')
expect(bubble.exists()).toBe(true)
const textBlock = bubble.find('.bubble-text')
expect(textBlock.html()).toContain('<strong>world</strong>')
const mermaidBlock = textBlock.find('.mermaid')
expect(mermaidBlock.exists()).toBe(true)
expect(mermaidBlock.text()).toContain('A-->B')
})
})
describe('ChatView system error and failed status handling', () => {
beforeEach(() => {
localStorage.clear()