Files
map-client-vue/web/src/components/Chat/ChatLayout.vue.backup
2025-10-14 21:52:11 +08:00

701 lines
16 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div class="chat-container">
<!-- 对话区域 -->
<div class="chat-area" :class="{ 'sidebar-visible': showSidebar }">
<!-- 搜索框 -->
<div class="search-box">
<n-input
v-model:value="searchKeyword"
placeholder="搜索对话..."
clearable
size="small"
>
<template #prefix>
<n-icon :component="SearchIcon" />
</template>
</n-input>
</div>
<!-- 话题列表 -->
<n-scrollbar class="topics-list">
<div
v-for="topic in filteredTopics"
:key="topic.id"
class="topic-item"
:class="{ active: store.state.currentTopicId === topic.id }"
@click="store.setCurrentTopic(topic.id)"
>
<div class="topic-content">
<div class="topic-header">
<span class="topic-name">{{ topic.name }}</span>
<n-dropdown
trigger="click"
:options="getTopicMenuOptions(topic)"
@select="(key) => handleTopicMenu(key, topic)"
>
<n-button text size="tiny" @click.stop>
<n-icon :component="DotsVerticalIcon" size="16" />
</n-button>
</n-dropdown>
</div>
<div class="topic-preview">{{ topic.lastMessage || '暂无消息' }}</div>
<div class="topic-meta">
<span>{{ topic.messageCount }} 条消息</span>
<span>{{ formatDate(topic.updatedAt) }}</span>
</div>
</div>
</div>
<div v-if="filteredTopics.length === 0" class="empty-topics">
<p>{{ searchKeyword ? '未找到匹配的对话' : '暂无对话' }}</p>
</div>
</n-scrollbar>
</div>
<!-- 对话区域 -->
<div class="chat-area">
<div v-if="!store.state.currentTopicId" class="empty-chat">
<div class="empty-icon">
<n-icon :component="MessageIcon" size="64" />
</div>
<h3>选择一个对话开始聊天</h3>
<p>或者创建一个新对话</p>
<n-button type="primary" @click="handleCreateTopic">
<template #icon>
<n-icon :component="PlusIcon" />
</template>
新建对话
</n-button>
</div>
<template v-else>
<!-- 对话头部 -->
<div class="chat-header">
<div class="header-info">
<h3>{{ store.currentTopic.value?.name }}</h3>
<span class="message-count">{{ store.state.messages.length }} 条消息</span>
</div>
<div class="header-actions">
<n-button text @click="handleClearMessages">
<n-icon :component="TrashIcon" />
</n-button>
</div>
</div>
<!-- 消息列表 -->
<n-scrollbar class="messages-container" ref="messagesScrollRef">
<div class="messages-list">
<div
v-for="msg in store.state.messages"
:key="msg.id"
class="message-item"
:class="msg.role"
>
<div class="message-avatar">
<n-icon
:component="msg.role === 'user' ? UserIcon : RobotIcon"
size="20"
/>
</div>
<div class="message-content">
<div class="message-header">
<span class="message-role">{{
msg.role === 'user' ? '你' : 'AI 助手'
}}</span>
<span class="message-time">{{ formatTime(msg.timestamp) }}</span>
<n-tag v-if="msg.status === 'sending'" type="info" size="small">
发送中...
</n-tag>
<n-tag v-else-if="msg.status === 'error'" type="error" size="small">
发送失败
</n-tag>
</div>
<div class="message-text">
<div v-if="msg.status === 'sending'" class="typing-indicator">
<span></span>
<span></span>
<span></span>
</div>
<template v-else>
{{ msg.content }}
</template>
<div v-if="msg.error" class="message-error">
{{ msg.error }}
</div>
</div>
<div v-if="msg.role === 'assistant' && msg.status === 'success'" class="message-actions">
<n-button text size="tiny" @click="handleCopyMessage(msg.content)">
<n-icon :component="CopyIcon" size="14" />
复制
</n-button>
<n-button text size="tiny" @click="handleRegenerateMessage(msg.id)">
<n-icon :component="RefreshIcon" size="14" />
重新生成
</n-button>
<n-button text size="tiny" @click="handleDeleteMessage(msg.id)">
<n-icon :component="TrashIcon" size="14" />
删除
</n-button>
</div>
</div>
</div>
</div>
</n-scrollbar>
<!-- 输入框 -->
<div class="input-container">
<n-input
v-model:value="inputText"
type="textarea"
placeholder="输入消息... (Shift + Enter 换行Enter 发送)"
:autosize="{ minRows: 1, maxRows: 6 }"
:disabled="store.state.isSending"
@keydown.enter="handleKeyDown"
/>
<div class="input-actions">
<div class="input-info">
<span v-if="selectedModel" class="model-info">
模型: {{ selectedModel }}
</span>
</div>
<n-button
type="primary"
:disabled="!inputText.trim() || store.state.isSending"
:loading="store.state.isSending"
@click="handleSendMessage"
>
<template #icon>
<n-icon :component="SendIcon" />
</template>
发送
</n-button>
</div>
</div>
</template>
</div>
<!-- 编辑话题弹窗 -->
<n-modal
v-model:show="showEditModal"
preset="card"
title="编辑对话"
style="width: 500px"
>
<n-form>
<n-form-item label="对话名称">
<n-input v-model:value="editForm.name" placeholder="请输入对话名称" />
</n-form-item>
<n-form-item label="描述">
<n-input
v-model:value="editForm.description"
type="textarea"
placeholder="可选"
:rows="3"
/>
</n-form-item>
</n-form>
<template #footer>
<div style="display: flex; justify-content: flex-end; gap: 12px">
<n-button @click="showEditModal = false">取消</n-button>
<n-button type="primary" @click="handleSaveEdit">保存</n-button>
</div>
</template>
</n-modal>
</div>
</template>
<script setup lang="ts">
import { ref, computed, nextTick, onMounted } from 'vue'
import {
NButton,
NIcon,
NInput,
NScrollbar,
NTag,
NDropdown,
NModal,
NForm,
NFormItem,
useMessage
} from 'naive-ui'
import {
Plus as PlusIcon,
Search as SearchIcon,
DotsVertical as DotsVerticalIcon,
Message as MessageIcon,
Trash as TrashIcon,
User as UserIcon,
Robot as RobotIcon,
Copy as CopyIcon,
Refresh as RefreshIcon,
Send as SendIcon
} from '@vicons/tabler'
import { useChatStore } from '../../stores/chatStore'
import type { Topic } from '../../types/chat'
const message = useMessage()
const store = useChatStore()
// 响应式数据
const searchKeyword = ref('')
const inputText = ref('')
const showEditModal = ref(false)
const editingTopic = ref<Topic | null>(null)
const editForm = ref({
name: '',
description: ''
})
const messagesScrollRef = ref()
const selectedModel = ref<string>()
// 计算属性
const filteredTopics = computed(() => {
let topics = store.state.topics
if (searchKeyword.value) {
const keyword = searchKeyword.value.toLowerCase()
topics = topics.filter(
t =>
t.name.toLowerCase().includes(keyword) ||
t.lastMessage?.toLowerCase().includes(keyword)
)
}
return topics
})
// 话题菜单选项
const getTopicMenuOptions = (topic: Topic) => [
{
label: topic.pinned ? '取消置顶' : '置顶',
key: 'pin'
},
{
label: '重命名',
key: 'rename'
},
{
label: '删除',
key: 'delete'
}
]
// 处理话题菜单
const handleTopicMenu = (key: string, topic: Topic) => {
switch (key) {
case 'pin':
store.toggleTopicPin(topic.id)
message.success(topic.pinned ? '已取消置顶' : '已置顶')
break
case 'rename':
editingTopic.value = topic
editForm.value.name = topic.name
editForm.value.description = topic.description || ''
showEditModal.value = true
break
case 'delete':
if (confirm(`确定要删除对话"${topic.name}"吗?`)) {
store.deleteTopic(topic.id)
message.success('已删除')
}
break
}
}
// 创建话题
const handleCreateTopic = () => {
const name = prompt('请输入对话名称:', '新对话')
if (name) {
store.createTopic(name.trim())
message.success('已创建')
}
}
// 保存编辑
const handleSaveEdit = () => {
if (!editingTopic.value) return
store.updateTopic(editingTopic.value.id, {
name: editForm.value.name,
description: editForm.value.description
})
showEditModal.value = false
editingTopic.value = null
message.success('已保存')
}
// 清空消息
const handleClearMessages = () => {
if (confirm('确定要清空当前对话的所有消息吗?')) {
// TODO: 实现清空消息
message.success('已清空')
}
}
// 发送消息
const handleSendMessage = async () => {
if (!inputText.value.trim() || store.state.isSending) return
const content = inputText.value.trim()
inputText.value = ''
try {
await store.sendMessageStream(content, selectedModel.value, () => {
// 滚动到底部
scrollToBottom()
})
scrollToBottom()
} catch (error) {
message.error(error instanceof Error ? error.message : '发送失败')
}
}
// 键盘事件
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleSendMessage()
}
}
// 复制消息
const handleCopyMessage = (content: string) => {
navigator.clipboard
.writeText(content)
.then(() => message.success('已复制'))
.catch(() => message.error('复制失败'))
}
// 重新生成
const handleRegenerateMessage = async (messageId: string) => {
try {
await store.regenerateMessage(messageId)
message.success('正在重新生成...')
scrollToBottom()
} catch (error) {
message.error(error instanceof Error ? error.message : '重新生成失败')
}
}
// 删除消息
const handleDeleteMessage = (messageId: string) => {
if (confirm('确定要删除这条消息吗?')) {
store.deleteMessage(messageId)
message.success('已删除')
}
}
// 滚动到底部
const scrollToBottom = () => {
nextTick(() => {
if (messagesScrollRef.value) {
messagesScrollRef.value.scrollTo({ top: messagesScrollRef.value.$el.scrollHeight, behavior: 'smooth' })
}
})
}
// 格式化日期
const formatDate = (date: Date): string => {
const now = new Date()
const diff = now.getTime() - new Date(date).getTime()
const mins = Math.floor(diff / 60000)
const hours = Math.floor(diff / 3600000)
const days = Math.floor(diff / 86400000)
if (mins < 1) return '刚刚'
if (mins < 60) return `${mins}分钟前`
if (hours < 24) return `${hours}小时前`
if (days < 7) return `${days}天前`
return new Date(date).toLocaleDateString('zh-CN')
}
// 格式化时间
const formatTime = (date: Date): string => {
return new Date(date).toLocaleTimeString('zh-CN', {
hour: '2-digit',
minute: '2-digit'
})
}
// 初始化
onMounted(() => {
store.initialize()
scrollToBottom()
})
</script>
<style scoped>
.chat-container {
display: flex;
height: 100%;
background: var(--color-base);
}
/* 话题侧边栏 */
.topics-sidebar {
width: 280px;
border-right: 1px solid var(--border-color);
display: flex;
flex-direction: column;
background: var(--color-target-bg);
}
.sidebar-header {
padding: 16px;
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 1px solid var(--border-color);
}
.sidebar-header h3 {
margin: 0;
font-size: 16px;
font-weight: 600;
}
.search-box {
padding: 12px 16px;
border-bottom: 1px solid var(--border-color);
}
.topics-list {
flex: 1;
padding: 8px;
}
.topic-item {
padding: 12px;
margin-bottom: 4px;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s;
}
.topic-item:hover {
background: var(--hover-color);
}
.topic-item.active {
background: var(--primary-color-hover);
}
.topic-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 4px;
}
.topic-name {
font-weight: 500;
font-size: 14px;
}
.topic-preview {
font-size: 12px;
color: var(--text-color-3);
margin-bottom: 4px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.topic-meta {
display: flex;
justify-content: space-between;
font-size: 11px;
color: var(--text-color-3);
}
.empty-topics {
text-align: center;
padding: 40px 20px;
color: var(--text-color-3);
}
/* 对话区域 */
.chat-area {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
.empty-chat {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 16px;
}
.empty-icon {
color: var(--border-color);
}
.empty-chat h3 {
margin: 0;
font-size: 20px;
}
.empty-chat p {
margin: 0;
color: var(--text-color-3);
}
/* 对话头部 */
.chat-header {
padding: 16px 24px;
border-bottom: 1px solid var(--border-color);
display: flex;
justify-content: space-between;
align-items: center;
}
.header-info h3 {
margin: 0 0 4px 0;
font-size: 18px;
font-weight: 600;
}
.message-count {
font-size: 12px;
color: var(--text-color-3);
}
/* 消息列表 */
.messages-container {
flex: 1;
padding: 24px;
}
.messages-list {
display: flex;
flex-direction: column;
gap: 24px;
}
.message-item {
display: flex;
gap: 12px;
}
.message-avatar {
width: 32px;
height: 32px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
background: var(--primary-color);
color: white;
}
.message-item.assistant .message-avatar {
background: var(--success-color);
}
.message-content {
flex: 1;
min-width: 0;
}
.message-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.message-role {
font-weight: 600;
font-size: 14px;
}
.message-time {
font-size: 12px;
color: var(--text-color-3);
}
.message-text {
font-size: 14px;
line-height: 1.6;
white-space: pre-wrap;
word-break: break-word;
}
.message-error {
color: var(--error-color);
font-size: 12px;
margin-top: 8px;
}
.message-actions {
display: flex;
gap: 8px;
margin-top: 8px;
}
.typing-indicator {
display: flex;
gap: 4px;
padding: 8px 0;
}
.typing-indicator span {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--primary-color);
animation: typing 1.4s infinite;
}
.typing-indicator span:nth-child(2) {
animation-delay: 0.2s;
}
.typing-indicator span:nth-child(3) {
animation-delay: 0.4s;
}
@keyframes typing {
0%, 60%, 100% {
opacity: 0.3;
}
30% {
opacity: 1;
}
}
/* 输入框 */
.input-container {
padding: 16px 24px;
border-top: 1px solid var(--border-color);
background: var(--color-target-bg);
}
.input-actions {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 12px;
}
.input-info {
font-size: 12px;
color: var(--text-color-3);
}
.model-info {
background: var(--tag-color);
padding: 2px 8px;
border-radius: 4px;
}
</style>