update at 2025-10-14 21:52:11

This commit is contained in:
douboer
2025-10-14 21:52:11 +08:00
parent ac3ed480ab
commit 4f5eea604e
40 changed files with 15231 additions and 126 deletions

View File

@@ -0,0 +1,755 @@
<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>