2025-12-12 18:17:38 +08:00
|
|
|
<template>
|
|
|
|
|
<div class="chat-view">
|
|
|
|
|
<!-- Left Sidebar - ChatGPT Style -->
|
|
|
|
|
<aside class="chat-sidebar" :class="{ collapsed: sidebarCollapsed }">
|
|
|
|
|
<div class="sidebar-header">
|
|
|
|
|
<button class="collapse-toggle" @click="sidebarCollapsed = !sidebarCollapsed">
|
|
|
|
|
<el-icon><Fold v-if="!sidebarCollapsed" /><Expand v-else /></el-icon>
|
|
|
|
|
<span v-if="!sidebarCollapsed">{{ sidebarCollapsed ? '展开' : '收起' }}</span>
|
|
|
|
|
</button>
|
|
|
|
|
<button class="new-chat-btn" @click="handleNewChat">
|
|
|
|
|
<el-icon><Plus /></el-icon>
|
|
|
|
|
<span v-if="!sidebarCollapsed">新建对话</span>
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div v-if="!sidebarCollapsed" class="conversations-list">
|
|
|
|
|
<div class="list-section">
|
|
|
|
|
<div class="section-title">今天</div>
|
|
|
|
|
<div
|
|
|
|
|
v-for="conv in todayConversations"
|
|
|
|
|
:key="conv.id"
|
|
|
|
|
class="conversation-item"
|
|
|
|
|
:class="{ active: currentConversationId === conv.id }"
|
|
|
|
|
@click="selectConversation(conv)"
|
|
|
|
|
>
|
|
|
|
|
<el-icon><ChatDotRound /></el-icon>
|
|
|
|
|
<span class="conv-title">{{ conv.title }}</span>
|
|
|
|
|
<div class="conv-actions">
|
|
|
|
|
<el-icon class="action-icon" @click.stop="deleteConversation(conv.id)"><Delete /></el-icon>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div class="list-section" v-if="olderConversations.length > 0">
|
|
|
|
|
<div class="section-title">历史记录</div>
|
|
|
|
|
<div
|
|
|
|
|
v-for="conv in olderConversations"
|
|
|
|
|
:key="conv.id"
|
|
|
|
|
class="conversation-item"
|
|
|
|
|
:class="{ active: currentConversationId === conv.id }"
|
|
|
|
|
@click="selectConversation(conv)"
|
|
|
|
|
>
|
|
|
|
|
<el-icon><ChatDotRound /></el-icon>
|
|
|
|
|
<span class="conv-title">{{ conv.title }}</span>
|
|
|
|
|
<div class="conv-actions">
|
|
|
|
|
<el-icon class="action-icon" @click.stop="deleteConversation(conv.id)"><Delete /></el-icon>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</aside>
|
|
|
|
|
|
|
|
|
|
<!-- Main Chat Area -->
|
|
|
|
|
<div class="chat-main">
|
|
|
|
|
<!-- Header -->
|
|
|
|
|
<header class="chat-header">
|
|
|
|
|
<el-dropdown trigger="click" @command="handleAgentChange" class="agent-dropdown">
|
|
|
|
|
<div class="header-title">
|
|
|
|
|
<span class="agent-icon">{{ currentAgent.icon }}</span>
|
|
|
|
|
<span>{{ currentAgent.name }}</span>
|
|
|
|
|
<el-icon><ArrowDown /></el-icon>
|
|
|
|
|
</div>
|
|
|
|
|
<template #dropdown>
|
|
|
|
|
<el-dropdown-menu>
|
|
|
|
|
<el-dropdown-item
|
|
|
|
|
v-for="agent in agents"
|
|
|
|
|
:key="agent.id"
|
|
|
|
|
:command="agent.id"
|
|
|
|
|
:class="{ 'is-active': agent.id === currentAgent.id }"
|
|
|
|
|
>
|
|
|
|
|
<span class="dropdown-agent-icon">{{ agent.icon }}</span>
|
|
|
|
|
<span>{{ agent.name }}</span>
|
|
|
|
|
</el-dropdown-item>
|
|
|
|
|
</el-dropdown-menu>
|
|
|
|
|
</template>
|
|
|
|
|
</el-dropdown>
|
|
|
|
|
</header>
|
|
|
|
|
|
|
|
|
|
<!-- Chat Content -->
|
|
|
|
|
<div class="chat-content" ref="chatContentRef">
|
|
|
|
|
<!-- Welcome Message -->
|
|
|
|
|
<div v-if="messages.length === 0" class="welcome-section">
|
|
|
|
|
<ChatDefault
|
|
|
|
|
:agent="currentAgent"
|
|
|
|
|
@suggestion-click="handleSuggestionClick"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<!-- Chat Messages -->
|
|
|
|
|
<div v-else class="messages-container">
|
|
|
|
|
<div
|
|
|
|
|
v-for="message in messages"
|
|
|
|
|
:key="message.id"
|
|
|
|
|
class="message"
|
|
|
|
|
:class="message.role"
|
|
|
|
|
>
|
|
|
|
|
<div class="message-avatar">
|
|
|
|
|
<img v-if="message.role === 'assistant'" src="/logo.jpg" alt="AI" class="ai-avatar-small" />
|
|
|
|
|
<div v-else class="user-avatar-small">👤</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="message-content">
|
|
|
|
|
<div class="message-text">{{ message.content }}</div>
|
|
|
|
|
<div class="message-time">{{ formatTime(message.timestamp) }}</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<!-- Loading indicator -->
|
|
|
|
|
<div v-if="isLoading" class="message assistant">
|
|
|
|
|
<div class="message-avatar">
|
|
|
|
|
<img src="/logo.jpg" alt="AI" class="ai-avatar-small" />
|
|
|
|
|
</div>
|
|
|
|
|
<div class="message-content">
|
|
|
|
|
<div class="typing-indicator">
|
|
|
|
|
<span></span>
|
|
|
|
|
<span></span>
|
|
|
|
|
<span></span>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<!-- Input Area -->
|
|
|
|
|
<div class="input-area">
|
|
|
|
|
<div class="input-wrapper">
|
|
|
|
|
<textarea
|
|
|
|
|
v-model="inputText"
|
|
|
|
|
placeholder="请输入内容..."
|
|
|
|
|
@keydown.enter.prevent="handleSend"
|
2025-12-20 12:55:43 +08:00
|
|
|
:rows="1"
|
2025-12-12 18:17:38 +08:00
|
|
|
ref="textareaRef"
|
|
|
|
|
></textarea>
|
|
|
|
|
<div class="input-actions">
|
|
|
|
|
<div class="action-buttons">
|
|
|
|
|
<button class="action-btn" title="附件">
|
|
|
|
|
<el-icon><Paperclip /></el-icon>
|
|
|
|
|
</button>
|
|
|
|
|
<button class="action-btn" title="表情">
|
|
|
|
|
<el-icon><Star /></el-icon>
|
|
|
|
|
</button>
|
|
|
|
|
<button class="action-btn" title="图片">
|
|
|
|
|
<el-icon><Picture /></el-icon>
|
|
|
|
|
</button>
|
|
|
|
|
<button class="action-btn" title="更多">
|
|
|
|
|
<el-icon><MoreFilled /></el-icon>
|
|
|
|
|
</button>
|
|
|
|
|
<button class="action-btn" title="截图">
|
|
|
|
|
<el-icon><CameraFilled /></el-icon>
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="send-actions">
|
|
|
|
|
<button class="action-btn" title="语音">
|
|
|
|
|
<el-icon><Microphone /></el-icon>
|
|
|
|
|
</button>
|
|
|
|
|
<button class="send-btn" @click="handleSend" :disabled="!inputText.trim()">
|
|
|
|
|
<el-icon><Promotion /></el-icon>
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
<script lang="ts" setup>
|
|
|
|
|
import { ref, computed, nextTick, onMounted } from 'vue'
|
|
|
|
|
import { useRoute } from 'vue-router'
|
|
|
|
|
import {
|
2025-12-20 17:12:42 +08:00
|
|
|
ChevronDown as ArrowDown,
|
2025-12-12 18:17:38 +08:00
|
|
|
Paperclip,
|
|
|
|
|
Star,
|
2025-12-20 17:12:42 +08:00
|
|
|
Image as Picture,
|
|
|
|
|
MoreHorizontal as MoreFilled,
|
|
|
|
|
Camera as CameraFilled,
|
|
|
|
|
Mic as Microphone,
|
|
|
|
|
Send as Promotion,
|
2025-12-12 18:17:38 +08:00
|
|
|
Plus,
|
2025-12-20 17:12:42 +08:00
|
|
|
PanelLeftClose as Fold,
|
|
|
|
|
PanelLeftOpen as Expand,
|
|
|
|
|
MessageCircle as ChatDotRound,
|
|
|
|
|
Trash2 as Delete
|
|
|
|
|
} from 'lucide-vue-next'
|
2025-12-12 18:17:38 +08:00
|
|
|
import ChatDefault from './components/ChatDefault/ChatDefault.vue'
|
|
|
|
|
|
|
|
|
|
const route = useRoute()
|
|
|
|
|
|
|
|
|
|
// Mock 数据 - 智能体列表
|
|
|
|
|
const agents = ref([
|
|
|
|
|
{
|
|
|
|
|
id: 'default',
|
|
|
|
|
name: '城市生命线助手',
|
|
|
|
|
icon: '🏙️',
|
|
|
|
|
color: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
|
|
|
|
description: '我是城市生命线智能助手,专注于城市基础设施安全管理'
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
id: 'emergency',
|
|
|
|
|
name: '应急处理助手',
|
|
|
|
|
icon: '🚨',
|
|
|
|
|
color: 'linear-gradient(135deg, #f093fb 0%, #f5576c 100%)',
|
|
|
|
|
description: '专注于应急事件处理和预案制定'
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
id: 'analysis',
|
|
|
|
|
name: '数据分析助手',
|
|
|
|
|
icon: '📊',
|
|
|
|
|
color: 'linear-gradient(135deg, #4facfe 0%, #00f2fe 100%)',
|
|
|
|
|
description: '提供数据分析和可视化服务'
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
id: 'safety',
|
|
|
|
|
name: '安全检查助手',
|
|
|
|
|
icon: '🔍',
|
|
|
|
|
color: 'linear-gradient(135deg, #43e97b 0%, #38f9d7 100%)',
|
|
|
|
|
description: '协助进行安全隐患排查和整改'
|
|
|
|
|
}
|
|
|
|
|
])
|
|
|
|
|
|
|
|
|
|
const currentAgent = ref(agents.value[0])
|
|
|
|
|
|
|
|
|
|
// 状态管理
|
|
|
|
|
const sidebarCollapsed = ref(false)
|
|
|
|
|
const currentConversationId = ref<number | null>(null)
|
|
|
|
|
const inputText = ref('')
|
|
|
|
|
const isLoading = ref(false)
|
|
|
|
|
const chatContentRef = ref<HTMLElement | null>(null)
|
|
|
|
|
const textareaRef = ref<HTMLTextAreaElement | null>(null)
|
|
|
|
|
|
|
|
|
|
// Mock 数据 - 对话历史
|
|
|
|
|
interface Conversation {
|
|
|
|
|
id: number
|
|
|
|
|
title: string
|
|
|
|
|
date: Date
|
|
|
|
|
messages: Message[]
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface Message {
|
|
|
|
|
id: string
|
|
|
|
|
content: string
|
|
|
|
|
role: 'user' | 'assistant'
|
|
|
|
|
timestamp: string
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const conversations = ref<Conversation[]>([
|
|
|
|
|
{
|
|
|
|
|
id: 1,
|
|
|
|
|
title: '城市生命线关键设施咨询',
|
|
|
|
|
date: new Date(),
|
|
|
|
|
messages: []
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
id: 2,
|
|
|
|
|
title: '消防安全隐患处理方案',
|
|
|
|
|
date: new Date(),
|
|
|
|
|
messages: []
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
id: 3,
|
|
|
|
|
title: '排水系统优化建议',
|
|
|
|
|
date: new Date(Date.now() - 86400000),
|
|
|
|
|
messages: []
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
id: 4,
|
|
|
|
|
title: '应急预案讨论',
|
|
|
|
|
date: new Date(Date.now() - 172800000),
|
|
|
|
|
messages: []
|
|
|
|
|
}
|
|
|
|
|
])
|
|
|
|
|
|
|
|
|
|
const messages = ref<Message[]>([])
|
|
|
|
|
|
|
|
|
|
// 今天的对话
|
|
|
|
|
const todayConversations = computed(() => {
|
|
|
|
|
const today = new Date()
|
|
|
|
|
today.setHours(0, 0, 0, 0)
|
|
|
|
|
return conversations.value.filter(conv => new Date(conv.date) >= today)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// 历史对话
|
|
|
|
|
const olderConversations = computed(() => {
|
|
|
|
|
const today = new Date()
|
|
|
|
|
today.setHours(0, 0, 0, 0)
|
|
|
|
|
return conversations.value.filter(conv => new Date(conv.date) < today)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// 格式化时间
|
|
|
|
|
const formatTime = (timestamp: string) => {
|
|
|
|
|
if (!timestamp) return ''
|
|
|
|
|
const date = new Date(timestamp)
|
|
|
|
|
return date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 滚动到底部
|
|
|
|
|
const scrollToBottom = async () => {
|
|
|
|
|
await nextTick()
|
|
|
|
|
if (chatContentRef.value) {
|
|
|
|
|
chatContentRef.value.scrollTop = chatContentRef.value.scrollHeight
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 新建对话
|
|
|
|
|
const handleNewChat = () => {
|
|
|
|
|
const newConv: Conversation = {
|
|
|
|
|
id: Date.now(),
|
|
|
|
|
title: '新对话',
|
|
|
|
|
date: new Date(),
|
|
|
|
|
messages: []
|
|
|
|
|
}
|
|
|
|
|
conversations.value.unshift(newConv)
|
|
|
|
|
currentConversationId.value = newConv.id
|
|
|
|
|
messages.value = []
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 选择对话
|
|
|
|
|
const selectConversation = (conv: Conversation) => {
|
|
|
|
|
currentConversationId.value = conv.id
|
|
|
|
|
messages.value = conv.messages || []
|
|
|
|
|
scrollToBottom()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 删除对话
|
|
|
|
|
const deleteConversation = (id: number) => {
|
|
|
|
|
const index = conversations.value.findIndex(c => c.id === id)
|
|
|
|
|
if (index > -1) {
|
|
|
|
|
conversations.value.splice(index, 1)
|
|
|
|
|
if (currentConversationId.value === id) {
|
|
|
|
|
currentConversationId.value = null
|
|
|
|
|
messages.value = []
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 切换智能体
|
|
|
|
|
const handleAgentChange = (agentId: string) => {
|
|
|
|
|
const agent = agents.value.find(a => a.id === agentId)
|
|
|
|
|
if (agent) {
|
|
|
|
|
currentAgent.value = agent
|
|
|
|
|
// 切换智能体时清空对话
|
|
|
|
|
messages.value = []
|
|
|
|
|
currentConversationId.value = null
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 处理建议点击
|
|
|
|
|
const handleSuggestionClick = async (suggestion: string) => {
|
|
|
|
|
inputText.value = suggestion
|
|
|
|
|
await handleSend()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 发送消息 - Mock 实现
|
|
|
|
|
const handleSend = async () => {
|
|
|
|
|
const text = inputText.value.trim()
|
|
|
|
|
if (!text || isLoading.value) return
|
|
|
|
|
|
|
|
|
|
// 添加用户消息
|
|
|
|
|
const userMessage: Message = {
|
|
|
|
|
id: Date.now().toString(),
|
|
|
|
|
content: text,
|
|
|
|
|
role: 'user',
|
|
|
|
|
timestamp: new Date().toISOString()
|
|
|
|
|
}
|
|
|
|
|
messages.value.push(userMessage)
|
|
|
|
|
inputText.value = ''
|
|
|
|
|
|
|
|
|
|
await scrollToBottom()
|
|
|
|
|
|
|
|
|
|
// 显示加载状态
|
|
|
|
|
isLoading.value = true
|
|
|
|
|
|
|
|
|
|
// Mock API 响应
|
|
|
|
|
setTimeout(async () => {
|
|
|
|
|
const mockResponses = [
|
|
|
|
|
'根据城市生命线安全管理的相关规定,我为您分析如下...',
|
|
|
|
|
'这是一个很好的问题。让我详细为您解答...',
|
|
|
|
|
'基于您的需求,我建议采取以下措施...',
|
|
|
|
|
'从专业角度来看,这个问题需要综合考虑多个因素...'
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
const assistantMessage: Message = {
|
|
|
|
|
id: Date.now().toString() + '_ai',
|
|
|
|
|
content: mockResponses[Math.floor(Math.random() * mockResponses.length)],
|
|
|
|
|
role: 'assistant',
|
|
|
|
|
timestamp: new Date().toISOString()
|
|
|
|
|
}
|
|
|
|
|
messages.value.push(assistantMessage)
|
|
|
|
|
|
|
|
|
|
// 保存到当前对话
|
|
|
|
|
if (currentConversationId.value) {
|
|
|
|
|
const conv = conversations.value.find(c => c.id === currentConversationId.value)
|
|
|
|
|
if (conv) {
|
|
|
|
|
conv.messages = messages.value
|
|
|
|
|
// 更新对话标题(取第一条用户消息)
|
|
|
|
|
if (conv.title === '新对话' && messages.value.length > 0) {
|
|
|
|
|
const firstUserMsg = messages.value.find(m => m.role === 'user')
|
|
|
|
|
if (firstUserMsg) {
|
|
|
|
|
conv.title = firstUserMsg.content.slice(0, 20) + (firstUserMsg.content.length > 20 ? '...' : '')
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
isLoading.value = false
|
|
|
|
|
await scrollToBottom()
|
|
|
|
|
}, 1000 + Math.random() * 1000) // 随机延迟 1-2 秒
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
onMounted(() => {
|
|
|
|
|
// 从 URL 参数读取 agentId
|
|
|
|
|
const agentId = route.query.agentId as string
|
|
|
|
|
if (agentId) {
|
|
|
|
|
const agent = agents.value.find(a => a.id === agentId)
|
|
|
|
|
if (agent) {
|
|
|
|
|
currentAgent.value = agent
|
|
|
|
|
console.log('[AI Chat] 已切换到智能体:', agent.name)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
console.log('AI Chat View mounted')
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
<style lang="scss" scoped>
|
|
|
|
|
@import url('./AIChatView.scss');
|
|
|
|
|
</style>
|