Files
iFlow-Settings-Editor-GUI/src/App.vue

1469 lines
40 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="app">
<div class="titlebar">
<div class="titlebar-left">
<span class="titlebar-title">iFlow Settings Editor</span>
</div>
<div class="titlebar-controls">
<button class="titlebar-btn" @click="minimize" title="最小化">
<svg viewBox="0 0 10 1"><line x1="0" y1="0.5" x2="10" y2="0.5" /></svg>
</button>
<button class="titlebar-btn" @click="maximize" title="最大化">
<svg viewBox="0 0 10 10"><rect x="0.5" y="0.5" width="9" height="9" stroke-width="1" stroke="currentColor" fill="none" /></svg>
</button>
<button class="titlebar-btn close" @click="close" title="关闭">
<svg viewBox="0 0 10 10">
<line x1="0" y1="0" x2="10" y2="10" />
<line x1="10" y1="0" x2="0" y2="10" />
</svg>
</button>
</div>
</div>
<header class="header">
<div class="header-left">
<span class="header-title">iFlow 设置编辑器</span>
</div>
<div class="header-actions">
<button class="btn btn-secondary" @click="reloadSettings">
<Refresh size="14" />
重新加载
</button>
<button class="btn btn-primary" @click="saveSettings">
<Save size="14" />
保存更改
</button>
</div>
</header>
<main class="main">
<aside class="sidebar">
<div class="sidebar-section">
<div class="sidebar-title">常规</div>
<div class="nav-item" :class="{ active: currentSection === 'general' }" @click="showSection('general')">
<Config size="16" />
<span class="nav-item-text">基本设置</span>
</div>
<div class="nav-item" :class="{ active: currentSection === 'api' }" @click="showSection('api')">
<Key size="16" />
<span class="nav-item-text">API 配置</span>
</div>
</div>
<div class="sidebar-section">
<div class="sidebar-title">高级</div>
<div class="nav-item" :class="{ active: currentSection === 'mcp' }" @click="showSection('mcp')">
<Server size="16" />
<span class="nav-item-text">MCP 服务器</span>
<span class="nav-item-badge">{{ serverCount }}</span>
</div>
</div>
</aside>
<div class="content">
<section v-if="currentSection === 'general'">
<div class="content-header">
<h1 class="content-title">基本设置</h1>
<p class="content-desc">配置应用程序的常规选项</p>
</div>
<div class="card">
<div class="card-title">
<Globe size="16" />
语言与界面
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label">语言</label>
<select class="form-select" v-model="settings.language">
<option value="zh-CN">简体中文</option>
<option value="en-US">English</option>
<option value="ja-JP">日本語</option>
</select>
</div>
<div class="form-group">
<label class="form-label">主题</label>
<select class="form-select" v-model="settings.theme">
<option value="Xcode">Xcode</option>
<option value="Dark">深色</option>
<option value="Light">浅色</option>
<option value="Solarized Dark">Solarized Dark</option>
</select>
</div>
</div>
</div>
<div class="card">
<div class="card-title">
<Setting size="16" />
其他设置
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label">启动动画</label>
<select class="form-select" v-model="settings.bootAnimationShown">
<option :value="true">已显示</option>
<option :value="false">未显示</option>
</select>
</div>
<div class="form-group">
<label class="form-label">检查点保存</label>
<select class="form-select" v-model="settings.checkpointing.enabled">
<option :value="true">已启用</option>
<option :value="false">已禁用</option>
</select>
</div>
</div>
</div>
</section>
<section v-if="currentSection === 'api'">
<div class="content-header">
<h1 class="content-title">API 配置</h1>
<p class="content-desc">配置 AI 服务和搜索 API</p>
</div>
<div class="card">
<div class="card-title">
<Exchange size="16" />
配置文件管理
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label">当前配置</label>
<select class="form-select" v-model="currentApiProfile" @change="switchApiProfile">
<option v-for="profile in apiProfiles" :key="profile.name" :value="profile.name">{{ profile.name }}</option>
</select>
</div>
<div class="form-group">
<label class="form-label">&nbsp;</label>
<div class="btn-group">
<button class="btn btn-secondary" @click="createNewApiProfile">
<Add size="14" />
新建
</button>
<button class="btn btn-danger" @click="deleteApiProfile" :disabled="currentApiProfile === 'default'">
<Delete size="14" />
删除
</button>
</div>
</div>
</div>
</div>
<div class="card">
<div class="card-title">
<Robot size="16" />
AI 模型
</div>
<div class="form-group">
<label class="form-label">认证方式</label>
<select class="form-select" v-model="settings.selectedAuthType">
<option value="iflow">iFlow</option>
<option value="api">API Key</option>
<option value="openai-compatible">OpenAI 兼容</option>
</select>
</div>
<div class="form-group">
<label class="form-label">API Key</label>
<input type="password" class="form-input" v-model="settings.apiKey" placeholder="sk-cp-XXXXX..." />
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label">Base URL</label>
<input type="text" class="form-input" v-model="settings.baseUrl" placeholder="https://api.minimaxi.com/v1" />
</div>
<div class="form-group">
<label class="form-label">模型名称</label>
<input type="text" class="form-input" v-model="settings.modelName" placeholder="MiniMax-M2.7" />
</div>
</div>
</div>
<div class="card">
<div class="card-title">
<Search size="16" />
搜索服务
</div>
<div class="form-group">
<label class="form-label">搜索 API Key</label>
<input type="password" class="form-input" v-model="settings.searchApiKey" placeholder="sk-XXXXX..." />
</div>
<div class="form-group">
<label class="form-label">CNA</label>
<input type="text" class="form-input" v-model="settings.cna" placeholder="CNA 标识" />
</div>
</div>
</section>
<section v-if="currentSection === 'mcp'">
<div class="content-header">
<h1 class="content-title">MCP 服务器</h1>
<p class="content-desc">管理 Model Context Protocol 服务器配置</p>
</div>
<div class="form-group">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px">
<label class="form-label" style="margin: 0">服务器列表</label>
<button class="btn btn-primary" @click="addServer" style="padding: 6px 12px; font-size: 12px">
<Add size="12" />
添加服务器
</button>
</div>
<div class="server-list">
<template v-if="serverCount > 0">
<div v-for="(config, name) in settings.mcpServers" :key="name" class="server-item" :class="{ selected: currentServerName === name }" @click="selectServer(name)">
<div class="server-info">
<div class="server-name">{{ name }}</div>
<div class="server-desc">{{ config.description || '无描述' }}</div>
</div>
<div class="server-status"></div>
</div>
</template>
<div v-else class="empty-state">
<Server size="48" class="empty-state-icon" />
<div class="empty-state-title">暂无 MCP 服务器</div>
<div class="empty-state-desc">点击上方按钮添加第一个服务器</div>
</div>
</div>
</div>
</section>
</div>
</main>
<footer class="footer">
<div class="footer-status">
<div class="footer-status-dot"></div>
<span>配置: {{ currentApiProfile || 'default' }}</span>
</div>
<span :class="{ 'footer-modified': modified }">{{ modified ? '● 已修改' : '✓ 未修改' }}</span>
</footer>
<!-- Input Dialog -->
<div v-if="showInputDialog.show" class="dialog-overlay dialog-overlay-top" @click.self="closeInputDialog(false)">
<div class="dialog">
<div class="dialog-title">{{ showInputDialog.title }}</div>
<div v-if="showInputDialog.isConfirm" class="dialog-confirm-text">{{ showInputDialog.placeholder }}</div>
<input v-else type="text" class="form-input" v-model="inputDialogValue" :placeholder="showInputDialog.placeholder" @keyup.enter="closeInputDialog(true)" autofocus />
<div class="dialog-actions">
<button class="btn btn-secondary" @click="closeInputDialog(false)">取消</button>
<button class="btn btn-primary" @click="closeInputDialog(true)">确定</button>
</div>
</div>
</div>
<!-- Server Side Panel -->
<div v-if="showServerPanel" class="side-panel-overlay" @click.self="closeServerPanel" @keyup.esc="closeServerPanel" tabindex="-1" ref="serverPanelOverlay">
<div class="side-panel" @click.stop>
<div class="side-panel-header">
<div class="side-panel-title">
<Server size="18" />
{{ isEditingServer ? '编辑服务器' : '添加服务器' }}
</div>
<button class="side-panel-close" @click="closeServerPanel">
<svg viewBox="0 0 10 10">
<line x1="0" y1="0" x2="10" y2="10" />
<line x1="10" y1="0" x2="0" y2="10" />
</svg>
</button>
</div>
<div class="side-panel-body">
<div class="form-group">
<label class="form-label">服务器名称 <span class="form-required">*</span></label>
<input type="text" class="form-input" v-model="editingServerData.name" placeholder="my-mcp-server" />
</div>
<div class="form-group">
<label class="form-label">描述</label>
<input type="text" class="form-input" v-model="editingServerData.description" placeholder="服务器描述信息" />
</div>
<div class="form-group">
<label class="form-label">命令 <span class="form-required">*</span></label>
<input type="text" class="form-input" v-model="editingServerData.command" placeholder="npx" />
</div>
<div class="form-group">
<label class="form-label">工作目录</label>
<input type="text" class="form-input" v-model="editingServerData.cwd" placeholder="." />
</div>
<div class="form-group">
<label class="form-label">参数 (每行一个)</label>
<textarea class="form-textarea" v-model="editingServerData.args" rows="4" placeholder="-y&#10;package-name"></textarea>
</div>
<div class="form-group">
<label class="form-label">环境变量 (JSON 格式)</label>
<textarea class="form-textarea" v-model="editingServerData.env" rows="3" placeholder='{"API_KEY": "xxx"}'></textarea>
</div>
</div>
<div class="side-panel-footer">
<button v-if="isEditingServer" class="btn btn-danger" @click="deleteServer">
<Delete size="14" />
删除
</button>
<div class="side-panel-footer-right">
<button class="btn btn-secondary" @click="closeServerPanel">取消</button>
<button class="btn btn-primary" @click="saveServerFromPanel">
<Save size="14" />
{{ isEditingServer ? '保存更改' : '添加服务器' }}
</button>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, reactive, computed, onMounted, watch, nextTick } from 'vue'
import { Refresh, Save, Config, Key, Server, Globe, Setting, Robot, Search, Add, Edit, Delete, Exchange } from '@icon-park/vue-next'
const settings = ref({
language: 'zh-CN',
theme: 'Xcode',
bootAnimationShown: true,
checkpointing: { enabled: true },
mcpServers: {},
selectedAuthType: 'iflow',
apiKey: '',
baseUrl: '',
modelName: '',
searchApiKey: '',
cna: '',
currentApiProfile: 'default',
apiProfiles: { default: {} },
})
const originalSettings = ref({})
const modified = ref(false)
const currentSection = ref('general')
const currentServerName = ref(null)
const isLoading = ref(true)
const apiProfiles = ref([])
const currentApiProfile = ref('default')
const showInputDialog = ref({ show: false, title: '', placeholder: '', callback: null })
const inputDialogValue = ref('')
const showServerPanel = ref(false)
const isEditingServer = ref(false)
const editingServerData = ref({
name: '',
description: '',
command: 'npx',
cwd: '.',
args: '',
env: ''
})
// Load API profiles list
const loadApiProfiles = async () => {
const result = await window.electronAPI.listApiProfiles()
if (result.success) {
// 确保至少有 default 配置
if (!result.profiles || result.profiles.length === 0) {
apiProfiles.value = [{ name: 'default', isDefault: true }]
} else {
apiProfiles.value = result.profiles
}
currentApiProfile.value = result.currentProfile || 'default'
}
}
// Switch API profile
const switchApiProfile = async () => {
if (modified.value) {
const confirmed = await new Promise(resolve => {
showInputDialog.value = { show: true, title: '切换配置', placeholder: '当前有未保存的更改,切换配置将丢失这些更改,确定要切换吗?', callback: resolve, isConfirm: true }
})
if (!confirmed) {
// 恢复到之前的值
const result = await window.electronAPI.listApiProfiles()
if (result.success) {
currentApiProfile.value = result.currentProfile
}
return
}
}
const result = await window.electronAPI.switchApiProfile(currentApiProfile.value)
if (result.success) {
const data = JSON.parse(JSON.stringify(result.data))
if (!data.checkpointing) data.checkpointing = { enabled: true }
if (!data.mcpServers) data.mcpServers = {}
settings.value = data
originalSettings.value = JSON.parse(JSON.stringify(data))
modified.value = false
} else {
await window.electronAPI.showMessage({ type: 'error', title: '切换失败', message: result.error })
}
}
// Create new API profile
const createNewApiProfile = async () => {
const name = await new Promise(resolve => {
showInputDialog.value = { show: true, title: '新建配置', placeholder: '请输入配置名称', callback: resolve }
})
if (!name) return
const result = await window.electronAPI.createApiProfile(name)
if (result.success) {
await loadApiProfiles()
await window.electronAPI.showMessage({ type: 'info', title: '创建成功', message: `配置 "${name}" 已创建` })
} else {
await window.electronAPI.showMessage({ type: 'error', title: '创建失败', message: result.error })
}
}
// Delete API profile
const deleteApiProfile = async () => {
if (currentApiProfile.value === 'default') {
await window.electronAPI.showMessage({ type: 'warning', title: '无法删除', message: '不能删除默认配置' })
return
}
const confirmed = await new Promise(resolve => {
showInputDialog.value = { show: true, title: '删除配置', placeholder: `确定要删除配置 "${currentApiProfile.value}" 吗?`, callback: resolve, isConfirm: true }
})
if (!confirmed) return
const result = await window.electronAPI.deleteApiProfile(currentApiProfile.value)
if (result.success) {
const data = JSON.parse(JSON.stringify(result.data))
if (!data.checkpointing) data.checkpointing = { enabled: true }
if (!data.mcpServers) data.mcpServers = {}
settings.value = data
originalSettings.value = JSON.parse(JSON.stringify(data))
modified.value = false
await loadApiProfiles()
await window.electronAPI.showMessage({ type: 'info', title: '删除成功', message: `配置已删除` })
} else {
await window.electronAPI.showMessage({ type: 'error', title: '删除失败', message: result.error })
}
}
const loadSettings = async () => {
const result = await window.electronAPI.loadSettings()
if (result.success) {
const data = JSON.parse(JSON.stringify(result.data))
if (!data.checkpointing) data.checkpointing = { enabled: true }
if (!data.mcpServers) data.mcpServers = {}
// 确保 API 相关字段有默认值
if (data.selectedAuthType === undefined) data.selectedAuthType = 'iflow'
if (data.apiKey === undefined) data.apiKey = ''
if (data.baseUrl === undefined) data.baseUrl = ''
if (data.modelName === undefined) data.modelName = ''
if (data.searchApiKey === undefined) data.searchApiKey = ''
if (data.cna === undefined) data.cna = ''
if (!data.apiProfiles) data.apiProfiles = { default: {} }
if (!data.currentApiProfile) data.currentApiProfile = 'default'
settings.value = data
originalSettings.value = JSON.parse(JSON.stringify(data))
modified.value = false
}
isLoading.value = false
}
const saveSettings = async () => {
const dataToSave = JSON.parse(JSON.stringify(settings.value))
const result = await window.electronAPI.saveSettings(dataToSave)
if (result.success) {
originalSettings.value = JSON.parse(JSON.stringify(settings.value))
modified.value = false
await window.electronAPI.showMessage({ type: 'info', title: '保存成功', message: '设置已保存到 settings.json' })
} else {
await window.electronAPI.showMessage({ type: 'error', title: '保存失败', message: `无法保存设置: ${result.error}` })
}
}
const reloadSettings = async () => {
if (modified.value) {
const confirmed = await new Promise(resolve => {
showInputDialog.value = { show: true, title: '重新加载', placeholder: '当前有未保存的更改,确定要重新加载吗?', callback: resolve, isConfirm: true }
})
if (!confirmed) return
}
currentServerName.value = null
await loadSettings()
}
watch(
settings,
() => {
modified.value = true
},
{ deep: true },
)
const showSection = section => {
currentSection.value = section
}
const serverCount = computed(() => (settings.value.mcpServers ? Object.keys(settings.value.mcpServers).length : 0))
const selectServer = name => {
currentServerName.value = name
openEditServerPanel(name)
}
const serverPanelOverlay = ref(null)
const openAddServerPanel = () => {
isEditingServer.value = false
editingServerData.value = {
name: '',
description: '',
command: 'npx',
cwd: '.',
args: '-y\npackage-name',
env: ''
}
showServerPanel.value = true
nextTick(() => {
serverPanelOverlay.value?.focus()
})
}
const openEditServerPanel = (name) => {
const server = settings.value.mcpServers[name]
if (!server) return
isEditingServer.value = true
editingServerData.value = {
name: name,
description: server.description || '',
command: server.command || '',
cwd: server.cwd || '.',
args: (server.args || []).join('\n'),
env: server.env ? JSON.stringify(server.env, null, 2) : ''
}
showServerPanel.value = true
nextTick(() => {
serverPanelOverlay.value?.focus()
})
}
const closeServerPanel = () => {
showServerPanel.value = false
}
const saveServerFromPanel = async () => {
const name = editingServerData.value.name.trim()
if (!name) {
await window.electronAPI.showMessage({ type: 'warning', title: '错误', message: '请输入服务器名称' })
return
}
if (!isEditingServer.value && settings.value.mcpServers[name]) {
await window.electronAPI.showMessage({ type: 'warning', title: '错误', message: '服务器名称已存在' })
return
}
// 如果是编辑模式且名称改变了,需要删除旧的服务器
if (isEditingServer.value && currentServerName.value && currentServerName.value !== name) {
delete settings.value.mcpServers[currentServerName.value]
}
const serverConfig = {
command: editingServerData.value.command.trim(),
description: editingServerData.value.description.trim(),
cwd: editingServerData.value.cwd.trim() || '.',
args: editingServerData.value.args.split('\n').map(s => s.trim()).filter(s => s)
}
const envText = editingServerData.value.env.trim()
if (envText) {
try {
serverConfig.env = JSON.parse(envText)
} catch (e) {
await window.electronAPI.showMessage({ type: 'error', title: '错误', message: '环境变量 JSON 格式错误' })
return
}
}
settings.value.mcpServers[name] = serverConfig
currentServerName.value = name
showServerPanel.value = false
}
const addServer = async () => {
openAddServerPanel()
}
const deleteServer = async () => {
const serverName = isEditingServer.value ? editingServerData.value.name : currentServerName.value
if (!serverName) return
const confirmed = await new Promise(resolve => {
showInputDialog.value = { show: true, title: '删除服务器', placeholder: `确定要删除服务器 "${serverName}" 吗?`, callback: resolve, isConfirm: true }
})
if (!confirmed) return
delete settings.value.mcpServers[serverName]
currentServerName.value = null
showServerPanel.value = false
}
const currentServer = computed(() => {
if (!currentServerName.value || !settings.value.mcpServers) return null
return settings.value.mcpServers[currentServerName.value]
})
const minimize = () => window.electronAPI.minimize()
const maximize = () => window.electronAPI.maximize()
const close = () => window.electronAPI.close()
const closeInputDialog = result => {
if (showInputDialog.value.callback) {
// 如果是确认对话框,传递 resulttrue/false
// 如果是输入对话框,点击确定传递输入值,点击取消传递 false
if (showInputDialog.value.isConfirm) {
showInputDialog.value.callback(result)
} else {
showInputDialog.value.callback(result ? inputDialogValue.value : false)
}
}
showInputDialog.value.show = false
showInputDialog.value.isConfirm = false
inputDialogValue.value = ''
}
onMounted(async () => {
await loadApiProfiles()
await loadSettings()
})
</script>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
:root {
--bg-primary: #f8fafc;
--bg-secondary: #ffffff;
--bg-tertiary: #f1f5f9;
--bg-hover: #e2e8f0;
--text-primary: #0f172a;
--text-secondary: #475569;
--text-tertiary: #94a3b8;
--accent: #3b82f6;
--accent-hover: #2563eb;
--accent-light: #eff6ff;
--border: #e2e8f0;
--border-light: #f1f5f9;
--success: #10b981;
--danger: #ef4444;
--radius: 6px;
--radius-lg: 10px;
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.04);
--shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.06), 0 2px 4px -2px rgba(0, 0, 0, 0.04);
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.08), 0 4px 6px -4px rgba(0, 0, 0, 0.04);
}
/* Animations */
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateX(-10px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
@keyframes pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.6;
}
}
@keyframes shimmer {
0% {
background-position: -200% 0;
}
100% {
background-position: 200% 0;
}
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
background: var(--bg-primary);
color: var(--text-primary);
height: 100vh;
overflow: hidden;
user-select: none;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* Scrollbar Styles */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: transparent;
border-radius: 4px;
}
::-webkit-scrollbar-thumb {
background: var(--border);
border-radius: 4px;
transition: background 0.2s ease;
}
::-webkit-scrollbar-thumb:hover {
background: var(--text-tertiary);
}
::-webkit-scrollbar-corner {
background: transparent;
}
* {
scrollbar-width: thin;
scrollbar-color: var(--border) transparent;
}
.app {
display: flex;
flex-direction: column;
height: 100vh;
}
/* Titlebar */
.titlebar {
display: flex;
align-items: center;
justify-content: space-between;
height: 36px;
background: var(--bg-secondary);
border-bottom: 1px solid var(--border);
-webkit-app-region: drag;
}
.titlebar-left {
display: flex;
align-items: center;
padding-left: 16px;
gap: 10px;
}
.titlebar-icon {
width: 18px;
height: 18px;
background: linear-gradient(135deg, #3b82f6 0%, #8b5cf6 50%, #ec4899 100%);
border-radius: 5px;
box-shadow: 0 2px 4px rgba(59, 130, 246, 0.3);
}
.titlebar-title {
font-size: 13px;
font-weight: 500;
color: var(--text-secondary);
letter-spacing: -0.01em;
}
.titlebar-controls {
display: flex;
-webkit-app-region: no-drag;
}
.titlebar-btn {
width: 48px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
border: none;
background: transparent;
color: var(--text-secondary);
cursor: pointer;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}
.titlebar-btn:hover {
background: var(--bg-hover);
color: var(--text-primary);
transform: scale(1.05);
}
.titlebar-btn:active {
transform: scale(0.95);
}
.titlebar-btn.close:hover {
background: var(--danger);
color: white;
}
.titlebar-btn svg {
width: 12px;
height: 12px;
stroke: currentColor;
stroke-width: 1.5;
fill: none;
}
/* Header */
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 24px;
height: 60px;
background: var(--bg-secondary);
border-bottom: 1px solid var(--border);
}
.header-left {
display: flex;
align-items: center;
gap: 14px;
}
.header-icon {
width: 28px;
height: 28px;
background: linear-gradient(135deg, #3b82f6 0%, #8b5cf6 50%, #ec4899 100%);
border-radius: 8px;
box-shadow: 0 4px 8px rgba(59, 130, 246, 0.25);
}
.header-title {
font-size: 15px;
font-weight: 600;
letter-spacing: -0.02em;
}
.header-subtitle {
font-size: 13px;
color: var(--text-tertiary);
font-weight: 400;
}
.header-actions {
display: flex;
gap: 10px;
}
/* Buttons */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 7px;
padding: 9px 18px;
border: none;
border-radius: var(--radius);
font-family: inherit;
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
letter-spacing: -0.01em;
position: relative;
overflow: hidden;
}
.btn::after {
content: '';
position: absolute;
top: 50%;
left: 50%;
width: 0;
height: 0;
background: rgba(255, 255, 255, 0.2);
border-radius: 50%;
transform: translate(-50%, -50%);
transition:
width 0.4s ease,
height 0.4s ease;
}
.btn:active::after {
width: 200px;
height: 200px;
}
.btn-primary {
background: var(--accent);
color: white;
box-shadow: 0 2px 4px rgba(59, 130, 246, 0.3);
}
.btn-primary:hover {
background: var(--accent-hover);
transform: translateY(-1px);
box-shadow: 0 4px 8px rgba(59, 130, 246, 0.4);
}
.btn-primary:active {
transform: translateY(0) scale(0.98);
}
.btn-secondary {
background: var(--bg-secondary);
color: var(--text-secondary);
border: 1px solid var(--border);
}
.btn-secondary:hover {
background: var(--bg-tertiary);
color: var(--text-primary);
border-color: var(--text-tertiary);
}
.btn-secondary:active {
transform: scale(0.98);
}
.btn-danger {
background: var(--danger);
color: white;
box-shadow: 0 2px 4px rgba(239, 68, 68, 0.3);
}
.btn-danger:hover {
background: #dc2626;
transform: translateY(-1px);
}
.btn-danger:active {
transform: translateY(0) scale(0.98);
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
transform: none !important;
}
.btn-group {
display: flex;
gap: 10px;
}
/* Main Layout */
.main {
display: flex;
flex: 1;
overflow: hidden;
}
/* Sidebar */
.sidebar {
width: 220px;
background: var(--bg-secondary);
border-right: 1px solid var(--border);
padding: 12px;
display: flex;
flex-direction: column;
}
.sidebar-section {
margin-bottom: 20px;
}
.sidebar-title {
font-size: 11px;
font-weight: 600;
color: var(--text-tertiary);
text-transform: uppercase;
letter-spacing: 0.08em;
padding: 6px 10px;
margin-bottom: 4px;
}
.nav-item {
display: flex;
align-items: center;
gap: 10px;
padding: 10px;
border-radius: var(--radius);
cursor: pointer;
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
color: var(--text-secondary);
margin-bottom: 2px;
position: relative;
}
.nav-item::before {
content: '';
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 0;
background: var(--accent);
border-radius: var(--radius) 0 0 var(--radius);
transition: width 0.2s ease;
}
.nav-item:hover {
background: var(--bg-tertiary);
color: var(--text-primary);
}
.nav-item.active {
background: var(--accent-light);
color: var(--accent);
font-weight: 500;
animation: slideIn 0.3s ease;
}
.nav-item.active::before {
width: 3px;
}
.nav-item-text {
font-size: 13px;
letter-spacing: -0.01em;
}
.nav-item-badge {
margin-left: auto;
background: var(--bg-tertiary);
padding: 2px 7px;
border-radius: 12px;
font-size: 11px;
color: var(--text-tertiary);
font-weight: 500;
}
.nav-item.active .nav-item-badge {
background: var(--accent);
color: white;
}
/* Content */
.content {
flex: 1;
padding: 28px 32px;
overflow-y: auto;
background: var(--bg-primary);
}
.content section {
animation: fadeIn 0.35s ease;
}
.content-header {
margin-bottom: 28px;
}
.content-title {
font-size: 22px;
font-weight: 600;
margin-bottom: 6px;
letter-spacing: -0.03em;
animation: slideIn 0.3s ease;
}
.content-desc {
font-size: 14px;
color: var(--text-tertiary);
animation: fadeIn 0.4s ease 0.1s backwards;
}
/* Cards */
.card {
background: var(--bg-secondary);
border-radius: var(--radius-lg);
border: 1px solid var(--border);
padding: 20px 24px;
margin-bottom: 20px;
box-shadow: var(--shadow-sm);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
animation: fadeIn 0.4s ease backwards;
}
.card:nth-child(1) {
animation-delay: 0.05s;
}
.card:nth-child(2) {
animation-delay: 0.1s;
}
.card:nth-child(3) {
animation-delay: 0.15s;
}
.card:hover {
box-shadow: var(--shadow);
transform: translateY(-1px);
}
.card-title {
font-size: 14px;
font-weight: 600;
margin-bottom: 18px;
display: flex;
align-items: center;
gap: 10px;
color: var(--text-primary);
letter-spacing: -0.01em;
}
.card-title .iconpark-icon {
color: var(--accent);
}
/* Form Elements */
.form-group {
margin-bottom: 18px;
}
.form-group:last-child {
margin-bottom: 0;
}
.form-label {
display: block;
font-size: 13px;
font-weight: 500;
margin-bottom: 8px;
color: var(--text-secondary);
letter-spacing: -0.01em;
}
.form-input {
width: 100%;
padding: 10px 14px;
border: 1px solid var(--border);
border-radius: var(--radius);
font-family: 'SF Mono', 'Cascadia Code', 'Consolas', monospace;
font-size: 13px;
background: var(--bg-secondary);
color: var(--text-primary);
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
letter-spacing: -0.01em;
}
.form-input:hover {
border-color: var(--text-tertiary);
}
.form-input:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-light);
transform: translateY(-1px);
}
.form-input::placeholder {
color: var(--text-tertiary);
}
.form-row {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 20px;
}
.form-select {
width: 100%;
padding: 10px 14px;
border: 1px solid var(--border);
border-radius: var(--radius);
font-family: inherit;
font-size: 13px;
font-weight: 400;
background: var(--bg-secondary);
color: var(--text-primary);
cursor: pointer;
appearance: none;
letter-spacing: -0.01em;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%2394a3b8' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 12px center;
padding-right: 40px;
transition: all 0.2s ease;
position: relative;
}
.form-select:hover {
border-color: var(--text-tertiary);
background-color: var(--bg-tertiary);
}
.form-select:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-light);
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%233b82f6' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E");
}
.form-textarea {
width: 100%;
padding: 10px 14px;
border: 1px solid var(--border);
border-radius: var(--radius);
font-family: 'SF Mono', 'Cascadia Code', 'Consolas', monospace;
font-size: 13px;
background: var(--bg-secondary);
color: var(--text-primary);
resize: vertical;
min-height: 80px;
line-height: 1.5;
transition: all 0.2s ease;
}
.form-textarea:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-light);
}
/* Server List */
.server-list {
border: 1px solid var(--border);
border-radius: var(--radius-lg);
overflow: hidden;
background: var(--bg-secondary);
}
.server-item {
display: flex;
align-items: center;
padding: 14px 18px;
border-bottom: 1px solid var(--border-light);
cursor: pointer;
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
animation: fadeIn 0.3s ease backwards;
}
.server-item:nth-child(1) {
animation-delay: 0.02s;
}
.server-item:nth-child(2) {
animation-delay: 0.04s;
}
.server-item:nth-child(3) {
animation-delay: 0.06s;
}
.server-item:nth-child(4) {
animation-delay: 0.08s;
}
.server-item:nth-child(5) {
animation-delay: 0.1s;
}
.server-item:nth-child(6) {
animation-delay: 0.12s;
}
.server-item:nth-child(7) {
animation-delay: 0.14s;
}
.server-item:nth-child(8) {
animation-delay: 0.16s;
}
.server-item:last-child {
border-bottom: none;
}
.server-item:hover {
background: var(--bg-tertiary);
transform: translateX(4px);
}
.server-item.selected {
background: var(--accent-light);
border-left: 3px solid var(--accent);
padding-left: 15px;
}
.server-info {
flex: 1;
min-width: 0;
}
.server-name {
font-size: 13px;
font-weight: 500;
letter-spacing: -0.01em;
}
.server-desc {
font-size: 12px;
color: var(--text-tertiary);
margin-top: 3px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.server-status {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--success);
box-shadow: 0 0 6px rgba(16, 185, 129, 0.5);
animation: pulse 2s ease-in-out infinite;
}
/* Empty State */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 48px 24px;
text-align: center;
background: var(--bg-tertiary);
border-radius: var(--radius-lg);
}
.empty-state-icon {
font-size: 48px;
margin-bottom: 16px;
opacity: 0.3;
color: var(--text-tertiary);
}
.empty-state-title {
font-size: 15px;
font-weight: 500;
margin-bottom: 6px;
color: var(--text-secondary);
}
.empty-state-desc {
font-size: 13px;
color: var(--text-tertiary);
margin-bottom: 20px;
}
/* Footer */
.footer {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 24px;
background: var(--bg-secondary);
border-top: 1px solid var(--border);
font-size: 12px;
color: var(--text-tertiary);
}
.footer-status {
display: flex;
align-items: center;
gap: 8px;
}
.footer-status-dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--success);
box-shadow: 0 0 6px rgba(16, 185, 129, 0.5);
}
.footer-modified {
color: var(--accent);
font-weight: 500;
}
/* Dialog */
.dialog-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(15, 23, 42, 0.6);
backdrop-filter: blur(4px);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
animation: fadeIn 0.15s ease;
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.dialog {
background: var(--bg-secondary);
border-radius: var(--radius-lg);
padding: 24px;
min-width: 360px;
max-width: 480px;
box-shadow: var(--shadow-lg);
animation: slideUp 0.2s ease;
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.dialog-title {
font-size: 15px;
font-weight: 600;
margin-bottom: 18px;
letter-spacing: -0.01em;
}
.dialog-confirm-text {
font-size: 14px;
color: var(--text-secondary);
margin-bottom: 8px;
line-height: 1.5;
}
.dialog-actions {
display: flex;
justify-content: flex-end;
gap: 10px;
margin-top: 22px;
}
.dialog-overlay-top {
z-index: 1100;
}
.iconpark-icon {
display: inline-flex;
align-items: center;
justify-content: center;
vertical-align: -0.125em;
flex-shrink: 0;
}
.iconpark-icon svg {
display: block;
}
/* Side Panel */
.side-panel-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(15, 23, 42, 0.5);
backdrop-filter: blur(2px);
z-index: 1000;
animation: fadeIn 0.2s ease;
}
.side-panel {
position: absolute;
right: 0;
top: 0;
bottom: 0;
width: 420px;
max-width: 100%;
background: var(--bg-secondary);
box-shadow: -4px 0 24px rgba(0, 0, 0, 0.15);
display: flex;
flex-direction: column;
animation: slideInRight 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
@keyframes slideInRight {
from {
transform: translateX(100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
.side-panel-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 20px 24px;
border-bottom: 1px solid var(--border);
background: var(--bg-tertiary);
}
.side-panel-title {
font-size: 15px;
font-weight: 600;
display: flex;
align-items: center;
gap: 10px;
color: var(--text-primary);
letter-spacing: -0.01em;
}
.side-panel-title .iconpark-icon {
color: var(--accent);
}
.side-panel-close {
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
border: none;
background: transparent;
color: var(--text-tertiary);
cursor: pointer;
border-radius: var(--radius);
transition: all 0.2s ease;
}
.side-panel-close:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.side-panel-close svg {
width: 14px;
height: 14px;
stroke: currentColor;
stroke-width: 1.5;
fill: none;
}
.side-panel-body {
flex: 1;
padding: 24px;
overflow-y: auto;
}
.side-panel-body .form-group {
margin-bottom: 20px;
}
.side-panel-body .form-group:last-child {
margin-bottom: 0;
}
.form-required {
color: var(--danger);
font-weight: 500;
}
.side-panel-footer {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 24px;
border-top: 1px solid var(--border);
background: var(--bg-tertiary);
}
.side-panel-footer-right {
display: flex;
gap: 10px;
}
</style>