| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965 |
- <template>
- <a-drawer
- :visible="visible"
- :width="drawerWidth"
- :placement="isMobile ? 'bottom' : 'right'"
- :height="isMobile ? '100vh' : undefined"
- :footer="false"
- class="ai-chat-drawer"
- unmount-on-close
- @cancel="close"
- >
- <template #title>
- <div class="ai-chat-title">
- <a-avatar :size="36" class="ai-chat-title__avatar">
- <img src="@/assets/img/ai.png" alt="小妍助理" />
- </a-avatar>
- <div class="ai-chat-title__copy">
- <div class="ai-chat-title__name">小妍助理</div>
- <div class="ai-chat-title__sub">
- <span class="status-dot"></span>
- 在线
- </div>
- </div>
- </div>
- </template>
- <div class="ai-chat-shell" @paste="handlePaste">
- <aside class="conversation-panel" :class="{ 'conversation-panel--open': showConversationPanel }">
- <div class="conversation-panel__head">
- <div>
- <div class="conversation-panel__label">会话</div>
- <div class="conversation-panel__count">{{ conversations.length }} 条</div>
- </div>
- <a-tooltip content="新建对话">
- <a-button type="primary" shape="circle" @click="createConversationAndOpen">
- <template #icon><icon-plus /></template>
- </a-button>
- </a-tooltip>
- </div>
- <div class="conversation-list">
- <button
- v-for="item in conversations"
- :key="item.id"
- class="conversation-item"
- :class="{ 'conversation-item--active': item.id === currentConversationId }"
- @click="openConversation(item.id)"
- >
- <span class="conversation-item__top">
- <span class="conversation-item__title">{{ item.title || '小妍助理对话' }}</span>
- <span v-if="unreadMap[item.id]" class="conversation-item__dot"></span>
- </span>
- <span class="conversation-item__preview">{{ item.last_message_preview || '尚未开始' }}</span>
- <span class="conversation-item__foot">
- <span>{{ formatShortTime(item.update_time) || '新对话' }}</span>
- <span v-if="item.conversation_no">NO.{{ item.conversation_no }}</span>
- </span>
- </button>
- <div v-if="!conversations.length" class="conversation-empty">
- <a-avatar :size="42" class="conversation-empty__avatar">
- <img src="@/assets/img/ai.png" alt="小妍助理" />
- </a-avatar>
- <div>暂无会话</div>
- </div>
- </div>
- </aside>
- <main
- class="chat-main"
- :class="{ 'chat-main--dragging': dragging }"
- @dragenter.prevent="dragging = true"
- @dragover.prevent="dragging = true"
- @dragleave.prevent="dragging = false"
- @drop.prevent="handleDrop"
- >
- <div class="chat-toolbar">
- <a-button v-if="isMobile" type="text" shape="circle" @click="showConversationPanel = !showConversationPanel">
- <template #icon><icon-menu /></template>
- </a-button>
- <div class="chat-toolbar__identity">
- <a-avatar :size="34" class="chat-toolbar__avatar">
- <img src="@/assets/img/ai.png" alt="小妍助理" />
- </a-avatar>
- <div class="chat-toolbar__text">
- <strong>{{ currentConversation?.title || '新对话' }}</strong>
- <span>{{ currentConversation?.conversation_no ? `NO.${currentConversation.conversation_no}` : '准备开始' }}</span>
- </div>
- </div>
- <a-popconfirm content="是否删除该对话?" @ok="deleteCurrentConversation">
- <a-button :disabled="!currentConversationId" type="text" shape="circle" status="danger">
- <template #icon><icon-delete /></template>
- </a-button>
- </a-popconfirm>
- </div>
- <div ref="messageContainer" class="message-list">
- <div v-if="!currentConversationId" class="empty-state">
- <a-avatar :size="64" class="empty-state__avatar">
- <img src="@/assets/img/ai.png" alt="小妍助理" />
- </a-avatar>
- <div class="empty-state__title">开启一段新对话</div>
- </div>
- <template v-else>
- <div v-if="messagesLoading" class="message-loading">
- <a-spin />
- </div>
- <div v-if="!messagesLoading && !messages.length" class="welcome-card">
- <a-avatar :size="52" class="welcome-card__avatar">
- <img src="@/assets/img/ai.png" alt="小妍助理" />
- </a-avatar>
- <div>
- <div class="welcome-card__title">我是小妍助理</div>
- <div class="welcome-card__text">可以开始输入问题。</div>
- </div>
- </div>
- <div
- v-for="message in messages"
- :key="message.id"
- class="message-row"
- :class="`message-row--${message.role}`"
- >
- <a-avatar :size="36" class="message-avatar">
- <img v-if="message.role === 'assistant'" src="@/assets/img/ai.png" alt="小妍助理" />
- <icon-user v-else-if="message.role === 'user'" />
- <icon-info-circle v-else />
- </a-avatar>
- <div class="message-stack">
- <div class="message-meta">
- <span>{{ roleLabel(message.role) }}</span>
- <span>{{ formatFullTime(message.create_time) }}</span>
- <a-tag v-if="message.status === 'error'" size="small" color="red">异常</a-tag>
- </div>
- <div class="message-bubble">
- <div v-if="message.content" class="message-content" v-html="renderMarkdown(message.content)"></div>
- <div v-if="message.images?.length" class="message-images">
- <a-image
- v-for="url in message.images"
- :key="url"
- :src="url"
- width="118"
- height="118"
- fit="cover"
- />
- </div>
- <div v-if="message.error_msg" class="message-error">{{ message.error_msg }}</div>
- </div>
- </div>
- </div>
- </template>
- </div>
- <div v-if="uploadingImages.length" class="image-preview-bar">
- <div v-for="item in uploadingImages" :key="item.id" class="image-preview">
- <a-image :src="item.url" width="62" height="62" fit="cover" />
- <a-button size="mini" shape="circle" @click="removeImage(item.id)">
- <template #icon><icon-close /></template>
- </a-button>
- </div>
- </div>
- <div class="composer-wrap">
- <div class="composer">
- <input ref="fileInput" type="file" accept="image/png,image/jpeg,image/jpg,image/gif" multiple hidden @change="handleFileInput" />
- <a-tooltip content="添加图片">
- <a-button :loading="uploading" shape="circle" class="composer__tool" @click="fileInput?.click()">
- <template #icon><icon-image /></template>
- </a-button>
- </a-tooltip>
- <a-textarea
- v-model="input"
- placeholder="输入消息"
- :max-length="1000"
- :auto-size="{ minRows: 1, maxRows: 5 }"
- allow-clear
- @keydown.enter.exact.prevent="sendMessage"
- />
- <a-button type="primary" shape="circle" :loading="sending" :disabled="!canSend" class="composer__send" @click="sendMessage">
- <template #icon><icon-send /></template>
- </a-button>
- </div>
- </div>
- </main>
- </div>
- </a-drawer>
- </template>
- <script setup>
- import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
- import { Message, Notification } from '@arco-design/web-vue'
- import MarkdownIt from 'markdown-it'
- import storage from 'store'
- import { useUserStore } from '@/store/modules/user'
- import { uploadImg } from '@/api/upload'
- import {
- createAIChatConversation,
- deleteAIChatConversation,
- listAIChatConversations,
- listAIChatMessages,
- sendAIChatMessage
- } from '@/api/aiChat'
- const props = defineProps({
- visible: {
- type: Boolean,
- default: false
- }
- })
- const emit = defineEmits(['close', 'unread-change'])
- const md = new MarkdownIt({ html: false, linkify: true, breaks: true })
- const userStore = useUserStore()
- const isMobile = ref(window.innerWidth <= 768)
- const showConversationPanel = ref(false)
- const conversations = ref([])
- const messages = ref([])
- const currentConversationId = ref(null)
- const input = ref('')
- const uploadingImages = ref([])
- const messagesLoading = ref(false)
- const sending = ref(false)
- const uploading = ref(false)
- const dragging = ref(false)
- const messageContainer = ref(null)
- const fileInput = ref(null)
- const unreadMap = ref({})
- let conversationTimer = null
- let messageTimer = null
- const visible = computed(() => props.visible)
- const drawerWidth = computed(() => (isMobile.value ? '100%' : '940px'))
- const currentConversation = computed(() => conversations.value.find(item => item.id === currentConversationId.value))
- const canSend = computed(() => currentConversationId.value && (input.value.trim() || uploadingImages.value.length > 0))
- const unreadTotal = computed(() => Object.values(unreadMap.value).reduce((sum, value) => sum + Number(value || 0), 0))
- const seenKey = computed(() => `aiChatSeen:${userStore.uuid || 'guest'}`)
- const roleLabel = (role) => ({ user: '我', assistant: '小妍助理', system: '系统' }[role] || role)
- const renderMarkdown = (text) => md.render(text || '')
- const formatFullTime = (time) => time ? new Date(Number(time)).toLocaleString('zh-CN', { hour12: false }) : ''
- const formatShortTime = (time) => {
- if (!time) return ''
- return new Date(Number(time)).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false })
- }
- const loadSeen = () => storage.get(seenKey.value) || {}
- const saveSeen = (seen) => storage.set(seenKey.value, seen)
- const updateUnreadByConversations = (list) => {
- const oldTotal = unreadTotal.value
- const seen = loadSeen()
- const next = {}
- list.forEach((item) => {
- if (item.id === currentConversationId.value && visible.value) return
- if (Number(item.last_message_time || 0) > Number(seen[item.id] || 0)) next[item.id] = 1
- })
- unreadMap.value = next
- emit('unread-change', unreadTotal.value)
- if (!visible.value && unreadTotal.value > oldTotal) {
- Notification.info({ title: '小妍助理有新消息', content: '点击右下角助手查看回复' })
- }
- }
- const markCurrentSeen = () => {
- if (!currentConversationId.value) return
- const seen = loadSeen()
- const last = messages.value[messages.value.length - 1]
- const conv = currentConversation.value
- seen[currentConversationId.value] = Math.max(Number(last?.create_time || 0), Number(conv?.last_message_time || 0), Date.now())
- saveSeen(seen)
- delete unreadMap.value[currentConversationId.value]
- unreadMap.value = { ...unreadMap.value }
- emit('unread-change', unreadTotal.value)
- }
- const scrollToBottom = () => {
- nextTick(() => {
- if (messageContainer.value) messageContainer.value.scrollTop = messageContainer.value.scrollHeight
- })
- }
- const loadConversations = async () => {
- const res = await listAIChatConversations({ current: 1, pagesize: 30 })
- if (!res || res.code !== 0) return
- conversations.value = res.data || []
- updateUnreadByConversations(conversations.value)
- if (visible.value && !currentConversationId.value && conversations.value.length) {
- await openConversation(conversations.value[0].id)
- }
- }
- const loadMessages = async () => {
- if (!currentConversationId.value) return
- messagesLoading.value = true
- try {
- const res = await listAIChatMessages({ conversation_id: currentConversationId.value, current: 1, pagesize: 80 })
- if (!res || res.code !== 0) return Message.error(res?.msg || '获取消息失败')
- messages.value = res.data || []
- markCurrentSeen()
- scrollToBottom()
- } finally {
- messagesLoading.value = false
- }
- }
- const pollNewMessages = async () => {
- if (!currentConversationId.value || !visible.value) return
- const lastId = messages.value[messages.value.length - 1]?.id || 0
- const res = await listAIChatMessages({ conversation_id: currentConversationId.value, after_id: lastId })
- if (!res || res.code !== 0) return
- const incoming = res.data || []
- if (!incoming.length) return
- messages.value.push(...incoming)
- const hasAssistant = incoming.some(item => item.role === 'assistant' || item.role === 'system')
- if (hasAssistant) Notification.info({ title: '小妍助理有新回复', content: incoming[incoming.length - 1]?.content || '收到新消息' })
- markCurrentSeen()
- scrollToBottom()
- await loadConversations()
- }
- const openConversation = async (id) => {
- currentConversationId.value = id
- showConversationPanel.value = false
- await loadMessages()
- }
- const createConversationAndOpen = async () => {
- const blank = conversations.value.find(item => Number(item.last_message_time || 0) === 0)
- if (blank) {
- await openConversation(blank.id)
- return
- }
- const res = await createAIChatConversation({ title: '小妍助理对话' })
- if (!res || res.code !== 0) return Message.error(res?.msg || '创建会话失败')
- currentConversationId.value = res.data.id
- messages.value = []
- await loadConversations()
- scrollToBottom()
- }
- const deleteCurrentConversation = async () => {
- if (!currentConversationId.value) return
- const res = await deleteAIChatConversation({ id: currentConversationId.value })
- if (!res || res.code !== 0) return Message.error(res?.msg || '删除失败')
- currentConversationId.value = null
- messages.value = []
- await loadConversations()
- }
- const uploadFiles = async (files) => {
- const imageFiles = Array.from(files || []).filter(file => file.type?.startsWith('image/')).slice(0, 6 - uploadingImages.value.length)
- if (!imageFiles.length) return
- uploading.value = true
- try {
- for (const file of imageFiles) {
- const formData = new FormData()
- formData.append('uuid', userStore.uuid)
- formData.append('session', userStore.session)
- formData.append('upload', file)
- const res = await uploadImg(formData)
- if (!res || res.code !== 0) {
- Message.error(res?.msg || '图片上传失败')
- continue
- }
- const url = res.data?.picturePath
- if (url) uploadingImages.value.push({ id: `${Date.now()}-${Math.random()}`, url })
- }
- } finally {
- uploading.value = false
- if (fileInput.value) fileInput.value.value = ''
- }
- }
- const handleFileInput = (event) => uploadFiles(event.target.files)
- const handlePaste = (event) => {
- const files = Array.from(event.clipboardData?.files || [])
- if (files.length) uploadFiles(files)
- }
- const handleDrop = (event) => {
- dragging.value = false
- uploadFiles(event.dataTransfer?.files)
- }
- const removeImage = (id) => {
- uploadingImages.value = uploadingImages.value.filter(item => item.id !== id)
- }
- const sendMessage = async () => {
- if (!canSend.value || sending.value) return
- const content = input.value.trim()
- const images = uploadingImages.value.map(item => item.url)
- input.value = ''
- uploadingImages.value = []
- sending.value = true
- try {
- const res = await sendAIChatMessage({ conversation_id: currentConversationId.value, content, images })
- if (!res || res.code !== 0) {
- Message.error(res?.msg || '发送失败')
- input.value = content
- uploadingImages.value = images.map(url => ({ id: `${Date.now()}-${Math.random()}`, url }))
- return
- }
- await loadMessages()
- await loadConversations()
- } finally {
- sending.value = false
- }
- }
- const close = () => emit('close')
- const syncMobile = () => { isMobile.value = window.innerWidth <= 768 }
- const startPolling = () => {
- if (!conversationTimer) conversationTimer = setInterval(loadConversations, 8000)
- if (!messageTimer) messageTimer = setInterval(pollNewMessages, 3000)
- }
- const stopPolling = () => {
- clearInterval(conversationTimer)
- clearInterval(messageTimer)
- conversationTimer = null
- messageTimer = null
- }
- watch(() => props.visible, async (value) => {
- if (value) {
- await loadConversations()
- if (!currentConversationId.value) await createConversationAndOpen()
- markCurrentSeen()
- scrollToBottom()
- }
- })
- watch(unreadTotal, value => emit('unread-change', value))
- onMounted(async () => {
- await userStore.getInfo()
- window.addEventListener('resize', syncMobile)
- await loadConversations()
- startPolling()
- })
- onUnmounted(() => {
- window.removeEventListener('resize', syncMobile)
- stopPolling()
- })
- </script>
- <style lang="less" scoped>
- .ai-chat-title {
- display: flex;
- align-items: center;
- gap: 12px;
- &__avatar {
- background: linear-gradient(135deg, #eaf2ff, #f7ecff);
- box-shadow: 0 8px 20px rgba(48, 91, 189, 0.16);
- }
- &__copy {
- display: grid;
- gap: 3px;
- }
- &__name {
- color: #1d2129;
- font-size: 16px;
- font-weight: 700;
- line-height: 1.2;
- }
- &__sub {
- display: inline-flex;
- align-items: center;
- gap: 6px;
- color: #4e5969;
- font-size: 12px;
- }
- }
- .status-dot {
- width: 7px;
- height: 7px;
- border-radius: 50%;
- background: #16b364;
- box-shadow: 0 0 0 4px rgba(22, 179, 100, 0.12);
- }
- .ai-chat-shell {
- display: grid;
- grid-template-columns: 270px minmax(0, 1fr);
- height: calc(100vh - 92px);
- min-height: 0;
- margin: -12px -8px -16px;
- background:
- radial-gradient(circle at 18% 6%, rgba(36, 99, 235, 0.08), transparent 30%),
- linear-gradient(135deg, #f7faff 0%, #ffffff 46%, #f8fbf8 100%);
- border-top: 1px solid rgba(29, 33, 41, 0.08);
- }
- .conversation-panel {
- min-width: 0;
- border-right: 1px solid rgba(29, 33, 41, 0.08);
- background: rgba(255, 255, 255, 0.72);
- backdrop-filter: blur(18px);
- }
- .conversation-panel__head {
- display: flex;
- align-items: center;
- justify-content: space-between;
- padding: 18px 16px 12px;
- }
- .conversation-panel__label {
- color: #1d2129;
- font-size: 16px;
- font-weight: 720;
- }
- .conversation-panel__count {
- margin-top: 2px;
- color: #86909c;
- font-size: 12px;
- }
- .conversation-list {
- height: calc(100% - 76px);
- overflow-y: auto;
- padding: 0 12px 18px;
- }
- .conversation-item {
- position: relative;
- display: grid;
- width: 100%;
- gap: 7px;
- margin-bottom: 10px;
- padding: 13px 14px;
- border: 1px solid rgba(29, 33, 41, 0.07);
- border-radius: 8px;
- background: rgba(255, 255, 255, 0.58);
- box-shadow: 0 10px 24px rgba(15, 23, 42, 0.04);
- text-align: left;
- cursor: pointer;
- transition: border-color 0.18s ease, box-shadow 0.18s ease, transform 0.18s ease, background 0.18s ease;
- &:hover,
- &--active {
- border-color: rgba(37, 99, 235, 0.36);
- background: #ffffff;
- box-shadow: 0 12px 28px rgba(37, 99, 235, 0.12);
- transform: translateY(-1px);
- }
- &__top,
- &__foot {
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: 10px;
- min-width: 0;
- }
- &__title,
- &__preview {
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
- }
- &__title {
- min-width: 0;
- color: #1d2129;
- font-weight: 650;
- }
- &__preview {
- color: #4e5969;
- font-size: 13px;
- }
- &__foot {
- color: #86909c;
- font-size: 11px;
- }
- &__dot {
- flex: 0 0 auto;
- width: 8px;
- height: 8px;
- border-radius: 50%;
- background: #f53f3f;
- box-shadow: 0 0 0 4px rgba(245, 63, 63, 0.12);
- }
- }
- .conversation-empty {
- display: grid;
- place-items: center;
- gap: 10px;
- padding: 40px 12px;
- color: #86909c;
- font-size: 13px;
- &__avatar {
- background: #eef4ff;
- }
- }
- .chat-main {
- position: relative;
- display: grid;
- grid-template-rows: auto minmax(0, 1fr) auto auto;
- min-width: 0;
- &--dragging::after {
- content: '释放上传图片';
- position: absolute;
- inset: 16px;
- z-index: 5;
- display: grid;
- place-items: center;
- border: 2px dashed #2563eb;
- border-radius: 8px;
- background: rgba(255, 255, 255, 0.88);
- color: #1d4ed8;
- font-size: 15px;
- font-weight: 700;
- backdrop-filter: blur(6px);
- }
- }
- .chat-toolbar {
- display: flex;
- align-items: center;
- gap: 12px;
- min-height: 68px;
- padding: 14px 18px;
- border-bottom: 1px solid rgba(29, 33, 41, 0.08);
- background: rgba(255, 255, 255, 0.62);
- backdrop-filter: blur(16px);
- &__identity {
- display: flex;
- align-items: center;
- gap: 11px;
- flex: 1;
- min-width: 0;
- }
- &__avatar {
- flex: 0 0 auto;
- background: #eef4ff;
- }
- &__text {
- display: grid;
- flex: 1;
- min-width: 0;
- gap: 3px;
- strong,
- span {
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
- }
- strong {
- color: #1d2129;
- font-size: 15px;
- font-weight: 700;
- }
- span {
- color: #86909c;
- font-size: 12px;
- }
- }
- }
- .message-list {
- overflow-y: auto;
- padding: 22px 22px 18px;
- }
- .message-loading {
- display: flex;
- justify-content: center;
- padding: 18px;
- }
- .empty-state,
- .welcome-card {
- display: grid;
- place-items: center;
- gap: 10px;
- height: 100%;
- color: #86909c;
- }
- .empty-state__avatar,
- .welcome-card__avatar {
- background: linear-gradient(135deg, #eaf2ff, #f7ecff);
- }
- .empty-state__title,
- .welcome-card__title {
- color: #1d2129;
- font-size: 16px;
- font-weight: 700;
- }
- .welcome-card {
- justify-content: center;
- align-content: center;
- height: auto;
- min-height: 220px;
- text-align: center;
- }
- .welcome-card__text {
- margin-top: 4px;
- color: #86909c;
- font-size: 13px;
- }
- .message-row {
- display: flex;
- align-items: flex-start;
- gap: 11px;
- margin-bottom: 18px;
- &--user {
- flex-direction: row-reverse;
- .message-stack {
- align-items: flex-end;
- }
- .message-meta {
- flex-direction: row-reverse;
- }
- .message-bubble {
- color: #ffffff;
- background: linear-gradient(135deg, #2563eb, #0f766e);
- box-shadow: 0 12px 28px rgba(37, 99, 235, 0.2);
- }
- .message-content :deep(a) {
- color: #ffffff;
- }
- }
- &--system {
- .message-avatar {
- background: #fff7e8;
- color: #d46b08;
- }
- .message-bubble {
- background: #fffaf0;
- border-color: rgba(245, 158, 11, 0.22);
- }
- }
- }
- .message-avatar {
- flex: 0 0 36px;
- background: #eef4ff;
- box-shadow: 0 8px 20px rgba(15, 23, 42, 0.08);
- }
- .message-stack {
- display: flex;
- flex-direction: column;
- align-items: flex-start;
- max-width: min(78%, 640px);
- min-width: 0;
- }
- .message-meta {
- display: flex;
- flex-wrap: wrap;
- align-items: center;
- gap: 8px;
- margin-bottom: 6px;
- color: #86909c;
- font-size: 12px;
- }
- .message-bubble {
- max-width: 100%;
- padding: 12px 14px;
- border: 1px solid rgba(29, 33, 41, 0.07);
- border-radius: 8px;
- background: rgba(255, 255, 255, 0.86);
- box-shadow: 0 12px 28px rgba(15, 23, 42, 0.06);
- line-height: 1.72;
- word-break: break-word;
- }
- .message-content {
- :deep(p) {
- margin: 0 0 7px;
- }
- :deep(p:last-child) {
- margin-bottom: 0;
- }
- :deep(ul),
- :deep(ol) {
- margin: 6px 0;
- padding-left: 20px;
- }
- :deep(code) {
- border-radius: 4px;
- padding: 2px 5px;
- background: rgba(15, 23, 42, 0.08);
- font-size: 12px;
- }
- }
- .message-images {
- display: flex;
- flex-wrap: wrap;
- gap: 8px;
- margin-top: 9px;
- :deep(.arco-image-img) {
- border-radius: 8px;
- }
- }
- .message-error {
- margin-top: 7px;
- color: #f53f3f;
- font-size: 12px;
- }
- .image-preview-bar {
- display: flex;
- gap: 10px;
- overflow-x: auto;
- padding: 10px 18px 0;
- }
- .image-preview {
- position: relative;
- flex: 0 0 auto;
- border-radius: 8px;
- box-shadow: 0 8px 20px rgba(15, 23, 42, 0.1);
- :deep(.arco-image-img) {
- border-radius: 8px;
- }
- :deep(.arco-btn) {
- position: absolute;
- top: -8px;
- right: -8px;
- background: #ffffff;
- }
- }
- .composer-wrap {
- padding: 12px 18px 18px;
- background: linear-gradient(180deg, rgba(255, 255, 255, 0), rgba(255, 255, 255, 0.9) 32%);
- }
- .composer {
- display: grid;
- grid-template-columns: auto minmax(0, 1fr) auto;
- align-items: end;
- gap: 10px;
- padding: 10px;
- border: 1px solid rgba(29, 33, 41, 0.08);
- border-radius: 8px;
- background: rgba(255, 255, 255, 0.92);
- box-shadow: 0 16px 36px rgba(15, 23, 42, 0.1);
- :deep(.arco-textarea-wrapper) {
- border: 0;
- background: transparent;
- box-shadow: none;
- }
- :deep(textarea) {
- font-size: 14px;
- line-height: 1.65;
- }
- &__tool {
- background: #f2f3f5;
- }
- &__send {
- background: linear-gradient(135deg, #2563eb, #0f766e);
- border: 0;
- }
- }
- @media (max-width: 768px) {
- .ai-chat-shell {
- grid-template-columns: 1fr;
- height: calc(100vh - 64px);
- margin: -12px -8px -16px;
- }
- .conversation-panel {
- position: absolute;
- top: 0;
- bottom: 0;
- left: 0;
- z-index: 10;
- width: min(86vw, 310px);
- transform: translateX(-100%);
- transition: transform 0.22s ease;
- box-shadow: 12px 0 32px rgba(15, 23, 42, 0.16);
- &--open {
- transform: translateX(0);
- }
- }
- .chat-toolbar {
- min-height: 62px;
- padding: 12px;
- }
- .message-list {
- padding: 16px 12px 12px;
- }
- .message-stack {
- max-width: calc(100% - 52px);
- }
- .message-bubble {
- padding: 11px 12px;
- }
- .image-preview-bar {
- padding: 8px 12px 0;
- }
- .composer-wrap {
- padding: 10px 12px max(12px, env(safe-area-inset-bottom));
- }
- .composer {
- gap: 8px;
- padding: 8px;
- }
- }
- </style>
|