<template>
|
<el-dialog title="AI 解释分析" :visible="visible" width="860px" top="5vh" custom-class="ai-dialog"
|
:close-on-click-modal="false" @open="onOpen" @update:visible="onDialogClose">
|
<div class="ai-meta" v-if="context">
|
<span v-if="context.enterpriseName">企业:{{ context.enterpriseName }}</span>
|
<span v-if="context.reportPeriod">报表期:{{ context.reportPeriod }}</span>
|
<span v-if="context.verifyExplanation" class="ai-meta-explain" :title="context.verifyExplanation">企业解释:{{ context.verifyExplanation }}</span>
|
</div>
|
<div class="chat-box" ref="chatBox" @scroll="onChatScroll">
|
<div v-for="(msg, i) in chatMessages" :key="i" :class="['chat-msg', msg.role]">
|
<template v-if="msg.role === 'assistant'">
|
<ai-mascot :bouncing="msg.typing"></ai-mascot>
|
<div class="bubble">
|
<div class="md-body" v-html="renderMd(msg.content.slice(0, msg.displayLen), msg.typing)"></div>
|
<div v-if="!msg.typing && msg.content" class="msg-tools">
|
<button class="msg-speak" :class="{ playing: msg.speaking }"
|
:title="msg.speaking ? '停止朗读' : '朗读本条回复'" @click="speakMsg(msg)">
|
{{ msg.speaking ? '⏹' : '🔊' }}<span v-if="msg.speaking"> 朗读中</span>
|
</button>
|
</div>
|
</div>
|
</template>
|
<div v-else class="bubble">{{ msg.content }}</div>
|
</div>
|
<div v-if="chatLoading" class="chat-msg assistant">
|
<ai-mascot :bouncing="true"></ai-mascot>
|
<div class="bubble thinking-bubble">
|
<span class="thinking-dot"></span>
|
<span class="thinking-dot"></span>
|
<span class="thinking-dot"></span>
|
<span class="thinking-text">AI 思考中(如需会查询数据库)…</span>
|
</div>
|
</div>
|
<div v-if="!chatLoading && chatMessages.length === 0" class="chat-empty">正在准备分析…</div>
|
</div>
|
<div class="chat-input">
|
<el-input v-model="chatInput" type="textarea" :rows="2" resize="none"
|
placeholder="继续追问,例如:这家企业车辆数是多少?与运政数据一致吗?"
|
@keydown.enter.native="onChatKeydown"></el-input>
|
<div class="chat-input-actions">
|
<div class="chat-input-left">
|
<button v-if="speechSupported" class="voice-btn" :class="{ listening: speechListening }"
|
:title="speechListening ? '点击结束语音输入' : '语音输入(说完自动发送)'" @click="toggleVoiceInput">
|
<span v-if="speechListening" class="voice-ripple"></span>{{ speechListening ? '⏹' : '🎤' }}
|
</button>
|
<button class="voice-btn speak-toggle" :class="{ muted: !voiceEnabled }"
|
:title="voiceEnabled ? '关闭 AI 语音播报' : '开启 AI 语音播报'" @click="toggleVoice">
|
{{ voiceEnabled ? '🔊' : '🔇' }}
|
</button>
|
<span class="chat-tip">Enter 发送,Shift+Enter 换行;语音对话:点 🎤 说话,AI 将语音回答</span>
|
</div>
|
<el-button type="primary" :loading="chatLoading" @click="sendChat()">发送</el-button>
|
</div>
|
<div v-if="speechListening" class="speech-hint"><span class="dot"></span>正在聆听,请说话…</div>
|
</div>
|
</el-dialog>
|
</template>
|
<script>
|
import api from '@/api'
|
|
const AiMascot = {
|
name: 'AiMascot',
|
props: { bouncing: { type: Boolean, default: false } },
|
render(h) {
|
return h('div', { class: ['ai-mascot', { 'is-bouncing': this.bouncing }] }, [
|
h('div', { class: 'mascot-sprout' }),
|
h('div', { class: 'mascot-head' }, [
|
h('div', { class: 'mascot-eye left' }),
|
h('div', { class: 'mascot-eye right' }),
|
h('div', { class: 'mascot-blush left' }),
|
h('div', { class: 'mascot-blush right' }),
|
h('div', { class: 'mascot-mouth' })
|
]),
|
h('div', { class: 'mascot-body' }),
|
h('div', { class: 'mascot-feet' })
|
])
|
}
|
}
|
|
export default {
|
name: 'AiChatDialog',
|
components: { AiMascot },
|
props: {
|
visible: { type: Boolean, default: false },
|
context: { type: Object, default: () => ({}) }
|
},
|
data() {
|
return {
|
chatMessages: [],
|
chatInput: '',
|
chatLoading: false,
|
typingTimer: null,
|
userScrolledUp: false,
|
speechSupported: false,
|
speechRec: null,
|
speechListening: false,
|
voiceDraft: '',
|
pendingVoiceText: '',
|
voiceEnabled: true,
|
lastInputWasVoice: false
|
}
|
},
|
methods: {
|
onOpen() {
|
this.chatMessages = []
|
this.chatInput = ''
|
this.chatLoading = true
|
this.userScrolledUp = false
|
this.lastInputWasVoice = false
|
this.stopSpeech()
|
this.initSpeech()
|
this.$nextTick(() => this.scrollToBottom())
|
api.post('/llm/chat', {
|
messages: [{ role: 'user', content: '请用结构化 Markdown(小节标题、加粗、列表)分析该企业的解释是否合理,先给结论,再列数据依据,最后给出处置建议。' }],
|
context: this.context
|
})
|
.then(res => { this.pushAssistant(res.data || 'AI 未返回内容,请重试') })
|
.catch(() => { this.pushAssistant('AI 分析失败,请稍后重试或人工判断') })
|
.finally(() => { this.$nextTick(() => this.scrollToBottom()) })
|
},
|
onDialogClose(val) {
|
this.clearTyping()
|
this.stopSpeech()
|
this.$emit('update:visible', val)
|
},
|
beforeDestroy() {
|
this.clearTyping()
|
this.stopSpeech()
|
},
|
onChatScroll() {
|
const box = this.$refs.chatBox
|
if (!box) return
|
this.userScrolledUp = box.scrollTop + box.clientHeight < box.scrollHeight - 40
|
},
|
onChatKeydown(e) {
|
if (e.shiftKey) return
|
e.preventDefault()
|
this.sendChat()
|
},
|
sendChat(fromVoice) {
|
const text = (this.chatInput || '').trim()
|
if (!text || this.chatLoading) return
|
this.lastInputWasVoice = !!fromVoice
|
this.stopSpeech()
|
this.chatMessages.push({ role: 'user', content: text })
|
this.chatInput = ''
|
this.userScrolledUp = false
|
this.chatLoading = true
|
this.$nextTick(() => this.scrollToBottom())
|
api.post('/llm/chat', { messages: this.chatMessages, context: this.context })
|
.then(res => { this.pushAssistant(res.data || 'AI 未返回内容,请重试') })
|
.catch(() => { this.pushAssistant('AI 分析失败,请稍后重试或人工判断') })
|
.finally(() => { this.$nextTick(() => this.scrollToBottom()) })
|
},
|
pushAssistant(text) {
|
this.chatLoading = false
|
const msg = { role: 'assistant', content: text || '', displayLen: 0, typing: true, speaking: false }
|
this.chatMessages.push(msg)
|
this.startTyping(msg)
|
},
|
startTyping(msg) {
|
this.clearTyping()
|
const full = msg.content
|
if (!full) { msg.typing = false; return }
|
// 打字速度:匀速逐字输出(每秒约 70 字符),短回复至少 2.5 秒,长回复最多约 24 秒
|
const cps = 70
|
const tickMs = 40
|
const totalMs = Math.max(2500, Math.min(24000, (full.length / cps) * 1000))
|
const ticks = Math.max(1, Math.ceil(totalMs / tickMs))
|
const chunk = Math.max(1, Math.ceil(full.length / ticks))
|
this.typingTimer = setInterval(() => {
|
msg.displayLen = Math.min(full.length, msg.displayLen + chunk)
|
if (!this.userScrolledUp) this.$nextTick(() => this.scrollToBottom())
|
if (msg.displayLen >= full.length) {
|
msg.typing = false
|
this.clearTyping()
|
this.$nextTick(() => this.scrollToBottom())
|
this.afterTypingDone(msg)
|
}
|
}, tickMs)
|
},
|
clearTyping() {
|
if (this.typingTimer) { clearInterval(this.typingTimer); this.typingTimer = null }
|
},
|
afterTypingDone(msg) {
|
// 语音对话模式下(本次由语音发起),AI 回答自动朗读
|
if (this.voiceEnabled && this.lastInputWasVoice) {
|
this.speakMsg(msg)
|
}
|
},
|
scrollToBottom() {
|
const box = this.$refs.chatBox
|
if (box) box.scrollTop = box.scrollHeight
|
},
|
// ---------- 语音输入(浏览器自带语音识别) ----------
|
initSpeech() {
|
if (this.speechRec) return
|
const SR = window.SpeechRecognition || window.webkitSpeechRecognition
|
if (!SR) return
|
this.speechSupported = true
|
const rec = new SR()
|
rec.lang = 'zh-CN'
|
rec.interimResults = true
|
rec.continuous = false
|
rec.maxAlternatives = 1
|
rec.onresult = e => {
|
let interim = ''
|
let final = ''
|
for (let i = e.resultIndex; i < e.results.length; i++) {
|
const r = e.results[i]
|
if (r.isFinal) final += r[0].transcript
|
else interim += r[0].transcript
|
}
|
const base = this.voiceDraft || ''
|
this.chatInput = base + final + interim
|
if (final) this.pendingVoiceText = base + final
|
}
|
rec.onerror = e => {
|
if (e.error === 'not-allowed' || e.error === 'service-not-allowed') {
|
this.$message.warning('未获得麦克风权限,请在浏览器地址栏允许使用麦克风')
|
} else if (e.error !== 'aborted') {
|
this.$message.warning('语音识别失败:' + e.error)
|
}
|
}
|
rec.onend = () => {
|
this.speechListening = false
|
const t = this.pendingVoiceText
|
this.pendingVoiceText = ''
|
if (t && t.trim()) {
|
this.chatInput = t
|
this.$nextTick(() => this.sendChat(true))
|
}
|
}
|
this.speechRec = rec
|
},
|
toggleVoiceInput() {
|
if (!this.speechRec || this.chatLoading) return
|
if (this.speechListening) {
|
this.speechRec.stop()
|
return
|
}
|
this.voiceDraft = this.chatInput || ''
|
this.pendingVoiceText = ''
|
this.speechListening = true
|
try { this.speechRec.start() } catch (err) { this.speechListening = false }
|
},
|
// ---------- AI 语音播报(浏览器自带语音合成) ----------
|
toggleVoice() {
|
this.voiceEnabled = !this.voiceEnabled
|
if (!this.voiceEnabled) this.stopSpeech()
|
this.$message[this.voiceEnabled ? 'success' : 'warning'](this.voiceEnabled ? '已开启 AI 语音播报' : '已关闭 AI 语音播报')
|
},
|
speakMsg(msg) {
|
if (!('speechSynthesis' in window)) {
|
this.$message.warning('当前浏览器不支持语音播报')
|
return
|
}
|
const synth = window.speechSynthesis
|
if (msg.speaking) {
|
synth.cancel()
|
msg.speaking = false
|
return
|
}
|
this.chatMessages.forEach(m => { if (m.speaking) m.speaking = false })
|
synth.cancel()
|
const plain = msg.content.replace(/[*#`>|~]/g, ' ').replace(/\s+/g, ' ').trim()
|
if (!plain) return
|
const u = new SpeechSynthesisUtterance(plain.slice(0, 3000))
|
u.lang = 'zh-CN'
|
const voices = synth.getVoices()
|
const zhVoice = voices.find(v => /zh[-_]CN/i.test(v.lang)) || voices.find(v => /^zh/i.test(v.lang))
|
if (zhVoice) u.voice = zhVoice
|
u.rate = 1.05
|
u.onend = () => { msg.speaking = false }
|
u.onerror = () => { msg.speaking = false }
|
msg.speaking = true
|
synth.speak(u)
|
},
|
stopSpeech() {
|
if ('speechSynthesis' in window) window.speechSynthesis.cancel()
|
this.chatMessages.forEach(m => { m.speaking = false })
|
},
|
// ---------- 轻量 Markdown 渲染 ----------
|
escapeHtml(s) {
|
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
|
},
|
inline(text) {
|
let t = this.escapeHtml(text)
|
t = t.replace(/`([^`]+)`/g, '<code class="ai-code">$1</code>')
|
t = t.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
|
t = t.replace(/(^|[^*\s])\*([^*\n]+)\*(?!\*)/g, '$1<em>$2</em>')
|
return t
|
},
|
renderMd(text, showCaret) {
|
if (!text && !showCaret) return ''
|
const src = (text || '').replace(/\r\n/g, '\n')
|
const lines = src.split('\n')
|
let html = ''
|
let i = 0
|
const isBullet = l => /^\s*[-*•]\s+/.test(l)
|
const isNumbered = l => /^\s*\d+[.、]\s+/.test(l)
|
while (i < lines.length) {
|
const t = lines[i].trim()
|
if (!t) { html += '<div class="ai-blank"></div>'; i++; continue }
|
if (/^-{3,}\s*$/.test(t)) { html += '<hr class="ai-hr"/>'; i++; continue }
|
if (t.startsWith('|') && i + 1 < lines.length && /^\s*\|?[\s:|-]+\|?\s*$/.test(lines[i + 1].trim()) && lines[i + 1].indexOf('-') >= 0) {
|
html += this.renderTable(lines, i)
|
while (i < lines.length && lines[i].trim().startsWith('|')) i++
|
continue
|
}
|
const h = t.match(/^(#{1,4})\s+(.*)$/)
|
if (h) {
|
const lvl = h[1].length
|
html += '<div class="' + (lvl <= 2 ? 'ai-h' : 'ai-h3') + '">' + this.inline(h[2]) + '</div>'
|
i++; continue
|
}
|
if (/^>\s?/.test(t)) { html += '<div class="ai-quote">' + this.inline(t.replace(/^>\s?/, '')) + '</div>'; i++; continue }
|
if (isBullet(t)) {
|
let items = []
|
while (i < lines.length && isBullet(lines[i].trim())) {
|
items.push('<li>' + this.inline(lines[i].trim().replace(/^\s*[-*•]\s+/, '')) + '</li>')
|
i++
|
}
|
html += '<ul class="ai-ul">' + items.join('') + '</ul>'
|
continue
|
}
|
if (isNumbered(t)) {
|
let items = []
|
while (i < lines.length && isNumbered(lines[i].trim())) {
|
items.push('<li>' + this.inline(lines[i].trim().replace(/^\s*\d+[.、]\s+/, '')) + '</li>')
|
i++
|
}
|
html += '<ol class="ai-ol">' + items.join('') + '</ol>'
|
continue
|
}
|
const kw = t.replace(/\*\*/g, '').match(/^(结论|判断|综合判断|核实结论|处置建议|改进建议|建议|下一步|风险|问题|注意|警告|提示|数据依据|依据|原因|说明|参考)[::]/)
|
if (kw) {
|
html += '<div class="ai-label ' + this.labelClass(kw[1]) + '">' + this.inline(t) + '</div>'
|
i++; continue
|
}
|
html += '<p class="ai-p">' + this.inline(t) + '</p>'
|
i++
|
}
|
if (showCaret) html += '<span class="ai-caret"></span>'
|
return html
|
},
|
labelClass(kw) {
|
if (/结论|判断|核实/.test(kw)) return 'ai-label-conclusion'
|
if (/建议|下一步/.test(kw)) return 'ai-label-advice'
|
if (/风险|问题|注意|警告|提示/.test(kw)) return 'ai-label-risk'
|
return 'ai-label-basis'
|
},
|
renderTable(lines, start) {
|
const cells = l => {
|
let s = l.trim()
|
if (s.startsWith('|')) s = s.slice(1)
|
if (s.endsWith('|')) s = s.slice(0, -1)
|
return s.split('|')
|
}
|
const header = cells(lines[start])
|
let i = start + 2
|
const body = []
|
while (i < lines.length && lines[i].trim().startsWith('|')) { body.push(cells(lines[i])); i++ }
|
let h = '<table class="ai-table"><thead><tr>'
|
h += header.map(c => '<th>' + this.inline(c.trim()) + '</th>').join('')
|
h += '</tr></thead><tbody>'
|
h += body.map(r => '<tr>' + r.map(c => '<td>' + this.inline(c.trim()) + '</td>').join('') + '</tr>').join('')
|
h += '</tbody></table>'
|
return h
|
}
|
}
|
}
|
</script>
|
<style>
|
.ai-meta { display: flex; gap: 20px; color: #606266; font-size: 13px; padding: 6px 4px 10px; border-bottom: 1px solid #ebeef5; flex-shrink: 0; }
|
.ai-meta-explain { max-width: 60%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
.chat-box { height: 440px; flex: 1 1 440px; min-height: 120px; overflow-y: auto; background: #f5f7fa; border: 1px solid #ebeef5; border-radius: 14px; padding: 16px 12px 12px; margin-top: 10px; }
|
.chat-msg { display: flex; margin-bottom: 12px; }
|
.chat-msg.user { justify-content: flex-end; }
|
.chat-msg.assistant { justify-content: flex-start; align-items: flex-start; }
|
.bubble { max-width: 82%; padding: 10px 12px; border-radius: 14px; line-height: 1.6; font-size: 14px; word-break: break-word; }
|
.chat-msg.user .bubble { background: #409EFF; color: white; white-space: pre-line; border-radius: 14px 14px 4px 14px; }
|
.chat-msg.assistant .bubble { background: white; border: 1px solid #e4e7ed; color: #303133; border-radius: 4px 14px 14px 14px; }
|
.bubble.loading { color: #909399; }
|
.chat-empty { color: #909399; text-align: center; margin-top: 60px; font-size: 13px; }
|
.chat-input { margin-top: 12px; flex-shrink: 0; }
|
.chat-input-actions { display: flex; justify-content: space-between; align-items: center; margin-top: 8px; }
|
.chat-input-left { display: flex; align-items: center; gap: 10px; }
|
.chat-tip { color: #909399; font-size: 12px; }
|
|
/* 弹窗圆角矩形,圆润柔和;固定在屏幕中央(水平垂直居中),不随页面滚动 */
|
.ai-dialog {
|
position: fixed !important;
|
top: 50% !important;
|
left: 50% !important;
|
transform: translate(-50%, -50%) !important;
|
margin: 0 !important;
|
display: flex;
|
flex-direction: column;
|
max-height: calc(100vh - 32px);
|
border-radius: 18px;
|
overflow: hidden;
|
box-shadow: 0 12px 40px rgba(0, 0, 0, .12);
|
}
|
.ai-dialog .el-dialog__header { padding: 18px 20px 10px; border-bottom: 1px solid #f0f2f5; flex-shrink: 0; }
|
.ai-dialog .el-dialog__title { font-weight: 700; color: #1f2d3d; }
|
.ai-dialog .el-dialog__body { padding: 12px 20px 18px; display: flex; flex-direction: column; min-height: 0; overflow: hidden; }
|
|
/* 语音输入按钮 */
|
.voice-btn {
|
width: 34px; height: 34px; border-radius: 50%; border: 1px solid #dcdfe6;
|
background: #fff; font-size: 16px; line-height: 1; cursor: pointer;
|
display: flex; align-items: center; justify-content: center;
|
transition: all .2s; position: relative; flex-shrink: 0;
|
}
|
.voice-btn:hover { border-color: #409EFF; }
|
.voice-btn.listening { background: #ff4d4f; border-color: #ff4d4f; color: #fff; animation: mic-pulse 1.2s infinite; }
|
.voice-btn .voice-ripple { position: absolute; inset: -4px; border-radius: 50%; border: 2px solid #ff4d4f; animation: mic-ripple 1.2s infinite; }
|
.speak-toggle { font-size: 15px; }
|
.speak-toggle.muted { opacity: .45; }
|
@keyframes mic-pulse { 0%, 100% { box-shadow: 0 0 0 0 rgba(255, 77, 79, .35); } 50% { box-shadow: 0 0 0 9px rgba(255, 77, 79, 0); } }
|
@keyframes mic-ripple { 0% { transform: scale(1); opacity: .8; } 100% { transform: scale(1.7); opacity: 0; } }
|
.speech-hint { margin-top: 6px; color: #ff4d4f; font-size: 12px; display: flex; align-items: center; gap: 6px; }
|
.speech-hint .dot { width: 8px; height: 8px; border-radius: 50%; background: #ff4d4f; animation: hint-blink 1s infinite; }
|
@keyframes hint-blink { 50% { opacity: .3; } }
|
|
/* 每条回复的朗读按钮 */
|
.msg-tools { margin-top: 6px; text-align: right; }
|
.msg-speak { border: none; background: transparent; cursor: pointer; font-size: 12px; padding: 2px 6px; border-radius: 4px; color: #909399; }
|
.msg-speak:hover { color: #409EFF; background: #f0f7ff; }
|
.msg-speak.playing { color: #409EFF; animation: speak-pulse 1s infinite; }
|
@keyframes speak-pulse { 50% { opacity: .5; } }
|
|
/* 思考动画:三个跳动的小圆点 */
|
.thinking-bubble { display: flex; align-items: center; }
|
.thinking-dot { width: 6px; height: 6px; border-radius: 50%; background: #409EFF; opacity: .55; animation: ai-dot 1.2s infinite ease-in-out; }
|
.thinking-dot:nth-child(2) { animation-delay: .15s; }
|
.thinking-dot:nth-child(3) { animation-delay: .3s; }
|
.thinking-text { margin-left: 10px; font-size: 13px; color: #909399; }
|
@keyframes ai-dot { 0%, 60%, 100% { transform: translateY(0); opacity: .45; } 30% { transform: translateY(-5px); opacity: 1; } }
|
|
/* 卡哇伊蹦跳小人 */
|
.ai-mascot { flex-shrink: 0; position: relative; width: 56px; height: 60px; margin: -2px 4px 0 -4px; }
|
.ai-mascot .mascot-head {
|
position: absolute; top: 8px; left: 6px; width: 44px; height: 38px;
|
background: radial-gradient(circle at 35% 28%, #fff6ea, #ffdfc4);
|
border: 2px solid #f5b98a; border-radius: 50% 50% 46% 46%;
|
box-shadow: 0 2px 5px rgba(0, 0, 0, .08); z-index: 2;
|
}
|
.ai-mascot .mascot-eye { position: absolute; top: 14px; width: 5px; height: 7px; background: #4a3228; border-radius: 50%; animation: ai-blink 4.2s infinite; }
|
.ai-mascot .mascot-eye.left { left: 12px; }
|
.ai-mascot .mascot-eye.right { right: 12px; }
|
.ai-mascot .mascot-eye::after { content: ''; position: absolute; top: 1px; left: 1px; width: 2px; height: 2px; background: #fff; border-radius: 50%; }
|
.ai-mascot .mascot-blush { position: absolute; top: 24px; width: 9px; height: 4px; background: rgba(255, 138, 138, .55); border-radius: 50%; }
|
.ai-mascot .mascot-blush.left { left: 5px; }
|
.ai-mascot .mascot-blush.right { right: 5px; }
|
.ai-mascot .mascot-mouth { position: absolute; top: 23px; left: 50%; transform: translateX(-50%); width: 9px; height: 7px; border-bottom: 2px solid #a0603a; border-radius: 0 0 50% 50%; }
|
.ai-mascot .mascot-sprout {
|
position: absolute; top: 0; left: 22px; width: 13px; height: 13px; z-index: 3;
|
background: radial-gradient(circle at 45% 35%, #c6f09a, #7ccb4a);
|
border-radius: 90% 8% 90% 8%; transform: rotate(45deg);
|
}
|
.ai-mascot .mascot-body {
|
position: absolute; top: 42px; left: 16px; width: 24px; height: 12px;
|
background: linear-gradient(#7cc0ff, #5aa8f5); border-radius: 5px 5px 3px 3px;
|
}
|
.ai-mascot .mascot-feet { position: absolute; top: 52px; left: 20px; width: 16px; height: 5px; background: #f5b98a; border-radius: 0 0 4px 4px; }
|
.ai-mascot.is-bouncing { animation: ai-hop .65s ease-in-out infinite; }
|
@keyframes ai-hop {
|
0%, 100% { transform: translateY(0) scale(1, 1); }
|
25% { transform: translateY(-7px) scale(.97, 1.03); }
|
45% { transform: translateY(-9px) scale(1, 1); }
|
65% { transform: translateY(-3px) scale(1.03, .97); }
|
80% { transform: translateY(-1px) scale(1, 1); }
|
}
|
.ai-mascot:not(.is-bouncing) { animation: ai-float 2.6s ease-in-out infinite; }
|
@keyframes ai-float { 0%, 100% { transform: translateY(0); } 50% { transform: translateY(-2px); } }
|
@keyframes ai-blink { 0%, 90%, 100% { transform: scaleY(1); } 93%, 96% { transform: scaleY(.1); } }
|
|
/* 打字光标 */
|
.ai-caret { display: inline-block; width: 2px; height: 15px; background: #409EFF; vertical-align: text-bottom; margin-left: 2px; animation: ai-caret-blink .8s steps(1) infinite; }
|
@keyframes ai-caret-blink { 50% { opacity: 0; } }
|
|
/* 结构化 Markdown 排版 */
|
.md-body { font-size: 14px; line-height: 1.75; color: #303133; white-space: normal; }
|
.md-body .ai-h { margin: 12px 0 6px; font-size: 16px; font-weight: 700; color: #1f2d3d; border-left: 4px solid #409EFF; padding: 5px 8px; background: #f0f7ff; border-radius: 0 4px 4px 0; }
|
.md-body .ai-h3 { margin: 10px 0 4px; font-size: 14.5px; font-weight: 700; color: #34495e; }
|
.md-body .ai-p { margin: 4px 0; }
|
.md-body .ai-blank { height: 6px; }
|
.md-body .ai-ul, .md-body .ai-ol { margin: 4px 0 4px 4px; padding-left: 20px; }
|
.md-body .ai-ul li, .md-body .ai-ol li { margin: 3px 0; }
|
.md-body .ai-code { background: #f0f2f5; border: 1px solid #e4e7ed; border-radius: 3px; padding: 1px 5px; font-family: Consolas, Menlo, monospace; font-size: 12.5px; color: #e34d59; }
|
.md-body strong { color: #1f6feb; font-weight: 700; }
|
.md-body em { color: #606266; }
|
.md-body .ai-quote { border-left: 3px solid #c0c4cc; background: #fafafa; padding: 6px 10px; margin: 6px 0; color: #606266; border-radius: 0 4px 4px 0; }
|
.md-body .ai-hr { border: none; border-top: 1px dashed #dcdfe6; margin: 8px 0; }
|
.md-body .ai-table { border-collapse: collapse; width: 100%; margin: 6px 0; font-size: 13px; }
|
.md-body .ai-table th, .md-body .ai-table td { border: 1px solid #dcdfe6; padding: 5px 8px; text-align: left; }
|
.md-body .ai-table th { background: #f0f7ff; color: #1f2d3d; font-weight: 700; }
|
.md-body .ai-table tr:nth-child(even) td { background: #fafbfc; }
|
.md-body .ai-label { margin: 8px 0; padding: 7px 10px; border-radius: 5px; font-weight: 700; font-size: 14px; border-left: 4px solid; }
|
.md-body .ai-label-conclusion { color: #1f6feb; background: #eaf2ff; border-color: #1f6feb; }
|
.md-body .ai-label-advice { color: #12805c; background: #e8faf1; border-color: #12805c; }
|
.md-body .ai-label-risk { color: #c95f0d; background: #fff4e5; border-color: #e8820c; }
|
.md-body .ai-label-basis { color: #0f7d86; background: #e6f7f8; border-color: #0f8a8a; }
|
</style>
|