Browse Source

✨ feat: 新增ai对话功能

Pchen. 20 hours ago
parent
commit
182f73ba98

+ 66 - 0
src/api/aiChat.js

@@ -0,0 +1,66 @@
+import request from '../utils/request'
+
+const api = {
+  ConversationList: '/AIChat/Conversation/List',
+  Conversation: '/AIChat/Conversation',
+  MessageList: '/AIChat/Message/List',
+  Message: '/AIChat/Message',
+  AdminConversationList: '/Admin/AIChat/Conversation/List',
+  AdminConversationDetail: '/Admin/AIChat/Conversation/Detail'
+}
+
+export function listAIChatConversations(parameter) {
+  return request({
+    url: api.ConversationList,
+    method: 'get',
+    params: parameter
+  })
+}
+
+export function createAIChatConversation(parameter) {
+  return request({
+    url: api.Conversation,
+    method: 'post',
+    data: parameter
+  })
+}
+
+export function deleteAIChatConversation(parameter) {
+  return request({
+    url: api.Conversation,
+    method: 'delete',
+    data: parameter
+  })
+}
+
+export function listAIChatMessages(parameter) {
+  return request({
+    url: api.MessageList,
+    method: 'get',
+    params: parameter
+  })
+}
+
+export function sendAIChatMessage(parameter) {
+  return request({
+    url: api.Message,
+    method: 'post',
+    data: parameter
+  })
+}
+
+export function adminListAIChatConversations(parameter) {
+  return request({
+    url: api.AdminConversationList,
+    method: 'get',
+    params: parameter
+  })
+}
+
+export function adminGetAIChatConversationDetail(parameter) {
+  return request({
+    url: api.AdminConversationDetail,
+    method: 'get',
+    params: parameter
+  })
+}

+ 894 - 310
src/components/AIChat/index.vue

@@ -1,381 +1,965 @@
 <template>
-    <a-drawer :visible="props.visible" class="drawer" width="570px" @cancel="closeAI" placement="left" :footer="false">
-        <template #title>
-            <div class="aiTitle">
-                <img alt="AI" src="@/assets/ai.svg" height="25">
-                <span>AI助手</span>
-                <a-button size="small" class="button" @click="deleteMessages">清空记录</a-button>
+  <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>
-        </template>
-        <div class="container">
-            <div class="messages" ref="messagesContainer">
-                <a-skeleton animation :loading="messagesLoading" class="messages-skeleton">
-                    <div class="messages-skeleton__list">
-                        <div v-for="n in 3" :key="n" class="messages-skeleton__item" :class="{ 'messages-skeleton__item--right': n === 2 }">
-                            <a-skeleton-shape shape="circle" />
-                            <a-skeleton-line :widths="['180px', '220px']" :rows="2" />
-                        </div>
-                    </div>
-                    <template #content>
-                        <div v-for="(item, index) in messages" :key="index">
-                        <div class="time"
-                            v-if="!messages[index - 1] || (messages[index].time - messages[index - 1].time) > 300000">{{
-                                stramptoTime(item.time) }}</div>
-                        <div :class="['message', item.type === 'user' ? 'right' : 'left']">
-                            <a-avatar class="avatar">
-                                <img alt="avatar" src="@/assets/img/avatar/assistant.png" v-if="item.type !== 'user'" />
-                                <img alt="avatar" :src="user.avatar" v-else-if="item.type === 'user'" />
-                            </a-avatar>
-                            <a-dropdown trigger="contextMenu" alignPoint :style="{ display: 'block', zIndex: 99 }">
-                                <div :class="['content', item.type === 'user' ? 'user' : 'ai']"
-                                    v-html="renderMarkdown(item.content)">
-                                </div>
-                                <template #content>
-                                    <a-doption>
-                                        <icon-play-arrow /> 打开仓库
-                                    </a-doption>
-                                </template>
-                            </a-dropdown>
-                        </div>
-                        </div>
-                    </template>
-                </a-skeleton>
+          </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 class="inputBox">
-                <a-textarea placeholder="您的专属AI助手~" :max-length="300" :auto-size="{
-                    minRows: 1,
-                    maxRows: 6
-                }" allow-clear v-model="input" />
-                <a-button type="primary" :loading="send" @click="sendMessage">发送</a-button>
+            <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>
-    </a-drawer>
+
+        <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 { ref, onMounted, nextTick, watch, onUnmounted } from 'vue'
-import { Message, Modal } from '@arco-design/web-vue'
-import { eventBus } from '@/utils/eventBus'
-import { AIChat, GetAIChatMessages, GetAIChatMessage, DeleteAIChatMessages } from '@/api/ai'
-import { useUserStore } from '@/store/modules/user'
+import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
+import { Message, Notification } from '@arco-design/web-vue'
 import MarkdownIt from 'markdown-it'
