增加相关功能
This commit is contained in:
1 parent
44a03186da
commit
d8936d9730
30 files changed
+4503
-2412
No files matched your search
@@ -0,0 +1,746 @@
|
||||
<template>
|
||||
<div class="chat-window">
|
||||
<div class="chat-header">
|
||||
<span class="chat-title">{{ sessionTitle || 'AI广场' }}</span>
|
||||
<div class="header-selectors">
|
||||
<el-tooltip :content="defaultPreset ? '当前预设:' + defaultPreset.name + ':' + defaultPreset.content : '未设置默认预设'" placement="bottom">
|
||||
<el-button
|
||||
size="small"
|
||||
:icon="UserFilled"
|
||||
class="preset-btn"
|
||||
:class="{ 'has-preset': !!defaultPreset }"
|
||||
@click="presetVisible = true"
|
||||
>
|
||||
{{ defaultPreset ? defaultPreset.name : '角色预设' }}
|
||||
</el-button>
|
||||
</el-tooltip>
|
||||
<el-select
|
||||
v-model="selectedProviderId"
|
||||
placeholder="选择接入"
|
||||
size="small"
|
||||
class="provider-select"
|
||||
@change="handleProviderChange"
|
||||
>
|
||||
<el-option
|
||||
v-for="p in providerList"
|
||||
:key="p.id"
|
||||
:label="p.name + ' (' + (p.provider_type === 'openai' ? 'OpenAI' : 'Anthropic') + ')'"
|
||||
:value="p.id"
|
||||
/>
|
||||
</el-select>
|
||||
<el-select
|
||||
v-model="selectedModel"
|
||||
placeholder="选择模型"
|
||||
size="small"
|
||||
class="model-select"
|
||||
:disabled="!selectedProviderId"
|
||||
>
|
||||
<el-option
|
||||
v-for="m in currentModels"
|
||||
:key="m"
|
||||
:label="m"
|
||||
:value="m"
|
||||
/>
|
||||
</el-select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="chat-messages" ref="messagesRef" v-loading="loading">
|
||||
<div v-if="messages.length === 0 && !loading" class="empty-state">
|
||||
<el-empty description="开始与AI对话吧" :image-size="100" />
|
||||
<div class="quick-tips">
|
||||
<el-tag v-for="tip in quickTips" :key="tip" class="tip-tag" @click="handleQuickTip(tip)">
|
||||
{{ tip }}
|
||||
</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="(msg, idx) in messages"
|
||||
:key="msg.id || idx"
|
||||
class="message-item"
|
||||
:class="msg.role === 'user' ? 'user-msg' : 'assistant-msg'"
|
||||
>
|
||||
<div class="message-avatar">
|
||||
<el-icon v-if="msg.role === 'user'"><User /></el-icon>
|
||||
<el-icon v-else><Cpu /></el-icon>
|
||||
</div>
|
||||
<div class="message-content">
|
||||
<!-- 编辑模式 -->
|
||||
<div v-if="editingId === msg.id" class="edit-area">
|
||||
<el-input
|
||||
v-model="editContent"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
resize="none"
|
||||
/>
|
||||
<div class="edit-actions">
|
||||
<el-button size="small" type="primary" :loading="editSaving" @click="saveEdit(msg)">保存</el-button>
|
||||
<el-button size="small" @click="cancelEdit">取消</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 正常显示 -->
|
||||
<template v-else>
|
||||
<div class="message-bubble" :class="{ typing: msg.role === 'assistant' && !msg.content && sending }">
|
||||
<template v-if="msg.role === 'assistant' && !msg.content && sending">
|
||||
<span class="typing-dot"></span>
|
||||
<span class="typing-dot"></span>
|
||||
<span class="typing-dot"></span>
|
||||
</template>
|
||||
<pre v-else class="message-text">{{ msg.content }}</pre>
|
||||
</div>
|
||||
<div class="message-actions">
|
||||
<el-tooltip content="复制" placement="top">
|
||||
<el-button link type="primary" :icon="CopyDocument" class="action-btn" @click="copyMessage(msg)" />
|
||||
</el-tooltip>
|
||||
<el-tooltip v-if="msg.role === 'user'" content="编辑" placement="top">
|
||||
<el-button link type="primary" :icon="Edit" class="action-btn" @click="startEdit(msg)" />
|
||||
</el-tooltip>
|
||||
<el-tooltip :content="msg.role === 'user' ? '重新发送' : '重新生成'" placement="top">
|
||||
<el-button link type="primary" :icon="RefreshRight" class="action-btn" @click="resendMessage(msg, idx)" />
|
||||
</el-tooltip>
|
||||
<el-tooltip content="删除" placement="top">
|
||||
<el-button link type="danger" :icon="Delete" class="action-btn" @click="deleteMessage(msg, idx)" />
|
||||
</el-tooltip>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="chat-input-area">
|
||||
<div class="input-wrapper">
|
||||
<el-input
|
||||
v-model="inputText"
|
||||
type="textarea"
|
||||
:rows="2"
|
||||
:autosize="{ minRows: 2, maxRows: 6 }"
|
||||
placeholder="输入消息,Enter发送,Shift+Enter换行"
|
||||
@keydown.enter.exact.prevent="handleSend"
|
||||
resize="none"
|
||||
/>
|
||||
<el-button
|
||||
type="primary"
|
||||
class="send-btn"
|
||||
:icon="Promotion"
|
||||
:loading="sending"
|
||||
:disabled="!inputText.trim()"
|
||||
@click="handleSend"
|
||||
>
|
||||
发送
|
||||
</el-button>
|
||||
</div>
|
||||
<div class="input-tip">AI生成内容仅供参考,请核实重要信息</div>
|
||||
</div>
|
||||
|
||||
<PresetDialog v-model="presetVisible" @change="fetchDefaultPreset" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, nextTick, watch, computed, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { User, Cpu, Promotion, Edit, CopyDocument, RefreshRight, Delete, UserFilled } from '@element-plus/icons-vue'
|
||||
import { getChatMessageList, sendChatMessage, getAiProviderList, deleteChatMessage, getAiPresetList } from '@/api/ai'
|
||||
import PresetDialog from './presetDialog.vue'
|
||||
|
||||
const props = defineProps({
|
||||
sessionId: { type: [Number, String], default: null },
|
||||
sessionTitle: { type: String, default: '' }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['session-created'])
|
||||
|
||||
const loading = ref(false)
|
||||
const sending = ref(false)
|
||||
const inputText = ref('')
|
||||
const messages = ref([])
|
||||
const messagesRef = ref()
|
||||
|
||||
// 消息编辑
|
||||
const editingId = ref(-1)
|
||||
const editContent = ref('')
|
||||
const editSaving = ref(false)
|
||||
|
||||
// 接入配置和模型选择
|
||||
const providerList = ref([])
|
||||
const selectedProviderId = ref(null)
|
||||
const selectedModel = ref('')
|
||||
|
||||
// 角色预设
|
||||
const presetVisible = ref(false)
|
||||
const defaultPreset = ref(null)
|
||||
|
||||
const currentModels = computed(() => {
|
||||
const p = providerList.value.find(item => item.id === selectedProviderId.value)
|
||||
return p && p.models ? p.models : []
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
fetchProviders()
|
||||
fetchDefaultPreset()
|
||||
})
|
||||
|
||||
async function fetchDefaultPreset() {
|
||||
try {
|
||||
const res = await getAiPresetList()
|
||||
if (res.code === 200 && res.data.list) {
|
||||
defaultPreset.value = res.data.list.find(p => p.is_default === 1) || null
|
||||
}
|
||||
} catch (e) {
|
||||
// 静默失败
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchProviders() {
|
||||
try {
|
||||
const res = await getAiProviderList()
|
||||
if (res.code === 200) {
|
||||
providerList.value = res.data.list || []
|
||||
// 默认选第一个启用的
|
||||
const firstActive = providerList.value.find(p => p.status === 1)
|
||||
if (firstActive) {
|
||||
selectedProviderId.value = firstActive.id
|
||||
if (firstActive.models && firstActive.models.length > 0) {
|
||||
selectedModel.value = firstActive.models[0]
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// 静默失败,用户可以在设置中配置
|
||||
}
|
||||
}
|
||||
|
||||
function handleProviderChange() {
|
||||
selectedModel.value = ''
|
||||
if (currentModels.value.length > 0) {
|
||||
selectedModel.value = currentModels.value[0]
|
||||
}
|
||||
}
|
||||
|
||||
const quickTips = [
|
||||
'帮我写一份项目计划书',
|
||||
'解释一下什么是微服务架构',
|
||||
'用Go语言写一个HTTP服务器',
|
||||
'总结一下今天的工作要点'
|
||||
]
|
||||
|
||||
watch(
|
||||
() => props.sessionId,
|
||||
(val) => {
|
||||
if (val) {
|
||||
fetchMessages(val)
|
||||
} else {
|
||||
messages.value = []
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
async function fetchMessages(sessionId) {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await getChatMessageList(sessionId)
|
||||
if (res.code === 200) {
|
||||
messages.value = res.data.list || []
|
||||
scrollToBottom()
|
||||
}
|
||||
} catch (e) {
|
||||
ElMessage.error(e.message || '加载消息失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSend() {
|
||||
const content = inputText.value.trim()
|
||||
if (!content || sending.value) return
|
||||
inputText.value = ''
|
||||
await doSend(content)
|
||||
}
|
||||
|
||||
// ===== 消息操作 =====
|
||||
function startEdit(msg) {
|
||||
editingId.value = msg.id
|
||||
editContent.value = msg.content
|
||||
}
|
||||
|
||||
function cancelEdit() {
|
||||
editingId.value = -1
|
||||
editContent.value = ''
|
||||
}
|
||||
|
||||
async function saveEdit(msg) {
|
||||
const content = editContent.value.trim()
|
||||
if (!content) return
|
||||
editSaving.value = true
|
||||
try {
|
||||
// 删除原消息及之后的所有消息,然后用新内容重新发送
|
||||
const idx = messages.value.findIndex(m => m.id === msg.id)
|
||||
if (idx >= 0) {
|
||||
// 删除该条及之后的消息(本地)
|
||||
const toDelete = messages.value.slice(idx)
|
||||
messages.value = messages.value.slice(0, idx)
|
||||
// 后端删除(有id的消息)
|
||||
for (const m of toDelete) {
|
||||
if (m.id) {
|
||||
try { await deleteChatMessage(m.id) } catch (e) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
cancelEdit()
|
||||
// 用新内容重新发送
|
||||
await doSend(content)
|
||||
} catch (e) {
|
||||
ElMessage.error(e.message || '操作失败')
|
||||
} finally {
|
||||
editSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function copyMessage(msg) {
|
||||
const text = msg.content
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
ElMessage.success('已复制到剪贴板')
|
||||
}).catch(() => {
|
||||
fallbackCopy(text)
|
||||
})
|
||||
} else {
|
||||
fallbackCopy(text)
|
||||
}
|
||||
}
|
||||
|
||||
function fallbackCopy(text) {
|
||||
const textarea = document.createElement('textarea')
|
||||
textarea.value = text
|
||||
textarea.style.position = 'fixed'
|
||||
textarea.style.opacity = '0'
|
||||
document.body.appendChild(textarea)
|
||||
textarea.select()
|
||||
try {
|
||||
document.execCommand('copy')
|
||||
ElMessage.success('已复制到剪贴板')
|
||||
} catch (e) {
|
||||
ElMessage.error('复制失败')
|
||||
}
|
||||
document.body.removeChild(textarea)
|
||||
}
|
||||
|
||||
async function resendMessage(msg, idx) {
|
||||
// 找到对应的用户消息
|
||||
let userMsg = msg
|
||||
if (msg.role === 'assistant') {
|
||||
// 往前找最近的用户消息
|
||||
for (let i = idx - 1; i >= 0; i--) {
|
||||
if (messages.value[i].role === 'user') {
|
||||
userMsg = messages.value[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!userMsg || !userMsg.content) {
|
||||
ElMessage.warning('找不到对应的用户消息')
|
||||
return
|
||||
}
|
||||
|
||||
// 删除该用户消息及之后的所有消息
|
||||
const userIdx = messages.value.findIndex(m => m === userMsg)
|
||||
if (userIdx >= 0) {
|
||||
const toDelete = messages.value.slice(userIdx)
|
||||
messages.value = messages.value.slice(0, userIdx)
|
||||
for (const m of toDelete) {
|
||||
if (m.id) {
|
||||
try { await deleteChatMessage(m.id) } catch (e) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 重新发送
|
||||
await doSend(userMsg.content)
|
||||
}
|
||||
|
||||
async function deleteMessage(msg, idx) {
|
||||
try {
|
||||
if (msg.id) {
|
||||
await deleteChatMessage(msg.id)
|
||||
}
|
||||
messages.value.splice(idx, 1)
|
||||
ElMessage.success('已删除')
|
||||
} catch (e) {
|
||||
ElMessage.error(e.message || '删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 内部发送方法(供编辑/重新发送调用)
|
||||
async function doSend(content) {
|
||||
if (!content || sending.value) return
|
||||
if (!selectedProviderId.value) {
|
||||
ElMessage.warning('请先选择接入')
|
||||
return
|
||||
}
|
||||
if (!selectedModel.value) {
|
||||
ElMessage.warning('请选择模型')
|
||||
return
|
||||
}
|
||||
|
||||
// 添加用户消息
|
||||
messages.value.push({ role: 'user', content })
|
||||
// 添加空的AI消息,用于流式更新
|
||||
const aiMsg = { id: null, role: 'assistant', content: '' }
|
||||
messages.value.push(aiMsg)
|
||||
sending.value = true
|
||||
scrollToBottom()
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem('token')
|
||||
const apiBase = import.meta.env.VITE_API_BASE_URL || ''
|
||||
const response = await fetch(`${apiBase}/backend/ai/chat/send-stream`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
session_id: props.sessionId || 0,
|
||||
content,
|
||||
provider_id: selectedProviderId.value,
|
||||
model: selectedModel.value
|
||||
})
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`)
|
||||
}
|
||||
|
||||
const reader = response.body.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
let buffer = ''
|
||||
let sessionCreated = false
|
||||
let currentEvent = ''
|
||||
let currentData = ''
|
||||
|
||||
const handleSSEEvent = (eventType, data) => {
|
||||
if (!data) return
|
||||
try {
|
||||
const parsed = JSON.parse(data)
|
||||
if (eventType === 'session') {
|
||||
if (!props.sessionId && parsed.session_id && !sessionCreated) {
|
||||
sessionCreated = true
|
||||
emit('session-created', {
|
||||
id: parsed.session_id,
|
||||
title: content.length > 20 ? content.slice(0, 20) + '...' : content
|
||||
})
|
||||
}
|
||||
} else if (eventType === 'content') {
|
||||
aiMsg.content += parsed.content
|
||||
scrollToBottom()
|
||||
} else if (eventType === 'done') {
|
||||
aiMsg.id = parsed.message_id
|
||||
} else if (eventType === 'error') {
|
||||
throw new Error(parsed.error || 'AI调用失败')
|
||||
}
|
||||
} catch (e) {
|
||||
if (e.message === 'AI调用失败') throw e
|
||||
// JSON解析错误忽略
|
||||
}
|
||||
}
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
|
||||
buffer += decoder.decode(value, { stream: true })
|
||||
// 统一换行符
|
||||
buffer = buffer.replace(/\r\n/g, '\n')
|
||||
const lines = buffer.split('\n')
|
||||
// 最后一行可能不完整,留到下一次
|
||||
buffer = lines.pop() || ''
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('event: ')) {
|
||||
currentEvent = line.slice(7)
|
||||
} else if (line.startsWith('data: ')) {
|
||||
currentData = line.slice(6)
|
||||
} else if (line === '') {
|
||||
// 空行表示一个事件结束
|
||||
handleSSEEvent(currentEvent, currentData)
|
||||
currentEvent = ''
|
||||
currentData = ''
|
||||
}
|
||||
}
|
||||
}
|
||||
// 处理缓冲区中剩余的最后一个事件
|
||||
if (currentData) {
|
||||
handleSSEEvent(currentEvent, currentData)
|
||||
}
|
||||
} catch (e) {
|
||||
ElMessage.error(e.message || '发送失败')
|
||||
// 移除空的AI消息
|
||||
if (aiMsg.content === '') {
|
||||
const idx = messages.value.indexOf(aiMsg)
|
||||
if (idx >= 0) messages.value.splice(idx, 1)
|
||||
}
|
||||
} finally {
|
||||
sending.value = false
|
||||
scrollToBottom()
|
||||
}
|
||||
}
|
||||
|
||||
function handleQuickTip(tip) {
|
||||
inputText.value = tip
|
||||
}
|
||||
|
||||
function scrollToBottom() {
|
||||
nextTick(() => {
|
||||
if (messagesRef.value) {
|
||||
messagesRef.value.scrollTop = messagesRef.value.scrollHeight
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
clearMessages: () => { messages.value = [] }
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.chat-window {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
background: var(--el-bg-color);
|
||||
}
|
||||
|
||||
.chat-header {
|
||||
padding: 12px 24px;
|
||||
border-bottom: 1px solid var(--el-border-color-lighter);
|
||||
font-weight: 600;
|
||||
font-size: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
|
||||
.chat-title {
|
||||
flex-shrink: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.header-selectors {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
|
||||
.preset-btn {
|
||||
height: 32px;
|
||||
max-width: 160px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
|
||||
&.has-preset {
|
||||
background: var(--el-color-primary-light-9);
|
||||
border-color: var(--el-color-primary);
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.provider-select {
|
||||
width: 200px;
|
||||
}
|
||||
|
||||
.model-select {
|
||||
width: 180px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.chat-messages {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
|
||||
.quick-tips {
|
||||
margin-top: 16px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
justify-content: center;
|
||||
max-width: 500px;
|
||||
|
||||
.tip-tag {
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.message-item {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-bottom: 24px;
|
||||
|
||||
.message-avatar {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.message-content {
|
||||
flex: 1;
|
||||
max-width: calc(100% - 60px);
|
||||
}
|
||||
|
||||
.message-bubble {
|
||||
display: inline-block;
|
||||
padding: 12px 16px;
|
||||
border-radius: 12px;
|
||||
line-height: 1.6;
|
||||
word-break: break-word;
|
||||
|
||||
.message-text {
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
font-family: inherit;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.message-actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
margin-top: 4px;
|
||||
|
||||
.action-btn {
|
||||
padding: 2px;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.edit-area {
|
||||
display: inline-block;
|
||||
text-align: left;
|
||||
width: 100%;
|
||||
max-width: 500px;
|
||||
|
||||
.edit-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
|
||||
&.user-msg {
|
||||
flex-direction: row-reverse;
|
||||
|
||||
.message-avatar {
|
||||
background: var(--el-color-primary);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.message-content {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.message-actions {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.message-bubble {
|
||||
background: var(--el-color-primary);
|
||||
color: #fff;
|
||||
border-top-right-radius: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
&.assistant-msg {
|
||||
.message-avatar {
|
||||
background: var(--el-color-success-light-9);
|
||||
color: var(--el-color-success);
|
||||
}
|
||||
|
||||
.message-bubble {
|
||||
background: var(--el-fill-color);
|
||||
border-top-left-radius: 4px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.typing {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
min-height: 24px;
|
||||
|
||||
.typing-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--el-text-color-secondary);
|
||||
animation: typing 1.4s infinite;
|
||||
|
||||
&:nth-child(2) {
|
||||
animation-delay: 0.2s;
|
||||
}
|
||||
&:nth-child(3) {
|
||||
animation-delay: 0.4s;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes typing {
|
||||
0%, 60%, 100% {
|
||||
transform: translateY(0);
|
||||
opacity: 0.4;
|
||||
}
|
||||
30% {
|
||||
transform: translateY(-6px);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.chat-input-area {
|
||||
padding: 16px 24px;
|
||||
border-top: 1px solid var(--el-border-color-lighter);
|
||||
background: var(--el-bg-color);
|
||||
|
||||
.input-wrapper {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: flex-end;
|
||||
|
||||
.send-btn {
|
||||
height: 40px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.input-tip {
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-placeholder);
|
||||
margin-top: 8px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in new issue
Block a user