756 lines
18 KiB
Vue
756 lines
18 KiB
Vue
<template>
|
||
<div class="data-manager-page">
|
||
<!-- 页面头部 -->
|
||
<div class="page-header">
|
||
<div class="header-info">
|
||
<h1>数据管理</h1>
|
||
<p>管理对话历史、数据源和系统数据</p>
|
||
</div>
|
||
<div class="header-actions">
|
||
<n-button @click="exportData">
|
||
<template #icon>
|
||
<n-icon :component="DownloadIcon" />
|
||
</template>
|
||
导出数据
|
||
</n-button>
|
||
<n-button type="error" @click="showClearModal = true">
|
||
<template #icon>
|
||
<n-icon :component="TrashIcon" />
|
||
</template>
|
||
清理数据
|
||
</n-button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 统计卡片 -->
|
||
<div class="stats-cards">
|
||
<n-card class="stat-card">
|
||
<div class="stat-content">
|
||
<div class="stat-icon">
|
||
<n-icon :component="MessageIcon" size="24" color="#18a058" />
|
||
</div>
|
||
<div class="stat-info">
|
||
<div class="stat-value">{{ conversations.length }}</div>
|
||
<div class="stat-label">对话数量</div>
|
||
</div>
|
||
</div>
|
||
</n-card>
|
||
|
||
<n-card class="stat-card">
|
||
<div class="stat-content">
|
||
<div class="stat-icon">
|
||
<n-icon :component="DatabaseIcon" size="24" color="#2080f0" />
|
||
</div>
|
||
<div class="stat-info">
|
||
<div class="stat-value">{{ getTotalMessages() }}</div>
|
||
<div class="stat-label">消息总数</div>
|
||
</div>
|
||
</div>
|
||
</n-card>
|
||
|
||
<n-card class="stat-card">
|
||
<div class="stat-content">
|
||
<div class="stat-icon">
|
||
<n-icon :component="ClockIcon" size="24" color="#f0a020" />
|
||
</div>
|
||
<div class="stat-info">
|
||
<div class="stat-value">{{ getStorageSize() }}</div>
|
||
<div class="stat-label">存储使用</div>
|
||
</div>
|
||
</div>
|
||
</n-card>
|
||
|
||
<n-card class="stat-card">
|
||
<div class="stat-content">
|
||
<div class="stat-icon">
|
||
<n-icon :component="CalendarIcon" size="24" color="#d03050" />
|
||
</div>
|
||
<div class="stat-info">
|
||
<div class="stat-value">{{ getLastActiveDate() }}</div>
|
||
<div class="stat-label">最后活动</div>
|
||
</div>
|
||
</div>
|
||
</n-card>
|
||
</div>
|
||
|
||
<!-- 对话历史列表 -->
|
||
<div class="conversations-section">
|
||
<div class="section-header">
|
||
<h2>对话历史</h2>
|
||
<div class="section-actions">
|
||
<n-input
|
||
v-model:value="searchKeyword"
|
||
placeholder="搜索对话..."
|
||
clearable
|
||
style="width: 240px"
|
||
>
|
||
<template #prefix>
|
||
<n-icon :component="SearchIcon" />
|
||
</template>
|
||
</n-input>
|
||
</div>
|
||
</div>
|
||
|
||
<div v-if="filteredConversations.length === 0" class="empty-state">
|
||
<div class="empty-icon">
|
||
<n-icon :component="MessageIcon" size="48" />
|
||
</div>
|
||
<h3>{{ searchKeyword ? '未找到匹配的对话' : '暂无对话历史' }}</h3>
|
||
<p>{{ searchKeyword ? '尝试使用其他关键词搜索' : '开始一个新对话吧' }}</p>
|
||
</div>
|
||
|
||
<div v-else class="conversations-list">
|
||
<n-card
|
||
v-for="conv in filteredConversations"
|
||
:key="conv.id"
|
||
class="conversation-card"
|
||
hoverable
|
||
>
|
||
<div class="conversation-header">
|
||
<div class="conversation-info">
|
||
<h3 class="conversation-title">{{ conv.title || '未命名对话' }}</h3>
|
||
<div class="conversation-meta">
|
||
<span class="meta-item">
|
||
<n-icon :component="MessageIcon" size="14" />
|
||
{{ conv.messages.length }} 条消息
|
||
</span>
|
||
<span class="meta-item">
|
||
<n-icon :component="ClockIcon" size="14" />
|
||
{{ formatDate(conv.updatedAt) }}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
<div class="conversation-actions">
|
||
<n-button text @click="viewConversation(conv)">
|
||
<n-icon :component="EyeIcon" size="18" />
|
||
</n-button>
|
||
<n-button text @click="exportConversation(conv)">
|
||
<n-icon :component="DownloadIcon" size="18" />
|
||
</n-button>
|
||
<n-button text @click="deleteConversation(conv)">
|
||
<n-icon :component="TrashIcon" size="18" color="#d03050" />
|
||
</n-button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="conversation-preview">
|
||
<p>{{ getConversationPreview(conv) }}</p>
|
||
</div>
|
||
</n-card>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 查看对话详情弹窗 -->
|
||
<n-modal
|
||
v-model:show="showViewModal"
|
||
preset="card"
|
||
title="对话详情"
|
||
style="width: 800px; max-height: 80vh"
|
||
>
|
||
<div v-if="selectedConversation" class="conversation-detail">
|
||
<div class="detail-header">
|
||
<h3>{{ selectedConversation.title || '未命名对话' }}</h3>
|
||
<div class="detail-meta">
|
||
<span>创建时间: {{ formatDate(selectedConversation.createdAt) }}</span>
|
||
<span>更新时间: {{ formatDate(selectedConversation.updatedAt) }}</span>
|
||
<span>消息数: {{ selectedConversation.messages.length }}</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="messages-list">
|
||
<div
|
||
v-for="(msg, index) in selectedConversation.messages"
|
||
:key="index"
|
||
class="message-item"
|
||
:class="msg.role"
|
||
>
|
||
<div class="message-header">
|
||
<span class="message-role">{{ getRoleLabel(msg.role) }}</span>
|
||
<span class="message-time">{{ formatTime(msg.timestamp) }}</span>
|
||
</div>
|
||
<div class="message-content">{{ msg.content }}</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</n-modal>
|
||
|
||
<!-- 清理数据确认弹窗 -->
|
||
<n-modal
|
||
v-model:show="showClearModal"
|
||
preset="dialog"
|
||
title="确认清理数据"
|
||
positive-text="确认清理"
|
||
negative-text="取消"
|
||
@positive-click="handleClearData"
|
||
>
|
||
<div class="clear-options">
|
||
<p>请选择要清理的数据类型:</p>
|
||
<n-checkbox-group v-model:value="clearOptions">
|
||
<n-space vertical>
|
||
<n-checkbox value="conversations" label="对话历史" />
|
||
<n-checkbox value="tools" label="工具配置" />
|
||
<n-checkbox value="services" label="模型服务" />
|
||
<n-checkbox value="settings" label="系统设置" />
|
||
</n-space>
|
||
</n-checkbox-group>
|
||
<n-alert type="warning" style="margin-top: 16px">
|
||
此操作不可恢复,请谨慎操作!建议先导出数据备份。
|
||
</n-alert>
|
||
</div>
|
||
</n-modal>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { ref, computed, onMounted } from 'vue'
|
||
import {
|
||
NCard,
|
||
NButton,
|
||
NIcon,
|
||
NModal,
|
||
NInput,
|
||
NCheckbox,
|
||
NCheckboxGroup,
|
||
NSpace,
|
||
NAlert,
|
||
useMessage
|
||
} from 'naive-ui'
|
||
import {
|
||
Download as DownloadIcon,
|
||
Trash as TrashIcon,
|
||
Message as MessageIcon,
|
||
Database as DatabaseIcon,
|
||
Clock as ClockIcon,
|
||
Calendar as CalendarIcon,
|
||
Search as SearchIcon,
|
||
Eye as EyeIcon
|
||
} from '@vicons/tabler'
|
||
|
||
const message = useMessage()
|
||
|
||
// 消息接口
|
||
interface Message {
|
||
role: 'user' | 'assistant' | 'system'
|
||
content: string
|
||
timestamp: Date
|
||
}
|
||
|
||
// 对话接口
|
||
interface Conversation {
|
||
id: string
|
||
title: string
|
||
messages: Message[]
|
||
createdAt: Date
|
||
updatedAt: Date
|
||
}
|
||
|
||
// 响应式数据
|
||
const conversations = ref<Conversation[]>([])
|
||
const searchKeyword = ref('')
|
||
const showViewModal = ref(false)
|
||
const showClearModal = ref(false)
|
||
const selectedConversation = ref<Conversation | null>(null)
|
||
const clearOptions = ref<string[]>([])
|
||
|
||
// 过滤后的对话列表
|
||
const filteredConversations = computed(() => {
|
||
if (!searchKeyword.value) {
|
||
return conversations.value
|
||
}
|
||
|
||
const keyword = searchKeyword.value.toLowerCase()
|
||
return conversations.value.filter(conv => {
|
||
return (
|
||
conv.title.toLowerCase().includes(keyword) ||
|
||
conv.messages.some(msg => msg.content.toLowerCase().includes(keyword))
|
||
)
|
||
})
|
||
})
|
||
|
||
// 获取总消息数
|
||
const getTotalMessages = (): number => {
|
||
return conversations.value.reduce((total, conv) => total + conv.messages.length, 0)
|
||
}
|
||
|
||
// 获取存储大小
|
||
const getStorageSize = (): string => {
|
||
try {
|
||
let totalSize = 0
|
||
for (let key in localStorage) {
|
||
if (localStorage.hasOwnProperty(key)) {
|
||
totalSize += localStorage[key].length + key.length
|
||
}
|
||
}
|
||
const sizeInKB = totalSize / 1024
|
||
if (sizeInKB < 1024) {
|
||
return `${sizeInKB.toFixed(2)} KB`
|
||
}
|
||
return `${(sizeInKB / 1024).toFixed(2)} MB`
|
||
} catch {
|
||
return '未知'
|
||
}
|
||
}
|
||
|
||
// 获取最后活动日期
|
||
const getLastActiveDate = (): string => {
|
||
if (conversations.value.length === 0) {
|
||
return '无'
|
||
}
|
||
|
||
const latest = conversations.value.reduce((latest, conv) => {
|
||
return new Date(conv.updatedAt) > new Date(latest.updatedAt) ? conv : latest
|
||
})
|
||
|
||
return formatDate(latest.updatedAt)
|
||
}
|
||
|
||
// 格式化日期
|
||
const formatDate = (date: Date | string): string => {
|
||
const d = new Date(date)
|
||
const now = new Date()
|
||
const diffMs = now.getTime() - d.getTime()
|
||
const diffMins = Math.floor(diffMs / 60000)
|
||
const diffHours = Math.floor(diffMs / 3600000)
|
||
const diffDays = Math.floor(diffMs / 86400000)
|
||
|
||
if (diffMins < 1) return '刚刚'
|
||
if (diffMins < 60) return `${diffMins} 分钟前`
|
||
if (diffHours < 24) return `${diffHours} 小时前`
|
||
if (diffDays < 7) return `${diffDays} 天前`
|
||
|
||
return d.toLocaleDateString('zh-CN', {
|
||
year: 'numeric',
|
||
month: '2-digit',
|
||
day: '2-digit'
|
||
})
|
||
}
|
||
|
||
// 格式化时间
|
||
const formatTime = (date: Date | string | undefined): string => {
|
||
if (!date) return ''
|
||
const d = new Date(date)
|
||
return d.toLocaleTimeString('zh-CN', {
|
||
hour: '2-digit',
|
||
minute: '2-digit'
|
||
})
|
||
}
|
||
|
||
// 获取角色标签
|
||
const getRoleLabel = (role: string): string => {
|
||
const labels: Record<string, string> = {
|
||
user: '用户',
|
||
assistant: '助手',
|
||
system: '系统'
|
||
}
|
||
return labels[role] || role
|
||
}
|
||
|
||
// 获取对话预览
|
||
const getConversationPreview = (conv: Conversation): string => {
|
||
if (conv.messages.length === 0) {
|
||
return '暂无消息'
|
||
}
|
||
|
||
const lastMsg = conv.messages[conv.messages.length - 1]
|
||
const preview = lastMsg.content.substring(0, 100)
|
||
return preview.length < lastMsg.content.length ? `${preview}...` : preview
|
||
}
|
||
|
||
// 查看对话
|
||
const viewConversation = (conv: Conversation) => {
|
||
selectedConversation.value = conv
|
||
showViewModal.value = true
|
||
}
|
||
|
||
// 导出单个对话
|
||
const exportConversation = (conv: Conversation) => {
|
||
try {
|
||
const data = JSON.stringify(conv, null, 2)
|
||
const blob = new Blob([data], { type: 'application/json' })
|
||
const url = URL.createObjectURL(blob)
|
||
const a = document.createElement('a')
|
||
a.href = url
|
||
a.download = `conversation_${conv.id}_${Date.now()}.json`
|
||
a.click()
|
||
URL.revokeObjectURL(url)
|
||
message.success('对话已导出')
|
||
} catch (error) {
|
||
message.error('导出失败')
|
||
}
|
||
}
|
||
|
||
// 删除对话
|
||
const deleteConversation = (conv: Conversation) => {
|
||
const index = conversations.value.findIndex(c => c.id === conv.id)
|
||
if (index !== -1) {
|
||
conversations.value.splice(index, 1)
|
||
saveConversations()
|
||
message.success('对话已删除')
|
||
}
|
||
}
|
||
|
||
// 导出所有数据
|
||
const exportData = () => {
|
||
try {
|
||
const data = {
|
||
conversations: conversations.value,
|
||
tools: JSON.parse(localStorage.getItem('mcp-tools') || '[]'),
|
||
services: JSON.parse(localStorage.getItem('model-services') || '[]'),
|
||
settings: JSON.parse(localStorage.getItem('mcp-settings') || '{}'),
|
||
exportDate: new Date().toISOString()
|
||
}
|
||
|
||
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' })
|
||
const url = URL.createObjectURL(blob)
|
||
const a = document.createElement('a')
|
||
a.href = url
|
||
a.download = `mcp_backup_${Date.now()}.json`
|
||
a.click()
|
||
URL.revokeObjectURL(url)
|
||
message.success('数据已导出')
|
||
} catch (error) {
|
||
message.error('导出失败')
|
||
}
|
||
}
|
||
|
||
// 清理数据
|
||
const handleClearData = () => {
|
||
if (clearOptions.value.length === 0) {
|
||
message.warning('请选择要清理的数据类型')
|
||
return false
|
||
}
|
||
|
||
try {
|
||
clearOptions.value.forEach(option => {
|
||
switch (option) {
|
||
case 'conversations':
|
||
localStorage.removeItem('mcp-conversations')
|
||
conversations.value = []
|
||
break
|
||
case 'tools':
|
||
localStorage.removeItem('mcp-tools')
|
||
break
|
||
case 'services':
|
||
localStorage.removeItem('model-services')
|
||
break
|
||
case 'settings':
|
||
localStorage.removeItem('mcp-settings')
|
||
localStorage.removeItem('display-settings')
|
||
break
|
||
}
|
||
})
|
||
|
||
message.success('数据已清理')
|
||
clearOptions.value = []
|
||
return true
|
||
} catch (error) {
|
||
message.error('清理失败')
|
||
return false
|
||
}
|
||
}
|
||
|
||
// 保存对话
|
||
const saveConversations = () => {
|
||
try {
|
||
localStorage.setItem('mcp-conversations', JSON.stringify(conversations.value))
|
||
} catch (error) {
|
||
console.error('保存对话失败:', error)
|
||
}
|
||
}
|
||
|
||
// 加载对话
|
||
const loadConversations = () => {
|
||
try {
|
||
const saved = localStorage.getItem('mcp-conversations')
|
||
if (saved) {
|
||
conversations.value = JSON.parse(saved)
|
||
} else {
|
||
// 加载示例数据(首次使用)
|
||
loadSampleData()
|
||
}
|
||
} catch (error) {
|
||
console.error('加载对话失败:', error)
|
||
}
|
||
}
|
||
|
||
// 加载示例数据
|
||
const loadSampleData = () => {
|
||
const sampleConversations: Conversation[] = [
|
||
{
|
||
id: 'sample_1',
|
||
title: '欢迎使用 MCP 客户端',
|
||
messages: [
|
||
{
|
||
role: 'system',
|
||
content: '欢迎使用 MCP 客户端!这是一个示例对话。',
|
||
timestamp: new Date(Date.now() - 86400000)
|
||
},
|
||
{
|
||
role: 'user',
|
||
content: '你好,这是我的第一条消息',
|
||
timestamp: new Date(Date.now() - 86400000 + 1000)
|
||
},
|
||
{
|
||
role: 'assistant',
|
||
content: '你好!很高兴见到你。我是 AI 助手,我可以帮助你完成各种任务。',
|
||
timestamp: new Date(Date.now() - 86400000 + 2000)
|
||
}
|
||
],
|
||
createdAt: new Date(Date.now() - 86400000),
|
||
updatedAt: new Date(Date.now() - 86400000 + 2000)
|
||
}
|
||
]
|
||
|
||
conversations.value = sampleConversations
|
||
saveConversations()
|
||
}
|
||
|
||
// 生命周期
|
||
onMounted(() => {
|
||
loadConversations()
|
||
})
|
||
</script>
|
||
|
||
<style scoped>
|
||
.data-manager-page {
|
||
padding: 24px;
|
||
max-width: 1400px;
|
||
margin: 0 auto;
|
||
}
|
||
|
||
.page-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: flex-start;
|
||
margin-bottom: 24px;
|
||
}
|
||
|
||
.header-info h1 {
|
||
font-size: 24px;
|
||
font-weight: 600;
|
||
margin: 0 0 8px 0;
|
||
}
|
||
|
||
.header-info p {
|
||
font-size: 14px;
|
||
color: var(--text-color-3);
|
||
margin: 0;
|
||
}
|
||
|
||
.header-actions {
|
||
display: flex;
|
||
gap: 12px;
|
||
}
|
||
|
||
.stats-cards {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||
gap: 16px;
|
||
margin-bottom: 32px;
|
||
}
|
||
|
||
.stat-card {
|
||
transition: all 0.3s;
|
||
}
|
||
|
||
.stat-card:hover {
|
||
transform: translateY(-2px);
|
||
}
|
||
|
||
.stat-content {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 16px;
|
||
}
|
||
|
||
.stat-icon {
|
||
width: 48px;
|
||
height: 48px;
|
||
border-radius: 12px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
background: var(--color-target-bg);
|
||
}
|
||
|
||
.stat-info {
|
||
flex: 1;
|
||
}
|
||
|
||
.stat-value {
|
||
font-size: 24px;
|
||
font-weight: 600;
|
||
margin-bottom: 4px;
|
||
}
|
||
|
||
.stat-label {
|
||
font-size: 13px;
|
||
color: var(--text-color-3);
|
||
}
|
||
|
||
.conversations-section {
|
||
margin-top: 32px;
|
||
}
|
||
|
||
.section-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
margin-bottom: 16px;
|
||
}
|
||
|
||
.section-header h2 {
|
||
font-size: 18px;
|
||
font-weight: 600;
|
||
margin: 0;
|
||
}
|
||
|
||
.empty-state {
|
||
text-align: center;
|
||
padding: 80px 20px;
|
||
}
|
||
|
||
.empty-icon {
|
||
font-size: 48px;
|
||
color: var(--border-color);
|
||
margin-bottom: 16px;
|
||
}
|
||
|
||
.empty-state h3 {
|
||
font-size: 18px;
|
||
margin: 0 0 8px 0;
|
||
}
|
||
|
||
.empty-state p {
|
||
color: var(--text-color-3);
|
||
margin: 0;
|
||
}
|
||
|
||
.conversations-list {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 12px;
|
||
}
|
||
|
||
.conversation-card {
|
||
transition: all 0.3s;
|
||
}
|
||
|
||
.conversation-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: flex-start;
|
||
margin-bottom: 12px;
|
||
}
|
||
|
||
.conversation-info {
|
||
flex: 1;
|
||
}
|
||
|
||
.conversation-title {
|
||
font-size: 16px;
|
||
font-weight: 600;
|
||
margin: 0 0 8px 0;
|
||
}
|
||
|
||
.conversation-meta {
|
||
display: flex;
|
||
gap: 16px;
|
||
font-size: 13px;
|
||
color: var(--text-color-3);
|
||
}
|
||
|
||
.meta-item {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 4px;
|
||
}
|
||
|
||
.conversation-actions {
|
||
display: flex;
|
||
gap: 4px;
|
||
}
|
||
|
||
.conversation-preview {
|
||
color: var(--text-color-2);
|
||
font-size: 14px;
|
||
line-height: 1.6;
|
||
}
|
||
|
||
.conversation-preview p {
|
||
margin: 0;
|
||
}
|
||
|
||
.conversation-detail {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 16px;
|
||
}
|
||
|
||
.detail-header h3 {
|
||
font-size: 18px;
|
||
font-weight: 600;
|
||
margin: 0 0 8px 0;
|
||
}
|
||
|
||
.detail-meta {
|
||
display: flex;
|
||
gap: 16px;
|
||
font-size: 13px;
|
||
color: var(--text-color-3);
|
||
}
|
||
|
||
.messages-list {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 16px;
|
||
max-height: 500px;
|
||
overflow-y: auto;
|
||
}
|
||
|
||
.message-item {
|
||
padding: 12px;
|
||
border-radius: 8px;
|
||
background: var(--color-target-bg);
|
||
}
|
||
|
||
.message-item.user {
|
||
background: rgba(24, 160, 88, 0.1);
|
||
}
|
||
|
||
.message-item.assistant {
|
||
background: rgba(32, 128, 240, 0.1);
|
||
}
|
||
|
||
.message-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
margin-bottom: 8px;
|
||
font-size: 12px;
|
||
}
|
||
|
||
.message-role {
|
||
font-weight: 600;
|
||
}
|
||
|
||
.message-time {
|
||
color: var(--text-color-3);
|
||
}
|
||
|
||
.message-content {
|
||
font-size: 14px;
|
||
line-height: 1.6;
|
||
white-space: pre-wrap;
|
||
word-break: break-word;
|
||
}
|
||
|
||
.clear-options {
|
||
padding: 16px 0;
|
||
}
|
||
|
||
.clear-options p {
|
||
margin: 0 0 16px 0;
|
||
font-weight: 500;
|
||
}
|
||
</style>
|