-
-const md = new MarkdownIt()
-
-const renderMarkdown = (text) => {
-    return md.render(text || '')
-}
+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
-    }
+  visible: {
+    type: Boolean,
+    default: false
+  }
 })
+const emit = defineEmits(['close', 'unread-change'])
 
-const user = ref('')
+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 send = ref(false)
-const messages = ref([])
-const msgid = ref('')
-const newIndex = ref()
+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 messagesContainer = ref(null)
+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 = () => {
-    if (messagesContainer.value) {
-        // 等 DOM 渲染完成再滚动
-        nextTick(() => {
-            messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight
-        })
-    }
+  nextTick(() => {
+    if (messageContainer.value) messageContainer.value.scrollTop = messageContainer.value.scrollHeight
+  })
 }
 
-const getAIChatMessages = async () => {
-    try {
-        messagesLoading.value = true
-        const res = await GetAIChatMessages()
-        if (!res || res.code !== 0)
-            return Message.error(`获取历史对话消息失败!${res?.msg ?? ''}`)
-        messages.value = res.data
-        messages.value.push({
-            type: 'system', time: new Date().getTime(), content: '你好!我是你的专属AI智能助手“小吉”,你可以问我任何问题哦~~\n试着问问:\n- 我拥有哪些Git仓库?\n- 仓库GitNexus最近一次提交的信息是什么?\n- 请对比electron仓库的最后两次提交。'
-        })
-    } catch (error) {
-        Message.error(`获取历史对话消息失败!`)
-    } finally {
-        scrollToBottom()
-        messagesLoading.value = false
-    }
+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 getAIChatMessage = async () => {
-    try {
-        const res = await GetAIChatMessage({ id: msgid.value })
-        if (!res || res.code !== 0) {
-            send.value = false
-            return messages.value[newIndex.value].content = '获取对话消息失败,请稍后再试'
-        }
-
-        if (!res.data)
-            return
-
-        stopPolling()
-        messages.value[newIndex.value].content = ''
-        for (let i = 0; i < res.data.length; i++) {
-            await new Promise(resolve => setTimeout(resolve, 30))
-            messages.value[newIndex.value].content += res.data[i]
-            scrollToBottom()
-        }
-        send.value = false
-    } catch (error) {
-        Message.error(`获取对话消息失败!`)
-    } finally {
-        scrollToBottom()
-    }
+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
+  }
 }
 
-let timer = null
+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 startPolling = () => {
-    if (!timer) {
-        timer = setInterval(async () => {
-            await getAIChatMessage()
-        }, 1000)
-    }
+const openConversation = async (id) => {
+  currentConversationId.value = id
+  showConversationPanel.value = false
+  await loadMessages()
 }
 
-// 停止轮询
-const stopPolling = () => {
-    if (timer) {
-        clearInterval(timer)
-        timer = null
+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 })
     }
