mirror of
https://github.com/fastapiadmin/FastapiAdmin.git
synced 2026-09-23 13:13:09 +00:00
feat: 实现智能助手WebSocket聊天界面
This commit is contained in:
@@ -1,13 +1,850 @@
|
||||
<template>
|
||||
<dev>
|
||||
开发中...
|
||||
</dev>
|
||||
<div class="chatgpt-container">
|
||||
<!-- 主聊天区域 -->
|
||||
<div class="main-chat">
|
||||
<!-- 顶部导航栏 -->
|
||||
<div class="chat-navbar">
|
||||
<div class="navbar-left">
|
||||
<h2>FVA智能助手</h2>
|
||||
</div>
|
||||
<div class="navbar-right">
|
||||
<div class="connection-status">
|
||||
<el-icon :class="['status-icon', connectionStatus]">
|
||||
<Connection v-if="connectionStatus === 'connected'" />
|
||||
<Loading v-else-if="connectionStatus === 'connecting'" />
|
||||
<Warning v-else />
|
||||
</el-icon>
|
||||
<span class="status-text">{{ connectionStatusText }}</span>
|
||||
</div>
|
||||
<el-button
|
||||
text
|
||||
@click="clearCurrentChat"
|
||||
v-if="messages.length > 0"
|
||||
>
|
||||
<el-icon><Delete /></el-icon>
|
||||
清空对话
|
||||
</el-button>
|
||||
<el-button
|
||||
text
|
||||
@click="toggleConnection"
|
||||
>
|
||||
<el-icon><Setting /></el-icon>
|
||||
{{ isConnected ? '断开连接' : '重新连接' }}
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 聊天消息区域 -->
|
||||
<div class="chat-messages" ref="messagesContainer">
|
||||
<!-- 欢迎界面 -->
|
||||
<div v-if="messages.length === 0" class="welcome-screen">
|
||||
<div class="welcome-content">
|
||||
<div class="ai-logo">
|
||||
<el-icon size="64"><ChatDotRound /></el-icon>
|
||||
</div>
|
||||
<h1>FVA智能助手</h1>
|
||||
<p class="welcome-subtitle">我是您的专属AI助手,可以帮您回答问题、处理任务和进行智能对话</p>
|
||||
|
||||
<div class="example-prompts">
|
||||
<div class="prompt-card" @click="setPrompt('请介绍一下FastAPI Vue3 Admin系统')">
|
||||
<h4>系统介绍</h4>
|
||||
<p>请介绍一下FastAPI Vue3 Admin系统</p>
|
||||
</div>
|
||||
<div class="prompt-card" @click="setPrompt('如何在FVA系统中创建新的模块?')">
|
||||
<h4>开发指导</h4>
|
||||
<p>如何在FVA系统中创建新的模块?</p>
|
||||
</div>
|
||||
<div class="prompt-card" @click="setPrompt('FVA系统的权限管理是如何工作的?')">
|
||||
<h4>权限管理</h4>
|
||||
<p>FVA系统的权限管理是如何工作的?</p>
|
||||
</div>
|
||||
<div class="prompt-card" @click="setPrompt('如何优化FVA系统的性能?')">
|
||||
<h4>性能优化</h4>
|
||||
<p>如何优化FVA系统的性能?</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 消息列表 -->
|
||||
<div v-else class="messages-list">
|
||||
<div
|
||||
v-for="message in messages"
|
||||
:key="message.id"
|
||||
:class="['message-group', message.type]"
|
||||
>
|
||||
<div class="message-avatar">
|
||||
<div v-if="message.type === 'user'" class="user-avatar">
|
||||
<el-icon><User /></el-icon>
|
||||
</div>
|
||||
<div v-else class="ai-avatar">
|
||||
<el-icon><ChatDotRound /></el-icon>
|
||||
</div>
|
||||
</div>
|
||||
<div class="message-content">
|
||||
<div class="message-header">
|
||||
<strong class="sender-name">
|
||||
{{ message.type === 'user' ? 'You' : 'FVA助手' }}
|
||||
</strong>
|
||||
</div>
|
||||
<div class="message-body">
|
||||
<div v-if="message.type === 'assistant' && message.loading" class="typing-indicator">
|
||||
<div class="typing-dots">
|
||||
<span></span>
|
||||
<span></span>
|
||||
<span></span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="message-text" v-html="formatMessage(message.content)"></div>
|
||||
</div>
|
||||
<div class="message-actions" v-if="!message.loading">
|
||||
<el-button text size="small" @click="copyMessage(message.content)">
|
||||
<el-icon><CopyDocument /></el-icon>
|
||||
</el-button>
|
||||
<el-button text size="small" v-if="message.type === 'assistant'">
|
||||
<el-icon><RefreshLeft /></el-icon>
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 错误提示 -->
|
||||
<div v-if="error" class="error-banner">
|
||||
<el-alert
|
||||
:title="error"
|
||||
type="error"
|
||||
:closable="true"
|
||||
@close="error = ''"
|
||||
show-icon
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 输入区域 -->
|
||||
<div class="chat-input">
|
||||
<div class="input-wrapper">
|
||||
<div class="input-container">
|
||||
<el-input
|
||||
v-model="inputMessage"
|
||||
:placeholder="isConnected ? '向FVA助手发送消息...' : '请先连接到服务器'"
|
||||
:disabled="!isConnected || sending"
|
||||
type="textarea"
|
||||
:rows="1"
|
||||
:autosize="{ minRows: 1, maxRows: 6 }"
|
||||
@keydown.enter.exact.prevent="sendMessage"
|
||||
@keydown.shift.enter.exact="inputMessage += '\n'"
|
||||
class="message-input"
|
||||
resize="none"
|
||||
/>
|
||||
<el-button
|
||||
:disabled="!inputMessage.trim() || !isConnected || sending"
|
||||
:loading="sending"
|
||||
@click="sendMessage"
|
||||
class="send-button"
|
||||
type="primary"
|
||||
circle
|
||||
>
|
||||
<el-icon><Promotion /></el-icon>
|
||||
</el-button>
|
||||
</div>
|
||||
<div class="input-footer">
|
||||
<span class="input-hint">
|
||||
按 Enter 发送消息,Shift + Enter 换行
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted, onUnmounted, nextTick, computed } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import {
|
||||
ChatDotRound,
|
||||
User,
|
||||
Delete,
|
||||
Promotion,
|
||||
Connection,
|
||||
Loading,
|
||||
Warning,
|
||||
Setting,
|
||||
CopyDocument,
|
||||
RefreshLeft
|
||||
} from '@element-plus/icons-vue'
|
||||
|
||||
// 消息接口
|
||||
interface ChatMessage {
|
||||
id: string
|
||||
type: 'user' | 'assistant'
|
||||
content: string
|
||||
timestamp: number
|
||||
loading?: boolean
|
||||
}
|
||||
|
||||
// 响应式数据
|
||||
const messages = ref<ChatMessage[]>([])
|
||||
const inputMessage = ref('')
|
||||
const sending = ref(false)
|
||||
const isConnected = ref(false)
|
||||
const connectionStatus = ref<'connected' | 'connecting' | 'disconnected'>('disconnected')
|
||||
const error = ref('')
|
||||
const messagesContainer = ref<HTMLElement>()
|
||||
|
||||
// WebSocket 连接
|
||||
let ws: WebSocket | null = null
|
||||
const WS_URL = 'ws://service.fastapiadmin.com/api/v1/ai/mcp/ws/chat'
|
||||
|
||||
// 计算属性
|
||||
const connectionStatusText = computed(() => {
|
||||
switch (connectionStatus.value) {
|
||||
case 'connected': return '已连接'
|
||||
case 'connecting': return '连接中...'
|
||||
case 'disconnected': return '未连接'
|
||||
default: return '未知状态'
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
// WebSocket 连接管理
|
||||
const connectWebSocket = () => {
|
||||
if (ws?.readyState === WebSocket.OPEN) {
|
||||
return
|
||||
}
|
||||
|
||||
connectionStatus.value = 'connecting'
|
||||
error.value = ''
|
||||
|
||||
try {
|
||||
ws = new WebSocket(WS_URL)
|
||||
|
||||
ws.onopen = () => {
|
||||
console.log('WebSocket 连接已建立')
|
||||
isConnected.value = true
|
||||
connectionStatus.value = 'connected'
|
||||
ElMessage.success('连接成功')
|
||||
}
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data)
|
||||
handleWebSocketMessage(data)
|
||||
} catch (err) {
|
||||
console.error('解析消息失败:', err)
|
||||
handleWebSocketMessage({ content: event.data })
|
||||
}
|
||||
}
|
||||
|
||||
ws.onclose = (event) => {
|
||||
console.log('WebSocket 连接已关闭', event.code, event.reason)
|
||||
isConnected.value = false
|
||||
connectionStatus.value = 'disconnected'
|
||||
|
||||
if (event.code !== 1000) {
|
||||
error.value = '连接意外断开,请检查网络连接'
|
||||
ElMessage.error('连接断开')
|
||||
}
|
||||
}
|
||||
|
||||
ws.onerror = (error) => {
|
||||
console.error('WebSocket 错误:', error)
|
||||
isConnected.value = false
|
||||
connectionStatus.value = 'disconnected'
|
||||
ElMessage.error('连接失败,请检查服务器状态')
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
console.error('创建 WebSocket 连接失败:', err)
|
||||
connectionStatus.value = 'disconnected'
|
||||
error.value = '无法创建连接'
|
||||
}
|
||||
}
|
||||
|
||||
// 断开连接
|
||||
const disconnectWebSocket = () => {
|
||||
if (ws) {
|
||||
ws.close(1000, '用户主动断开')
|
||||
ws = null
|
||||
}
|
||||
isConnected.value = false
|
||||
connectionStatus.value = 'disconnected'
|
||||
}
|
||||
|
||||
// 切换连接状态
|
||||
const toggleConnection = () => {
|
||||
if (isConnected.value) {
|
||||
disconnectWebSocket()
|
||||
ElMessage.info('已断开连接')
|
||||
} else {
|
||||
connectWebSocket()
|
||||
}
|
||||
}
|
||||
|
||||
// 处理 WebSocket 消息
|
||||
const handleWebSocketMessage = (data: any) => {
|
||||
// 查找最后一个助手消息
|
||||
const lastMessage = messages.value[messages.value.length - 1]
|
||||
|
||||
if (lastMessage && lastMessage.type === 'assistant' && lastMessage.loading) {
|
||||
// 更新加载中的消息
|
||||
lastMessage.content = data.content || data.message || '收到回复'
|
||||
lastMessage.loading = false
|
||||
} else {
|
||||
// 添加新的助手消息
|
||||
addMessage('assistant', data.content || data.message || '收到回复')
|
||||
}
|
||||
|
||||
scrollToBottom()
|
||||
}
|
||||
|
||||
// 发送消息
|
||||
const sendMessage = async () => {
|
||||
const message = inputMessage.value.trim()
|
||||
if (!message || !isConnected.value || sending.value) {
|
||||
return
|
||||
}
|
||||
|
||||
// 添加用户消息
|
||||
addMessage('user', message)
|
||||
inputMessage.value = ''
|
||||
|
||||
// 添加加载中的助手消息
|
||||
const loadingMessage: ChatMessage = {
|
||||
id: generateId(),
|
||||
type: 'assistant',
|
||||
content: '',
|
||||
timestamp: Date.now(),
|
||||
loading: true
|
||||
}
|
||||
messages.value.push(loadingMessage)
|
||||
|
||||
sending.value = true
|
||||
scrollToBottom()
|
||||
|
||||
try {
|
||||
// 发送消息到 WebSocket
|
||||
if (ws?.readyState === WebSocket.OPEN) {
|
||||
const payload = {
|
||||
message: message,
|
||||
timestamp: Date.now()
|
||||
}
|
||||
ws.send(JSON.stringify(payload))
|
||||
} else {
|
||||
throw new Error('WebSocket 连接未建立')
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('发送消息失败:', err)
|
||||
// 移除加载消息并显示错误
|
||||
messages.value.pop()
|
||||
error.value = '发送消息失败,请检查连接状态'
|
||||
ElMessage.error('发送失败')
|
||||
} finally {
|
||||
sending.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 添加消息
|
||||
const addMessage = (type: 'user' | 'assistant', content: string) => {
|
||||
const message: ChatMessage = {
|
||||
id: generateId(),
|
||||
type,
|
||||
content,
|
||||
timestamp: Date.now()
|
||||
}
|
||||
messages.value.push(message)
|
||||
nextTick(() => scrollToBottom())
|
||||
}
|
||||
|
||||
const clearCurrentChat = async () => {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
'确定要清空当前对话吗?此操作不可恢复。',
|
||||
'确认清空',
|
||||
{
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
}
|
||||
)
|
||||
|
||||
messages.value = []
|
||||
ElMessage.success('对话已清空')
|
||||
} catch {
|
||||
// 用户取消
|
||||
}
|
||||
}
|
||||
|
||||
// 设置提示词
|
||||
const setPrompt = (prompt: string) => {
|
||||
inputMessage.value = prompt
|
||||
}
|
||||
|
||||
// 复制消息
|
||||
const copyMessage = async (content: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(content)
|
||||
ElMessage.success('已复制到剪贴板')
|
||||
} catch {
|
||||
// 降级方案
|
||||
const textArea = document.createElement('textarea')
|
||||
textArea.value = content
|
||||
document.body.appendChild(textArea)
|
||||
textArea.select()
|
||||
document.execCommand('copy')
|
||||
document.body.removeChild(textArea)
|
||||
ElMessage.success('已复制到剪贴板')
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 滚动到底部
|
||||
const scrollToBottom = () => {
|
||||
nextTick(() => {
|
||||
if (messagesContainer.value) {
|
||||
messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 格式化时间
|
||||
const formatTime = (timestamp: number) => {
|
||||
const date = new Date(timestamp)
|
||||
const now = new Date()
|
||||
|
||||
if (date.toDateString() === now.toDateString()) {
|
||||
return date.toLocaleTimeString('zh-CN', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
} else {
|
||||
return date.toLocaleString('zh-CN', {
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 格式化消息内容
|
||||
const formatMessage = (content: string) => {
|
||||
if (!content) return ''
|
||||
|
||||
// 简单的 Markdown 支持
|
||||
return content
|
||||
.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
|
||||
.replace(/\*(.*?)\*/g, '<em>$1</em>')
|
||||
.replace(/`(.*?)`/g, '<code>$1</code>')
|
||||
.replace(/\n/g, '<br>')
|
||||
}
|
||||
|
||||
// 生成唯一ID
|
||||
const generateId = () => {
|
||||
return Date.now().toString(36) + Math.random().toString(36).substr(2)
|
||||
}
|
||||
|
||||
// 生命周期
|
||||
onMounted(() => {
|
||||
connectWebSocket()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
disconnectWebSocket()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.chatgpt-container {
|
||||
display: flex;
|
||||
height: calc(100vh - 120px);
|
||||
background: var(--el-bg-color);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
// 主聊天区域
|
||||
.main-chat {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
|
||||
.chat-navbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16px 24px;
|
||||
border-bottom: 1px solid var(--el-border-color-light);
|
||||
background: var(--el-bg-color);
|
||||
|
||||
.navbar-left {
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.navbar-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
|
||||
.connection-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
|
||||
.status-icon {
|
||||
&.connected { color: var(--el-color-success); }
|
||||
&.connecting { color: var(--el-color-warning); }
|
||||
&.disconnected { color: var(--el-color-danger); }
|
||||
}
|
||||
|
||||
.status-text {
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.chat-messages {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
background: var(--el-bg-color);
|
||||
padding-bottom: 120px; // 为底部输入框留出空间
|
||||
|
||||
// 欢迎界面
|
||||
.welcome-screen {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
padding: 32px;
|
||||
text-align: center;
|
||||
|
||||
.welcome-content {
|
||||
max-width: 800px;
|
||||
|
||||
.ai-logo {
|
||||
margin-bottom: 24px;
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 32px;
|
||||
font-weight: 600;
|
||||
margin: 0 0 16px;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.welcome-subtitle {
|
||||
font-size: 16px;
|
||||
color: var(--el-text-color-secondary);
|
||||
margin-bottom: 32px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.example-prompts {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
gap: 16px;
|
||||
max-width: 600px;
|
||||
|
||||
.prompt-card {
|
||||
background: var(--el-bg-color-page);
|
||||
border: 1px solid var(--el-border-color-light);
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
text-align: left;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--el-color-primary);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: var(--el-box-shadow-light);
|
||||
}
|
||||
|
||||
h4 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: var(--el-text-color-secondary);
|
||||
line-height: 1.4;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 消息列表
|
||||
.messages-list {
|
||||
padding: 24px;
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
|
||||
.message-group {
|
||||
margin-bottom: 32px;
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
|
||||
.message-avatar {
|
||||
flex-shrink: 0;
|
||||
|
||||
.user-avatar {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 6px;
|
||||
background: var(--el-color-primary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.ai-avatar {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 6px;
|
||||
background: var(--el-color-success);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.message-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
|
||||
.message-header {
|
||||
margin-bottom: 8px;
|
||||
|
||||
.sender-name {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.message-body {
|
||||
.message-text {
|
||||
font-size: 15px;
|
||||
line-height: 1.6;
|
||||
color: var(--el-text-color-primary);
|
||||
word-wrap: break-word;
|
||||
|
||||
:deep(p) {
|
||||
margin: 0 0 12px;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(code) {
|
||||
background: var(--el-fill-color-light);
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-family: 'JetBrains Mono', 'Courier New', monospace;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
:deep(pre) {
|
||||
background: var(--el-fill-color-light);
|
||||
padding: 16px;
|
||||
border-radius: 8px;
|
||||
overflow-x: auto;
|
||||
margin: 12px 0;
|
||||
|
||||
code {
|
||||
background: none;
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(strong) {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
:deep(em) {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
:deep(ul), :deep(ol) {
|
||||
padding-left: 20px;
|
||||
margin: 12px 0;
|
||||
}
|
||||
|
||||
:deep(li) {
|
||||
margin: 4px 0;
|
||||
}
|
||||
}
|
||||
|
||||
.typing-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--el-text-color-secondary);
|
||||
|
||||
.typing-dots {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
|
||||
span {
|
||||
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; }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.message-actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
margin-top: 8px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
|
||||
.el-button {
|
||||
padding: 4px 8px;
|
||||
min-height: auto;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover .message-actions {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.error-banner {
|
||||
padding: 16px 24px;
|
||||
}
|
||||
}
|
||||
|
||||
// 输入区域
|
||||
.chat-input {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
padding: 16px 24px 24px;
|
||||
background: var(--el-bg-color);
|
||||
border-top: 1px solid var(--el-border-color-light);
|
||||
z-index: 10;
|
||||
|
||||
.input-wrapper {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
|
||||
.input-container {
|
||||
position: relative;
|
||||
background: var(--el-bg-color-page);
|
||||
border: 1px solid var(--el-border-color);
|
||||
border-radius: 12px;
|
||||
transition: border-color 0.2s ease;
|
||||
box-shadow: var(--el-box-shadow-light);
|
||||
|
||||
&:focus-within {
|
||||
border-color: var(--el-color-primary);
|
||||
box-shadow: var(--el-box-shadow);
|
||||
}
|
||||
|
||||
.message-input {
|
||||
:deep(.el-textarea__inner) {
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
background: transparent;
|
||||
resize: none;
|
||||
padding: 16px 60px 16px 16px;
|
||||
font-size: 15px;
|
||||
line-height: 1.5;
|
||||
|
||||
&:focus {
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.send-button {
|
||||
position: absolute;
|
||||
right: 12px;
|
||||
bottom: 12px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
min-height: 32px;
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.input-footer {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-top: 12px;
|
||||
|
||||
.input-hint {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes typing {
|
||||
0%, 20% {
|
||||
opacity: 0.4;
|
||||
transform: scale(0.8);
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
80%, 100% {
|
||||
opacity: 0.4;
|
||||
transform: scale(0.8);
|
||||
}
|
||||
}
|
||||
|
||||
// 滚动条样式
|
||||
.chat-messages::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.chat-messages::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.chat-messages::-webkit-scrollbar-thumb {
|
||||
background: var(--el-fill-color);
|
||||
border-radius: 3px;
|
||||
|
||||
&:hover {
|
||||
background: var(--el-fill-color-dark);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -94,7 +94,7 @@
|
||||
</div>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<div v-if="applicationList.length === 0 && !loading" class="empty-state">
|
||||
<div v-if="applicationList.length === 0 && !loading" >
|
||||
<el-empty :image-size="120" description="暂无应用" />
|
||||
</div>
|
||||
|
||||
@@ -283,6 +283,7 @@ async function handleCurrentChange(page: number) {
|
||||
|
||||
// 创建应用
|
||||
function handleCreateApp() {
|
||||
console.log('handleCreateApp');
|
||||
dialogType.value = 'create';
|
||||
resetForm();
|
||||
dialogVisible.value = true;
|
||||
@@ -552,18 +553,6 @@ onMounted(() => {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
// 空状态样式
|
||||
.empty-state {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.dialog-footer {
|
||||
display: flex;
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
<template>
|
||||
<div>
|
||||
<!-- Simple view placeholder -->
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// Simple view component
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Simple view styles */
|
||||
</style>
|
||||
@@ -127,11 +127,11 @@
|
||||
<template #default="{ row }">
|
||||
<div class="file-name">
|
||||
<el-icon class="file-icon">
|
||||
<Folder v-if="row.is_directory" />
|
||||
<Folder v-if="row.is_dir" />
|
||||
<Document v-else />
|
||||
</el-icon>
|
||||
<span
|
||||
:class="{ 'file-name-clickable': !row.is_directory }"
|
||||
:class="{ 'file-name-clickable': !row.is_dir }"
|
||||
@click="handleFileNameClick(row)"
|
||||
>
|
||||
{{ row.name }}
|
||||
@@ -141,7 +141,7 @@
|
||||
</el-table-column>
|
||||
<el-table-column label="大小" prop="size" min-width="120" align="center">
|
||||
<template #default="{ row }">
|
||||
<span v-if="!row.is_directory">{{ formatFileSize(row.size) }}</span>
|
||||
<span v-if="!row.is_dir">{{ formatFileSize(row.size) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="类型" prop="file_type" min-width="100" align="center">
|
||||
@@ -153,7 +153,7 @@
|
||||
<el-table-column fixed="right" label="操作" align="center" min-width="200">
|
||||
<template #default="{ row }">
|
||||
<el-button
|
||||
v-if="!row.is_directory"
|
||||
v-if="!row.is_dir"
|
||||
type="success"
|
||||
size="small"
|
||||
link
|
||||
@@ -181,7 +181,7 @@
|
||||
@click="handleItemClick(item)"
|
||||
>
|
||||
<div class="item-icon">
|
||||
<el-icon v-if="item.is_directory" size="48">
|
||||
<el-icon v-if="item.is_dir" size="48">
|
||||
<Folder />
|
||||
</el-icon>
|
||||
<el-icon v-else size="48">
|
||||
@@ -189,7 +189,7 @@
|
||||
</el-icon>
|
||||
</div>
|
||||
<div class="item-name">{{ item.name }}</div>
|
||||
<div class="item-size" v-if="!item.is_directory">
|
||||
<div class="item-size" v-if="!item.is_dir">
|
||||
{{ formatFileSize(item.size) }}
|
||||
</div>
|
||||
</div>
|
||||
@@ -347,8 +347,14 @@ const renameForm = reactive({
|
||||
old_path: ''
|
||||
})
|
||||
|
||||
// 工具函数:确保路径基于根路径
|
||||
// 工具函数:处理路径转换
|
||||
const ensureRootPath = (path: string) => {
|
||||
// 如果是HTTP URL,提取相对路径部分
|
||||
if (path.startsWith('http')) {
|
||||
const url = new URL(path)
|
||||
return url.pathname.replace('/api/v1/static', '/home/static')
|
||||
}
|
||||
|
||||
if (path.startsWith(RESOURCE_ROOT_PATH)) {
|
||||
return path
|
||||
}
|
||||
@@ -367,26 +373,16 @@ const loadFileList = async () => {
|
||||
loading.value = true
|
||||
const response = await ResourceAPI.getResourceList(currentQuery.value)
|
||||
|
||||
// 根据实际 API 响应结构获取数据
|
||||
// 根据新的 API 响应结构获取数据
|
||||
const data = response.data?.data?.items || response.data?.data
|
||||
|
||||
if (Array.isArray(data)) {
|
||||
// 转换数据格式以匹配前端期望的结构
|
||||
// 直接使用后端返回的数据结构,无需转换
|
||||
fileList.value = data.map(item => ({
|
||||
name: item.name,
|
||||
path: item.path,
|
||||
is_directory: item.is_dir || item.is_directory,
|
||||
size: item.size,
|
||||
file_type: item.file_type,
|
||||
extension: item.file_extension,
|
||||
modified_time: item.modified_time,
|
||||
created_time: item.created_time,
|
||||
is_hidden: item.name.startsWith('.'),
|
||||
resource_type: item.resource_type,
|
||||
file_url: item.file_url,
|
||||
thumbnail_url: item.thumbnail_url
|
||||
...item,
|
||||
is_hidden: item.name.startsWith('.')
|
||||
}))
|
||||
total.value = fileList.value.length
|
||||
total.value = response.data?.data?.total_files + response.data?.data?.total_dirs || fileList.value.length
|
||||
} else {
|
||||
fileList.value = []
|
||||
total.value = 0
|
||||
@@ -411,7 +407,15 @@ const handleBreadcrumbClick = (item: any, index: number) => {
|
||||
|
||||
const updateBreadcrumb = () => {
|
||||
const rootPath = RESOURCE_ROOT_PATH
|
||||
const relativePath = currentPath.value.replace(rootPath, '').replace(/^\/+/, '')
|
||||
let relativePath = currentPath.value
|
||||
|
||||
// 处理HTTP URL格式的路径
|
||||
if (currentPath.value.startsWith('http')) {
|
||||
const url = new URL(currentPath.value)
|
||||
relativePath = url.pathname.replace('/api/v1/static', '/home/static')
|
||||
}
|
||||
|
||||
relativePath = relativePath.replace(rootPath, '').replace(/^\/+/, '')
|
||||
const pathParts = relativePath ? relativePath.split('/').filter(Boolean) : []
|
||||
|
||||
breadcrumbList.value = [
|
||||
@@ -424,7 +428,7 @@ const updateBreadcrumb = () => {
|
||||
}
|
||||
|
||||
const handleFileNameClick = (row: ResourceItem) => {
|
||||
if (row.is_directory) {
|
||||
if (row.is_dir) {
|
||||
currentPath.value = ensureRootPath(row.path)
|
||||
updateBreadcrumb()
|
||||
loadFileList()
|
||||
@@ -435,7 +439,7 @@ const handleFileNameClick = (row: ResourceItem) => {
|
||||
}
|
||||
|
||||
const handleItemClick = (item: ResourceItem) => {
|
||||
if (item.is_directory) {
|
||||
if (item.is_dir) {
|
||||
currentPath.value = ensureRootPath(item.path)
|
||||
updateBreadcrumb()
|
||||
loadFileList()
|
||||
@@ -447,9 +451,8 @@ const handleItemClick = (item: ResourceItem) => {
|
||||
|
||||
// 文件预览
|
||||
const handleFilePreview = (file: ResourceItem) => {
|
||||
// 构建文件预览URL,移除/home前缀
|
||||
const relativePath = file.path.replace('/home/static', '')
|
||||
const previewUrl = `${import.meta.env.VITE_API_BASE_URL}${import.meta.env.VITE_APP_BASE_API}/static${relativePath}`
|
||||
// 使用后端返回的完整URL路径进行预览
|
||||
const previewUrl = file.path
|
||||
|
||||
// 根据文件类型决定预览方式
|
||||
const fileExtension = file.file_extension?.toLowerCase() || ''
|
||||
@@ -547,25 +550,15 @@ const handleQuery = async () => {
|
||||
loading.value = true
|
||||
const response = await ResourceAPI.searchResource(queryFormData)
|
||||
|
||||
// 根据实际 API 响应结构获取数据
|
||||
// 根据新的 API 响应结构获取数据
|
||||
const data = response.data?.data?.items || response.data?.data
|
||||
if (Array.isArray(data)) {
|
||||
// 转换数据格式以匹配前端期望的结构
|
||||
// 直接使用后端返回的数据结构,无需转换
|
||||
fileList.value = data.map(item => ({
|
||||
name: item.name,
|
||||
path: item.path,
|
||||
is_directory: item.is_dir || item.is_directory,
|
||||
size: item.size,
|
||||
file_type: item.file_type,
|
||||
extension: item.file_extension,
|
||||
modified_time: item.modified_time,
|
||||
created_time: item.created_time,
|
||||
is_hidden: item.name.startsWith('.'),
|
||||
resource_type: item.resource_type,
|
||||
file_url: item.file_url,
|
||||
thumbnail_url: item.thumbnail_url
|
||||
...item,
|
||||
is_hidden: item.name.startsWith('.')
|
||||
}))
|
||||
total.value = fileList.value.length
|
||||
total.value = response.data?.data?.total_files + response.data?.data?.total_dirs || fileList.value.length
|
||||
} else {
|
||||
console.warn('搜索 API 返回的数据不是数组类型:', data)
|
||||
fileList.value = []
|
||||
|
||||
Reference in New Issue
Block a user