update at 2025-10-14 21:52:11
This commit is contained in:
1066
web/src/components/Chat/ChatLayout.vue
Normal file
1066
web/src/components/Chat/ChatLayout.vue
Normal file
File diff suppressed because it is too large
Load Diff
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>
|
||||
755
web/src/components/DataManager.vue
Normal file
755
web/src/components/DataManager.vue
Normal 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>
|
||||
@@ -377,10 +377,31 @@ const applySettings = () => {
|
||||
root.setAttribute('data-theme', actualTheme)
|
||||
|
||||
// 应用主色调 - 修复颜色应用逻辑
|
||||
root.style.setProperty('--primary-color', displaySettings.primaryColor)
|
||||
root.style.setProperty('--n-color-primary', displaySettings.primaryColor)
|
||||
root.style.setProperty('--n-color-primary-hover', displaySettings.primaryColor + '20')
|
||||
root.style.setProperty('--n-color-primary-pressed', displaySettings.primaryColor + '40')
|
||||
const primaryColor = displaySettings.primaryColor
|
||||
root.style.setProperty('--primary-color', primaryColor)
|
||||
|
||||
// Naive UI 主题变量
|
||||
root.style.setProperty('--n-color-primary', primaryColor)
|
||||
root.style.setProperty('--n-color-primary-hover', primaryColor + 'CC') // 80% 透明度
|
||||
root.style.setProperty('--n-color-primary-pressed', primaryColor + '99') // 60% 透明度
|
||||
root.style.setProperty('--n-color-primary-suppl', primaryColor)
|
||||
|
||||
// 计算颜色变体
|
||||
const hexToRgb = (hex: string) => {
|
||||
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex)
|
||||
return result ? {
|
||||
r: parseInt(result[1], 16),
|
||||
g: parseInt(result[2], 16),
|
||||
b: parseInt(result[3], 16)
|
||||
} : null
|
||||
}
|
||||
|
||||
const rgb = hexToRgb(primaryColor)
|
||||
if (rgb) {
|
||||
root.style.setProperty('--n-color-primary-hover', `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, 0.8)`)
|
||||
root.style.setProperty('--n-color-primary-pressed', `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, 0.9)`)
|
||||
root.style.setProperty('--n-border-color-primary', `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, 0.3)`)
|
||||
}
|
||||
|
||||
// 应用缩放
|
||||
if (typeof document !== 'undefined') {
|
||||
@@ -421,6 +442,10 @@ const applySettings = () => {
|
||||
// 监听设置变化并自动应用
|
||||
watch(displaySettings, () => {
|
||||
applySettings()
|
||||
// 触发主题更新事件
|
||||
window.dispatchEvent(new CustomEvent('theme-color-changed', {
|
||||
detail: displaySettings.primaryColor
|
||||
}))
|
||||
}, { deep: true })
|
||||
|
||||
// 生命周期
|
||||
|
||||
1222
web/src/components/ModelService.vue
Normal file
1222
web/src/components/ModelService.vue
Normal file
File diff suppressed because it is too large
Load Diff
703
web/src/components/ToolsManager.vue
Normal file
703
web/src/components/ToolsManager.vue
Normal file
@@ -0,0 +1,703 @@
|
||||
<template>
|
||||
<div class="tools-manager-page">
|
||||
<!-- 页面头部 -->
|
||||
<div class="page-header">
|
||||
<div class="header-info">
|
||||
<h1>工具管理</h1>
|
||||
<p>管理和配置 MCP 工具,扩展 AI 模型的能力</p>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<n-button type="primary" @click="showAddModal = true">
|
||||
<template #icon>
|
||||
<n-icon :component="PlusIcon" />
|
||||
</template>
|
||||
添加工具
|
||||
</n-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 工具列表 -->
|
||||
<div class="tools-content">
|
||||
<div v-if="tools.length === 0" class="empty-state">
|
||||
<div class="empty-icon">
|
||||
<n-icon :component="ToolIcon" size="48" />
|
||||
</div>
|
||||
<h3>暂无工具</h3>
|
||||
<p>添加您的第一个 MCP 工具,扩展 AI 模型的能力</p>
|
||||
<n-button type="primary" @click="showAddModal = true">添加工具</n-button>
|
||||
</div>
|
||||
|
||||
<div v-else class="tools-grid">
|
||||
<n-card
|
||||
v-for="tool in tools"
|
||||
:key="tool.id"
|
||||
class="tool-card"
|
||||
hoverable
|
||||
>
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<div class="tool-info">
|
||||
<n-icon :component="ToolIcon" size="20" class="tool-icon" />
|
||||
<span class="tool-name">{{ tool.name }}</span>
|
||||
</div>
|
||||
<div class="card-actions">
|
||||
<n-switch
|
||||
v-model:value="tool.enabled"
|
||||
@update:value="toggleTool(tool)"
|
||||
size="small"
|
||||
/>
|
||||
<n-dropdown :options="getDropdownOptions()" @select="handleDropdownSelect($event, tool)">
|
||||
<n-button text>
|
||||
<n-icon :component="DotsVerticalIcon" size="18" />
|
||||
</n-button>
|
||||
</n-dropdown>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="tool-content">
|
||||
<p class="tool-description">{{ tool.description || '暂无描述' }}</p>
|
||||
|
||||
<div class="tool-meta">
|
||||
<div class="meta-item">
|
||||
<span class="meta-label">端点:</span>
|
||||
<span class="meta-value">{{ tool.endpoint || '未配置' }}</span>
|
||||
</div>
|
||||
<div class="meta-item">
|
||||
<span class="meta-label">参数:</span>
|
||||
<span class="meta-value">{{ getParameterCount(tool) }} 个</span>
|
||||
</div>
|
||||
<div class="meta-item">
|
||||
<span class="meta-label">状态:</span>
|
||||
<n-tag :type="tool.enabled ? 'success' : 'default'" size="small">
|
||||
{{ tool.enabled ? '已启用' : '已禁用' }}
|
||||
</n-tag>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tool-actions">
|
||||
<n-button size="small" @click="testTool(tool)">
|
||||
<template #icon>
|
||||
<n-icon :component="TestIcon" />
|
||||
</template>
|
||||
测试工具
|
||||
</n-button>
|
||||
<n-button size="small" @click="editTool(tool)">
|
||||
<template #icon>
|
||||
<n-icon :component="EditIcon" />
|
||||
</template>
|
||||
编辑
|
||||
</n-button>
|
||||
</div>
|
||||
</div>
|
||||
</n-card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 添加/编辑工具弹窗 -->
|
||||
<n-modal
|
||||
v-model:show="showAddModal"
|
||||
preset="card"
|
||||
:title="editingTool ? '编辑工具' : '添加工具'"
|
||||
class="tool-modal"
|
||||
style="width: 600px"
|
||||
:mask-closable="false"
|
||||
>
|
||||
<n-form
|
||||
ref="formRef"
|
||||
:model="formData"
|
||||
:rules="formRules"
|
||||
label-placement="left"
|
||||
label-width="100"
|
||||
>
|
||||
<n-form-item label="工具名称" path="name">
|
||||
<n-input
|
||||
v-model:value="formData.name"
|
||||
placeholder="例如: web_search"
|
||||
/>
|
||||
</n-form-item>
|
||||
|
||||
<n-form-item label="显示名称" path="displayName">
|
||||
<n-input
|
||||
v-model:value="formData.displayName"
|
||||
placeholder="例如: 网页搜索"
|
||||
/>
|
||||
</n-form-item>
|
||||
|
||||
<n-form-item label="描述" path="description">
|
||||
<n-input
|
||||
v-model:value="formData.description"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
placeholder="描述工具的功能和用途"
|
||||
/>
|
||||
</n-form-item>
|
||||
|
||||
<n-form-item label="端点地址" path="endpoint">
|
||||
<n-input
|
||||
v-model:value="formData.endpoint"
|
||||
placeholder="http://localhost:8080/api/tool"
|
||||
/>
|
||||
</n-form-item>
|
||||
|
||||
<n-form-item label="参数配置" path="parameters">
|
||||
<n-input
|
||||
v-model:value="formData.parameters"
|
||||
type="textarea"
|
||||
:rows="6"
|
||||
placeholder='{"query": {"type": "string", "description": "搜索关键词"}}'
|
||||
/>
|
||||
</n-form-item>
|
||||
|
||||
<n-form-item label="启用工具">
|
||||
<n-switch v-model:value="formData.enabled" />
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
|
||||
<template #footer>
|
||||
<div class="modal-footer">
|
||||
<n-button @click="showAddModal = false">取消</n-button>
|
||||
<n-button type="primary" @click="handleSaveTool" :loading="saving">
|
||||
保存
|
||||
</n-button>
|
||||
</div>
|
||||
</template>
|
||||
</n-modal>
|
||||
|
||||
<!-- 测试工具弹窗 -->
|
||||
<n-modal
|
||||
v-model:show="showTestModal"
|
||||
preset="card"
|
||||
title="测试工具"
|
||||
style="width: 600px"
|
||||
>
|
||||
<div class="test-content">
|
||||
<n-form label-placement="left" label-width="100">
|
||||
<n-form-item label="测试参数">
|
||||
<n-input
|
||||
v-model:value="testParams"
|
||||
type="textarea"
|
||||
:rows="6"
|
||||
placeholder='{"query": "测试搜索"}'
|
||||
/>
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
|
||||
<div v-if="testResult.status === 'testing'" class="test-status">
|
||||
<n-spin size="small" />
|
||||
<span>测试中...</span>
|
||||
</div>
|
||||
|
||||
<div v-else-if="testResult.status === 'success'" class="test-status success">
|
||||
<n-icon :component="CheckIcon" size="20" color="#18a058" />
|
||||
<span>测试成功</span>
|
||||
</div>
|
||||
|
||||
<div v-else-if="testResult.status === 'error'" class="test-status error">
|
||||
<n-icon :component="XIcon" size="20" color="#d03050" />
|
||||
<span>测试失败:{{ testResult.error }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="testResult.response" class="test-response">
|
||||
<h4>响应结果:</h4>
|
||||
<pre>{{ JSON.stringify(testResult.response, null, 2) }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="modal-footer">
|
||||
<n-button @click="showTestModal = false">关闭</n-button>
|
||||
<n-button type="primary" @click="runTest" :loading="testResult.status === 'testing'">
|
||||
运行测试
|
||||
</n-button>
|
||||
</div>
|
||||
</template>
|
||||
</n-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import {
|
||||
NCard,
|
||||
NButton,
|
||||
NIcon,
|
||||
NTag,
|
||||
NModal,
|
||||
NForm,
|
||||
NFormItem,
|
||||
NInput,
|
||||
NSwitch,
|
||||
NDropdown,
|
||||
NSpin,
|
||||
useMessage
|
||||
} from 'naive-ui'
|
||||
import {
|
||||
Plus as PlusIcon,
|
||||
Tool as ToolIcon,
|
||||
DotsVertical as DotsVerticalIcon,
|
||||
PlayerPlay as TestIcon,
|
||||
Edit as EditIcon,
|
||||
X as XIcon,
|
||||
Check as CheckIcon,
|
||||
Copy as CopyIcon,
|
||||
Trash as TrashIcon
|
||||
} from '@vicons/tabler'
|
||||
|
||||
const message = useMessage()
|
||||
|
||||
// 工具接口定义
|
||||
interface Tool {
|
||||
id: string
|
||||
name: string
|
||||
displayName?: string
|
||||
description: string
|
||||
endpoint: string
|
||||
parameters: Record<string, any>
|
||||
enabled: boolean
|
||||
createdAt?: Date
|
||||
updatedAt?: Date
|
||||
}
|
||||
|
||||
// 响应式数据
|
||||
const tools = ref<Tool[]>([])
|
||||
const showAddModal = ref(false)
|
||||
const showTestModal = ref(false)
|
||||
const editingTool = ref<Tool | null>(null)
|
||||
const saving = ref(false)
|
||||
const formRef = ref()
|
||||
|
||||
// 表单数据
|
||||
const formData = reactive({
|
||||
name: '',
|
||||
displayName: '',
|
||||
description: '',
|
||||
endpoint: '',
|
||||
parameters: '',
|
||||
enabled: true
|
||||
})
|
||||
|
||||
// 测试数据
|
||||
const testParams = ref('')
|
||||
const testResult = reactive({
|
||||
status: 'idle' as 'idle' | 'testing' | 'success' | 'error',
|
||||
error: '',
|
||||
response: null as any
|
||||
})
|
||||
|
||||
// 表单验证规则
|
||||
const formRules = {
|
||||
name: [
|
||||
{ required: true, message: '请输入工具名称', trigger: 'blur' }
|
||||
],
|
||||
endpoint: [
|
||||
{ required: true, message: '请输入端点地址', trigger: 'blur' }
|
||||
]
|
||||
}
|
||||
|
||||
// 获取参数数量
|
||||
const getParameterCount = (tool: Tool): number => {
|
||||
return Object.keys(tool.parameters || {}).length
|
||||
}
|
||||
|
||||
// 下拉菜单选项
|
||||
const getDropdownOptions = () => [
|
||||
{
|
||||
label: '编辑',
|
||||
key: 'edit',
|
||||
icon: () => h(NIcon, null, { default: () => h(EditIcon) })
|
||||
},
|
||||
{
|
||||
label: '测试',
|
||||
key: 'test',
|
||||
icon: () => h(NIcon, null, { default: () => h(TestIcon) })
|
||||
},
|
||||
{
|
||||
label: '复制配置',
|
||||
key: 'copy',
|
||||
icon: () => h(NIcon, null, { default: () => h(CopyIcon) })
|
||||
},
|
||||
{
|
||||
label: '删除',
|
||||
key: 'delete',
|
||||
icon: () => h(NIcon, null, { default: () => h(TrashIcon) })
|
||||
}
|
||||
]
|
||||
|
||||
// 处理下拉菜单选择
|
||||
const handleDropdownSelect = (key: string, tool: Tool) => {
|
||||
switch (key) {
|
||||
case 'edit':
|
||||
editTool(tool)
|
||||
break
|
||||
case 'test':
|
||||
testTool(tool)
|
||||
break
|
||||
case 'copy':
|
||||
copyToolConfig(tool)
|
||||
break
|
||||
case 'delete':
|
||||
deleteTool(tool)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// 切换工具启用状态
|
||||
const toggleTool = (tool: Tool) => {
|
||||
saveTools()
|
||||
message.success(`${tool.displayName || tool.name} 已${tool.enabled ? '启用' : '禁用'}`)
|
||||
}
|
||||
|
||||
// 编辑工具
|
||||
const editTool = (tool: Tool) => {
|
||||
editingTool.value = tool
|
||||
formData.name = tool.name
|
||||
formData.displayName = tool.displayName || ''
|
||||
formData.description = tool.description
|
||||
formData.endpoint = tool.endpoint
|
||||
formData.parameters = JSON.stringify(tool.parameters, null, 2)
|
||||
formData.enabled = tool.enabled
|
||||
showAddModal.value = true
|
||||
}
|
||||
|
||||
// 测试工具
|
||||
const testTool = (tool: Tool) => {
|
||||
editingTool.value = tool
|
||||
testParams.value = JSON.stringify({}, null, 2)
|
||||
testResult.status = 'idle'
|
||||
testResult.error = ''
|
||||
testResult.response = null
|
||||
showTestModal.value = true
|
||||
}
|
||||
|
||||
// 运行测试
|
||||
const runTest = async () => {
|
||||
if (!editingTool.value) return
|
||||
|
||||
testResult.status = 'testing'
|
||||
testResult.error = ''
|
||||
testResult.response = null
|
||||
|
||||
try {
|
||||
const params = JSON.parse(testParams.value)
|
||||
|
||||
const response = await fetch(editingTool.value.endpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(params)
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
testResult.status = 'success'
|
||||
testResult.response = data
|
||||
message.success('测试成功')
|
||||
} catch (error) {
|
||||
testResult.status = 'error'
|
||||
testResult.error = error instanceof Error ? error.message : '未知错误'
|
||||
message.error('测试失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 复制工具配置
|
||||
const copyToolConfig = (tool: Tool) => {
|
||||
const config = {
|
||||
name: tool.name,
|
||||
displayName: tool.displayName,
|
||||
description: tool.description,
|
||||
endpoint: tool.endpoint,
|
||||
parameters: tool.parameters
|
||||
}
|
||||
|
||||
navigator.clipboard.writeText(JSON.stringify(config, null, 2))
|
||||
.then(() => message.success('配置已复制到剪贴板'))
|
||||
.catch(() => message.error('复制失败'))
|
||||
}
|
||||
|
||||
// 删除工具
|
||||
const deleteTool = (tool: Tool) => {
|
||||
const index = tools.value.findIndex(t => t.id === tool.id)
|
||||
if (index !== -1) {
|
||||
tools.value.splice(index, 1)
|
||||
saveTools()
|
||||
message.success(`已删除工具 ${tool.displayName || tool.name}`)
|
||||
}
|
||||
}
|
||||
|
||||
// 保存工具
|
||||
const handleSaveTool = async () => {
|
||||
try {
|
||||
await formRef.value?.validate()
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
saving.value = true
|
||||
|
||||
try {
|
||||
let parameters = {}
|
||||
try {
|
||||
parameters = JSON.parse(formData.parameters || '{}')
|
||||
} catch {
|
||||
message.error('参数配置格式错误,请输入有效的 JSON')
|
||||
saving.value = false
|
||||
return
|
||||
}
|
||||
|
||||
const toolData: Tool = {
|
||||
id: editingTool.value?.id || generateId(),
|
||||
name: formData.name,
|
||||
displayName: formData.displayName,
|
||||
description: formData.description,
|
||||
endpoint: formData.endpoint,
|
||||
parameters,
|
||||
enabled: formData.enabled,
|
||||
updatedAt: new Date()
|
||||
}
|
||||
|
||||
if (editingTool.value) {
|
||||
// 更新现有工具
|
||||
const index = tools.value.findIndex(t => t.id === editingTool.value!.id)
|
||||
if (index !== -1) {
|
||||
tools.value[index] = toolData
|
||||
}
|
||||
message.success('工具已更新')
|
||||
} else {
|
||||
// 添加新工具
|
||||
toolData.createdAt = new Date()
|
||||
tools.value.push(toolData)
|
||||
message.success('工具已添加')
|
||||
}
|
||||
|
||||
saveTools()
|
||||
showAddModal.value = false
|
||||
resetForm()
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 重置表单
|
||||
const resetForm = () => {
|
||||
editingTool.value = null
|
||||
formData.name = ''
|
||||
formData.displayName = ''
|
||||
formData.description = ''
|
||||
formData.endpoint = ''
|
||||
formData.parameters = ''
|
||||
formData.enabled = true
|
||||
}
|
||||
|
||||
// 生成 ID
|
||||
const generateId = (): string => {
|
||||
return `tool_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`
|
||||
}
|
||||
|
||||
// 保存到本地存储
|
||||
const saveTools = () => {
|
||||
try {
|
||||
localStorage.setItem('mcp-tools', JSON.stringify(tools.value))
|
||||
} catch (error) {
|
||||
console.error('保存工具列表失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 从本地存储加载
|
||||
const loadTools = () => {
|
||||
try {
|
||||
const saved = localStorage.getItem('mcp-tools')
|
||||
if (saved) {
|
||||
tools.value = JSON.parse(saved)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载工具列表失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 生命周期
|
||||
onMounted(() => {
|
||||
loadTools()
|
||||
})
|
||||
|
||||
// h 函数导入
|
||||
import { h } from 'vue'
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.tools-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;
|
||||
}
|
||||
|
||||
.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 0 24px 0;
|
||||
}
|
||||
|
||||
.tools-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.tool-card {
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.tool-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.tool-icon {
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.tool-name {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.card-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.tool-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.tool-description {
|
||||
color: var(--text-color-2);
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.tool-meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.meta-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.meta-label {
|
||||
color: var(--text-color-3);
|
||||
min-width: 60px;
|
||||
}
|
||||
|
||||
.meta-value {
|
||||
color: var(--text-color-2);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.tool-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.test-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.test-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px;
|
||||
border-radius: 6px;
|
||||
background: var(--color-target-bg);
|
||||
}
|
||||
|
||||
.test-status.success {
|
||||
background: rgba(24, 160, 88, 0.1);
|
||||
}
|
||||
|
||||
.test-status.error {
|
||||
background: rgba(208, 48, 80, 0.1);
|
||||
color: #d03050;
|
||||
}
|
||||
|
||||
.test-response {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.test-response h4 {
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.test-response pre {
|
||||
background: var(--code-bg);
|
||||
padding: 12px;
|
||||
border-radius: 6px;
|
||||
overflow-x: auto;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user