-    send.value = false
+  } 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 (input.value === '') return
-    send.value = true
-    let content = input.value
-    input.value = ''
-    try {
-        messages.value.push({ type: 'user', time: new Date().getTime(), content })
-        newIndex.value = messages.value.push({ type: 'ai', time: new Date().getTime(), content: '小吉正在思考哦~请稍候...' }) - 1
-        scrollToBottom()
-        const res = await AIChat({ message: content })
-        if (!res || res.code !== 0) {
-            send.value = false
-            return messages.value[newIndex.value].content = '获取对话消息失败,请稍后再试'
-        }
-
-        msgid.value = res.id
-        startPolling()
-    } catch (error) {
-        console.log(error)
-        Message.error('获取对话消息失败!请稍后再试')
+  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 closeAI = () => {
-    eventBus.emit('closeAI')
-}
+const close = () => emit('close')
+const syncMobile = () => { isMobile.value = window.innerWidth <= 768 }
 
-const getuser = async () => {
-    const userStore = useUserStore()
-    let userInfo = await userStore.getInfo()
-    user.value = userInfo
+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 () => {
-    getuser()
-    await getAIChatMessages()
+  await userStore.getInfo()
+  window.addEventListener('resize', syncMobile)
+  await loadConversations()
+  startPolling()
 })
 
-// 组件销毁时停止轮询
 onUnmounted(() => {
-    stopPolling()
+  window.removeEventListener('resize', syncMobile)
+  stopPolling()
 })
+</script>
 
-watch(
-    () => props.visible,
-    (val) => {
-        if (val) {
-            nextTick(() => {
-                scrollToBottom()
-            })
-        }
-    }
-)
+<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;
+  }
+}
 
-const stramptoTime = (time) => {
-    return new Date(time).toLocaleString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })
+.status-dot {
+  width: 7px;
+  height: 7px;
+  border-radius: 50%;
+  background: #16b364;
+  box-shadow: 0 0 0 4px rgba(22, 179, 100, 0.12);
 }
 
