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

756 lines
18 KiB
Vue
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="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>