update at 2025-10-14 21:52:11
This commit is contained in:
700
web/src/components/Chat/ChatLayout.vue.backup
Normal file
700
web/src/components/Chat/ChatLayout.vue.backup
Normal file
@@ -0,0 +1,700 @@
|
||||
<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>
|
||||
Reference in New Issue
Block a user