-const deleteMessages = () => {
-    Modal.confirm({
-        title: '清空消息记录',
-        content: '您即将清空所有消息记录,是否继续?',
-        onOk: async () => {
-            const res = await DeleteAIChatMessages()
-            if (!res || res.code !== 0)
-                return Message.error('清空消息记录失败!')
-            getAIChatMessages()
-            Message.success('清空消息记录成功!')
-        },
-        onCancel: () => {
+.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);
 }
 
-</script>
+.conversation-panel__head {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  padding: 18px 16px 12px;
+}
 
-<style lang="less" scoped>
-.container {
+.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;
-    flex-direction: column;
-    height: calc(100% + 20px);
+    align-items: center;
+    justify-content: space-between;
     gap: 10px;
-    margin-top: -20px;
+    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;
+  }
+}
 
-    /* 对于 Webkit 浏览器(Chrome、Safari) */
-    .messages::-webkit-scrollbar {
-        width: 8px;
+.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;
     }
 
-    .messages::-webkit-scrollbar-track {
-        background: transparent;
+    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;
     }
 
-    .messages::-webkit-scrollbar-thumb {
-        background-color: rgba(0, 0, 0, 0.2);
-        /* 滚动条滑块背景 */
-        border-radius: 10px;
-        /* 滚动条滑块圆角 */
+    .message-meta {
+      flex-direction: row-reverse;
     }
 
-    .messages::-webkit-scrollbar-thumb:hover {
-        background-color: rgba(0, 0, 0, 0.3);
-        /* 滚动条滑块悬停时的背景 */
+    .message-bubble {
+      color: #ffffff;
+      background: linear-gradient(135deg, #2563eb, #0f766e);
+      box-shadow: 0 12px 28px rgba(37, 99, 235, 0.2);
     }
 
-    .messages {
-        flex: 1;
-        overflow-y: auto;
-        padding: 10px;
-        display: flex;
-        flex-direction: column;
-
-        .messages-skeleton {
-            width: 100%;
-
-            &__list {
-                display: flex;
-                flex-direction: column;
-                gap: 16px;
-                padding: 8px 0;
-            }
-
-            &__item {
-                display: flex;
-                align-items: flex-start;
-                gap: 10px;
-
-                &--right {
-                    flex-direction: row-reverse;
-                }
-            }
-        }
-
-        .time {
-            color: #888;
-            font-size: 0.9em;
-            text-align: center;
-            margin: 10px 0 0;
-        }
-
-        .message {
-            display: flex;
-            margin-top: 10px;
-
-            &.right {
-                flex-direction: row-reverse;
-                justify-content: end;
-            }
-
-            .avatar {
-                user-select: none;
-                width: 36px;
-                height: 36px;
-                background-color: #fff;
-            }
-
-
-            /* 对于 Webkit 浏览器(Chrome、Safari) */
-            .content::-webkit-scrollbar {
-                height: 6px;
-            }
-
-            .content::-webkit-scrollbar-track {
-                background: transparent;
-            }
-
-            .content::-webkit-scrollbar-thumb {
-                background-color: rgba(0, 0, 0, 0.2);
-                /* 滚动条滑块背景 */
-                border-radius: 10px;
-                /* 滚动条滑块圆角 */
-            }
-
-            .content::-webkit-scrollbar-thumb:hover {
-                background-color: rgba(0, 0, 0, 0.3);
-                /* 滚动条滑块悬停时的背景 */
-            }
-
-            .content {
-                max-width: 70%;
-                padding: 0 14px;
-                border-radius: 10px;
-                margin: 0 10px;
-                font-size: 14px;
-                line-height: 1.5;
-                word-break: break-word;
-                overflow-x: auto;
-
-                &.user {
-                    background-color: #4e88ff;
-                    color: white;
-                }
-
-                &.ai {
-                    background-color: #eee;
-                    color: #333;
-                }
-            }
-        }
+    .message-content :deep(a) {
+      color: #ffffff;
+    }
+  }
 
+  &--system {
+    .message-avatar {
+      background: #fff7e8;
+      color: #d46b08;
     }
 
-    .inputBox {
-        display: flex;
-        gap: 10px;
-        min-height: 15px;
+    .message-bubble {
+      background: #fffaf0;
+      border-color: rgba(245, 158, 11, 0.22);
     }
+  }
 }
 
-.aiTitle {
-    display: flex;
-    font-size: 1.1em;
-    gap: 10px;
-    width: 100%;
+.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;
+}
 
-    .button {
-        position: absolute;
-        left: 75%;
+.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>
+</style>

+ 25 - 11
src/layout/default-layout.vue

@@ -44,11 +44,13 @@
       />
     </a-layout>
 
-    <!-- <a-avatar class="aiButton" @click="chat = !chat">
-      <img alt="avatar" src="@/assets/img/ai.png" />
-    </a-avatar>
+    <a-badge v-if="aiAvailable" :count="aiUnread" :dot="aiUnread > 0" class="aiButtonBadge">
+      <a-avatar class="aiButton" @click="chat = !chat">
+        <img alt="AI助手" src="@/assets/img/ai.png" />
+      </a-avatar>
+    </a-badge>
 
-    <AIChat :visible="chat"/> -->
+    <AIChat v-if="aiAvailable" :visible="chat" @close="chat = false" @unread-change="aiUnread = $event" />
 
     <a-modal
       v-model:visible="popupVisible"
@@ -64,22 +66,26 @@
 </template>
 
 <script setup>
-import { onMounted, onBeforeUnmount, ref, onUnmounted } from 'vue'
+import { computed, onMounted, onBeforeUnmount, ref, onUnmounted } from 'vue'
 import { eventBus } from '@/utils/eventBus'
 import { Notification } from '@arco-design/web-vue'
 import { getUnreadPopup, markPopupRead } from '@/api/public'
+import { useUserStore } from '@/store'
 
 import PageLayout from './page-layout.vue'
 import Menu from '@/components/Menu/index.vue'
 import Navbar from '@/components/Navbar/index.vue'
 import Footer from '@/components/Footer/index.vue'
-// import AIChat from '@/components/AIChat/index.vue'
+import AIChat from '@/components/AIChat/index.vue'
 
 const chat = ref(false)
+const aiUnread = ref(0)
 const popupVisible = ref(false)
 const popupData = ref({})
 const siderCollapsed = ref(false)
 const isMobile = ref(false)
+const userStore = useUserStore()
+const aiAvailable = computed(() => !!(userStore.uuid && userStore.session))
 
 // 按钮拖动
 let isDragging = false
@@ -115,7 +121,7 @@ function handleMouseUp() {
 }
 
 onMounted(() => {
-  button = document.querySelector('.aiButton')
+  button = document.querySelector('.aiButtonBadge')
   if (button) {
     button.addEventListener('mousedown', handleMouseDown)
   }
@@ -319,10 +325,6 @@ const toggleSider = () => {
 }
 
 .aiButton {
-  position: fixed;
-  bottom: 100px;
-  right: 60px;
-  z-index: 200;
   width: 55px;
   height: 55px;
   background-color: #fff;
@@ -334,6 +336,13 @@ const toggleSider = () => {
   }
 }
 
+.aiButtonBadge {
+  position: fixed;
+  bottom: 100px;
+  right: 60px;
+  z-index: 200;
+}
+
 .popup-html {
   margin-top: 0;
   padding-right: 4px;
@@ -446,5 +455,10 @@ const toggleSider = () => {
   .layout-body--menu-open .layout-content {
     transform: translateX(200px);
   }
+
+  .aiButtonBadge {
+    right: 18px;
+    bottom: 24px;
+  }
 }
 </style>

+ 130 - 0
src/pages/admin/aiChat/detail.vue

@@ -0,0 +1,130 @@
+<template>
+  <div class="store-page">
+    <Breadcrumb />
+    <a-card title="AI 对话详情" :loading="loading">
+      <a-descriptions :column="{ xs: 1, sm: 2 }" bordered>
+        <a-descriptions-item label="会话编号">{{ data.conversation_no || '-' }}</a-descriptions-item>
+        <a-descriptions-item label="内部ID">{{ data.id || '-' }}</a-descriptions-item>
+        <a-descriptions-item label="标题">{{ data.title || '-' }}</a-descriptions-item>
+        <a-descriptions-item label="用户">{{ data.username || '-' }}</a-descriptions-item>
+        <a-descriptions-item label="用户UUID">{{ data.create_user || '-' }}</a-descriptions-item>
+        <a-descriptions-item label="状态">{{ data.state === 1 ? '正常' : '已删除' }}</a-descriptions-item>
+        <a-descriptions-item label="更新时间">{{ formatTime(data.update_time) }}</a-descriptions-item>
+      </a-descriptions>
+    </a-card>
+
+    <a-card title="消息记录" class="message-card">
+      <div v-if="!data.messages?.length" class="empty">暂无消息</div>
+      <div v-for="message in data.messages" :key="message.id" class="message">
+        <div class="message__head">
+          <a-avatar :size="34">
+            <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>
+            <div class="message__name">
+              {{ roleLabel(message.role) }}
+              <a-tag v-if="message.status === 'error'" color="red" size="small">异常</a-tag>
+            </div>
+            <div class="message__time">{{ formatTime(message.create_time) }}</div>
+          </div>
+        </div>
+        <div v-if="message.content" class="message__content">{{ 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="120" height="120" fit="cover" />
+        </div>
+        <div v-if="message.error_msg" class="message__error">{{ message.error_msg }}</div>
+      </div>
+    </a-card>
+  </div>
+</template>
+
+<script setup>
+import { onMounted, ref } from 'vue'
+import { useRoute } from 'vue-router'
+import { Notification } from '@arco-design/web-vue'
+import { adminGetAIChatConversationDetail } from '@/api/aiChat'
+
+const route = useRoute()
+const loading = ref(false)
+const data = ref({})
+const roleLabel = (role) => ({ user: '用户', assistant: '小妍助理', system: '系统' }[role] || role)
+const formatTime = (time) => time ? new Date(Number(time)).toLocaleString('zh-CN', { hour12: false }) : '-'
+
+const fetchDetail = async () => {
+  loading.value = true
+  try {
+    const res = await adminGetAIChatConversationDetail({ id: route.params.id })
+    if (!res || res.code !== 0) return Notification.error({ title: '获取 AI 对话详情失败', content: res?.msg || '请稍后再试' })
+    data.value = res.data || {}
+  } finally {
+    loading.value = false
+  }
+}
+
+onMounted(fetchDetail)
+</script>
+
+<style scoped lang="less">
+@import '@/styles/store-theme.less';
+
+.message-card {
+  margin-top: 15px;
+}
+
+.empty {
+  padding: 24px;
+  text-align: center;
+  color: @store-text-muted;
+}
+
+.message {
+  padding: 14px 0;
+  border-bottom: 1px solid var(--color-border-2);
+
+  &:last-child {
+    border-bottom: 0;
+  }
+
+  &__head {
+    display: flex;
+    align-items: flex-start;
+    gap: 10px;
+  }
+
+  &__name {
+    display: flex;
+    align-items: center;
+    gap: 8px;
+    font-weight: 650;
+    color: @store-primary;
+  }
+
+  &__time {
+    margin-top: 2px;
+    font-size: 12px;
+    color: @store-text-muted;
+  }
+
+  &__content {
+    margin: 10px 0 0 44px;
+    max-width: 900px;
+    white-space: pre-wrap;
+    word-break: break-word;
+    line-height: 1.7;
+  }
+
+  &__images {
+    display: flex;
+    flex-wrap: wrap;
+    gap: 8px;
+    margin: 10px 0 0 44px;
+  }
+
+  &__error {
+    margin: 8px 0 0 44px;
+    color: rgb(var(--red-6));
+  }
+}
+</style>

+ 195 - 0
src/pages/admin/aiChat/list.vue

@@ -0,0 +1,195 @@
+<template>
+  <div class="store-page">
+    <Breadcrumb />
+    <a-card title="AI 对话管理">
+      <AppQueryFilter>
+        <a-row class="query-row">
+          <a-col :flex="'1000px'" class="query-main">
+            <a-form class="app-query-form" :model="queryData" :label-col-props="{ span: 6 }" :wrapper-col-props="{ span: 18 }" label-align="left">
+              <a-row :gutter="16">
+                <a-col :span="6">
+                  <a-form-item field="conversation_no" label="会话编号">
+                    <a-input v-model="queryData.conversation_no" allow-clear />
+                  </a-form-item>
+                </a-col>
+                <a-col :span="6">
+                  <a-form-item field="id" label="内部ID">
+                    <a-input-number v-model="queryData.id" :precision="0" allow-clear />
+                  </a-form-item>
+                </a-col>
+                <a-col :span="6">
+                  <a-form-item field="username" label="用户名">
+                    <a-input v-model="queryData.username" allow-clear />
+                  </a-form-item>
+                </a-col>
+                <a-col :span="6">
+                  <a-form-item field="create_user" label="用户UUID">
+                    <a-input v-model="queryData.create_user" allow-clear />
+                  </a-form-item>
+                </a-col>
+                <a-col :span="6">
+                  <a-form-item field="state" label="状态">
+                    <a-select v-model="queryData.state">
+                      <a-option :value="-1">全部</a-option>
+                      <a-option :value="1">正常</a-option>
+                      <a-option :value="2">已删除</a-option>
+                    </a-select>
+                  </a-form-item>
+                </a-col>
+                <a-col :span="8">
+                  <a-form-item field="queryTime" label="更新时间">
+                    <a-range-picker v-model="queryData.queryTime" show-time value-format="x" popup-container="body" />
+                  </a-form-item>
+                </a-col>
+              </a-row>
+            </a-form>
+          </a-col>
+          <a-divider class="query-divider" style="height: 84px" direction="vertical" />
+          <a-col :flex="1" class="app-query-actions">
+            <a-space class="query-actions-space" direction="vertical" :size="18">
+              <a-button type="primary" @click="search">
+                <template #icon><icon-search /></template>
+                搜索
+              </a-button>
+              <a-button @click="reset">
+                <template #icon><icon-refresh /></template>
+                重置
+              </a-button>
+            </a-space>
+          </a-col>
+        </a-row>
+      </AppQueryFilter>
+
+      <a-table
+        class="table"
+        :bordered="false"
+        :data="data"
+        :loading="loading"
+        :columns="columns"
+        :scroll="{ x: 1240 }"
+        :pagination="{
+          showPageSize: true,
+          showJumper: true,
+          showTotal: true,
+          pageSize: pagination.pagesize,
+          current: pagination.current,
+          total: pagination.total
+        }"
+        @page-change="handlePageChange"
+        @page-size-change="handlePageSizeChange"
+      >
+        <template #state="{ record }">
+          <a-tag :color="record.state === 1 ? 'green' : 'gray'">{{ record.state === 1 ? '正常' : '已删除' }}</a-tag>
+        </template>
+        <template #last_message_time="{ record }">
+          {{ formatTime(record.last_message_time) }}
+        </template>
+        <template #update_time="{ record }">
+          {{ formatTime(record.update_time) }}
+        </template>
+        <template #optional="{ record }">
+          <a-button @click="$router.push(`/admin/aiChat/detail/${record.id}`)">查看详情</a-button>
+        </template>
+      </a-table>
+    </a-card>
+  </div>
+</template>
+
+<script setup>
+import { reactive, ref, onMounted } from 'vue'
+import { Notification } from '@arco-design/web-vue'
+import { adminListAIChatConversations } from '@/api/aiChat'
+import { useResponsiveTable } from '@/hooks/useResponsiveTable'
+
+const pagination = reactive({ current: 1, pagesize: 20, total: 0 })
+const queryData = reactive({ id: '', conversation_no: '', username: '', create_user: '', state: -1, queryTime: [] })
+const data = ref([])
+const loading = ref(false)
+
+const columnDefs = [
+  { title: '会话编号', dataIndex: 'conversation_no', width: 150 },
+  { title: '内部ID', dataIndex: 'id', width: 100 },
+  { title: '标题', dataIndex: 'title', width: 180, ellipsis: true, tooltip: true },
+  { title: '用户', dataIndex: 'username', width: 140, ellipsis: true, tooltip: true },
+  { title: '用户UUID', dataIndex: 'create_user', width: 240, ellipsis: true, tooltip: true },
+  { title: '最后消息', dataIndex: 'last_message_preview', width: 260, ellipsis: true, tooltip: true },
+  { title: '最后消息时间', slotName: 'last_message_time', width: 170 },
+  { title: '更新时间', slotName: 'update_time', width: 170 },
+  { title: '状态', slotName: 'state', width: 100 },
+  { title: '操作', slotName: 'optional', width: 120, fixed: 'right' }
+]
+const { useTableColumns } = useResponsiveTable()
+const columns = useTableColumns(columnDefs)
+
+const formatTime = (time) => time ? new Date(Number(time)).toLocaleString('zh-CN', { hour12: false }) : '-'
+
+const fetchList = async () => {
+  loading.value = true
+  try {
+    const res = await adminListAIChatConversations({
+      ...queryData,
+      current: pagination.current,
+      pagesize: pagination.pagesize
+    })
+    if (!res || res.code !== 0) return Notification.error({ title: '获取 AI 对话失败', content: res?.msg || '请稍后再试' })
+    data.value = res.data || []
+    pagination.total = res.pagination?.total || 0
+  } finally {
+    loading.value = false
+  }
+}
+
+const search = () => {
+  pagination.current = 1
+  fetchList()
+}
+
+const reset = () => {
+  queryData.id = ''
+  queryData.conversation_no = ''
+  queryData.username = ''
+  queryData.create_user = ''
+  queryData.state = -1
+  queryData.queryTime = []
+  search()
+}
+
+const handlePageChange = (page) => {
+  pagination.current = page
+  fetchList()
+}
+
+const handlePageSizeChange = (size) => {
+  pagination.pagesize = size
+  pagination.current = 1
+  fetchList()
+}
+
+onMounted(fetchList)
+</script>
+
+<style scoped>
+.query-row {
+  margin-bottom: 10px;
+}
+
+.table {
+  margin-top: 15px;
+}
+
+@media (max-width: 768px) {
+  .query-row {
+    display: block;
+  }
+
+  .query-divider {
+    display: none;
+  }
+
+  .query-actions-space {
+    margin-top: 4px;
+    flex-direction: row !important;
+    gap: 10px !important;
+  }
+}
+</style>

+ 23 - 0
src/router/index.js

@@ -360,6 +360,7 @@ const routes = [
                 'page.admin.userList',
                 'page.admin.lepaoAccount',
                 'page.admin.service.orderList',
+                'page.admin.aiChat',
                 'page.admin.goods.sendCountRequestList'
             ]
         },
@@ -460,6 +461,28 @@ const routes = [
                     permission: ['admin', 'service']
                 }
             },
+            {
+                path: 'aiChat',
+                name: 'admin.aiChat.list',
+                component: () => import('../pages/admin/aiChat/list.vue'),
+                meta: {
+                    title: 'AI对话管理',
+                    permission: ['admin', 'service'],
+                    permissionCode: 'page.admin.aiChat'
+                }
+            },
+            {
+                path: 'aiChat/detail/:id',
+                name: 'admin.aiChat.detail',
+                component: () => import('../pages/admin/aiChat/detail.vue'),
+                meta: {
+                    title: 'AI对话详情',
+                    hideInMenu: true,
+                    parent: 'admin.aiChat.list',
+                    permission: ['admin', 'service'],
+                    permissionCode: 'page.admin.aiChat'
+                }
+            },
             {
                 path: 'mqQueue',
                 name: 'admin.mq.queue',