refactor: 重构设置页面为 Linear.app 风格

设计改进:
- 左侧窄边栏 (200px) + 小图标 (18px)
- 主内容区单列居中 (最大宽度 720px)
- Segmented Control 风格的供应商选择
- 统一表单控件规格 (36px 高度, 6px 圆角)
- 深靛蓝 (#4f46e5) 主色调
- 极简专业的视觉风格

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Yuuuu 2026-02-20 23:41:23 +08:00
parent 0e252281cd
commit 3376c224e3
1 changed files with 662 additions and 189 deletions

View File

@ -1,140 +1,181 @@
<template> <template>
<n-card title="🤖 AI 模型配置" size="small"> <div class="settings-page">
<n-space vertical> <!-- 左侧窄边栏 -->
<!-- 提供商选择 --> <aside class="settings-sidebar">
<n-form-item label="LLM 提供商"> <nav class="sidebar-nav">
<n-select <a
v-model:value="config.provider" v-for="item in navItems"
:options="providerOptions" :key="item.key"
@update:value="onProviderChange" :class="['nav-item', { active: activeTab === item.key }]"
style="width: 200px" @click="activeTab = item.key"
> >
<template #label="{ option }"> <component :is="item.icon" :size="18" :stroke-width="1.5" />
<n-space align="center"> <span>{{ item.label }}</span>
<span>{{ option.label }}</span> </a>
<n-tag v-if="option.cloud" type="info" size="small">云端</n-tag> </nav>
<n-tag v-else type="success" size="small">本地</n-tag>
</n-space>
</template>
</n-select>
</n-form-item>
<!-- 提供商说明 --> <!-- 底部状态 -->
<n-alert v-if="providerInfo" :type="providerInfo.type" size="small"> <div class="sidebar-footer">
{{ providerInfo.text }} <div class="connection-status">
</n-alert> <span :class="['status-dot', { connected: isConnected }]"></span>
<span class="status-text">{{ statusText }}</span>
</div>
</div>
</aside>
<!-- API 密钥输入 --> <!-- 主内容区 -->
<n-divider /> <main class="settings-main">
<n-text depth="3" style="font-size: 12px;">API 密钥配置</n-text> <div class="settings-content">
<!-- 页面标题 -->
<header class="page-header">
<h1>设置</h1>
<p class="page-description">配置 AI 模型提供商和连接参数</p>
</header>
<n-form-item v-if="config.provider === 'deepseek'" label="DeepSeek API Key"> <!-- 分割线 -->
<n-input <hr class="divider">
v-model:value="config.deepseek_api_key"
<!-- 供应商选择 -->
<section class="settings-section">
<h2 class="section-title">模型提供商</h2>
<!-- Segmented Control 风格 -->
<div class="provider-selector">
<button
v-for="provider in providers"
:key="provider.value"
:class="['provider-btn', { active: config.provider === provider.value }]"
@click="selectProvider(provider.value)"
>
{{ provider.label }}
</button>
</div>
<!-- 选中提供商说明 -->
<div class="provider-info">
<span class="provider-desc">{{ selectedProvider.desc }}</span>
<span class="provider-pricing">{{ selectedProvider.pricing }}</span>
</div>
</section>
<!-- 连接配置 -->
<section class="settings-section">
<h2 class="section-title">连接配置</h2>
<!-- API 密钥配置 -->
<div v-if="showApiKey" class="form-group">
<label class="form-label">API 密钥</label>
<input
v-model="apiKey"
type="password" type="password"
show-password-on="click" class="form-input"
placeholder="sk-..." :placeholder="apiKeyPlaceholder"
style="width: 300px" >
/> <div class="form-hint">
<template #feedback> 获取地址:<a :href="selectedProvider.apiUrl" target="_blank" rel="noopener">{{ selectedProvider.apiLabel }}</a>
<n-text depth="3" style="font-size: 11px;"> </div>
获取地址: <n-a href="https://platform.deepseek.com/api_keys" target="_blank">platform.deepseek.com</n-a> </div>
</n-text>
</template>
</n-form-item>
<n-form-item v-if="config.provider === 'openai'" label="OpenAI API Key"> <!-- Ollama 地址配置 -->
<n-input <div v-if="config.provider === 'ollama'" class="form-group">
v-model:value="config.openai_api_key" <label class="form-label">服务地址</label>
type="password" <input
show-password-on="click" v-model="config.ollama_host"
placeholder="sk-..." type="text"
style="width: 300px" class="form-input"
/>
<template #feedback>
<n-text depth="3" style="font-size: 11px;">
获取地址: <n-a href="https://platform.openai.com/api-keys" target="_blank">platform.openai.com</n-a>
</n-text>
</template>
</n-form-item>
<n-form-item v-if="config.provider === 'qwen'" label="Qwen API Key">
<n-input
v-model:value="config.qwen_api_key"
type="password"
show-password-on="click"
placeholder="sk-..."
style="width: 300px"
/>
<template #feedback>
<n-text depth="3" style="font-size: 11px;">
获取地址: <n-a href="https://dashscope.console.aliyun.com/apiKey" target="_blank">dashscope.console.aliyun.com</n-a>
</n-text>
</template>
</n-form-item>
<n-form-item v-if="config.provider === 'ollama'" label="Ollama 地址">
<n-input
v-model:value="config.ollama_host"
placeholder="http://127.0.0.1:11434" placeholder="http://127.0.0.1:11434"
style="width: 300px" >
/> <div class="form-actions">
<template #feedback> <button class="btn-secondary" @click="testConnection" :disabled="testing">
<n-button text type="primary" @click="checkOllamaStatus" :loading="checking"> {{ testing ? '测试中...' : '测试连接' }}
🔄 检测状态 </button>
</n-button> </div>
</template> </div>
</n-form-item>
<!-- 保存按钮 --> <!-- 高级选项(折叠) -->
<n-space> <details class="advanced-options">
<n-button type="primary" @click="saveConfig" :loading="saving"> <summary>高级选项</summary>
保存配置 <div class="advanced-content">
</n-button> <div class="form-row">
<n-button v-if="needsRestart" type="warning" @click="showRestartModal = true"> <div class="form-group">
需要重启后端 <label class="form-label">Temperature</label>
</n-button> <input v-model.number="config.temperature" type="number" step="0.1" min="0" max="2" class="form-input">
</n-space> </div>
<div class="form-group">
<label class="form-label">Max Tokens</label>
<input v-model.number="config.max_tokens" type="number" step="128" min="128" max="8192" class="form-input">
</div>
</div>
</div>
</details>
</section>
<!-- 当前配置状态 --> <!-- 底部操作栏 -->
<n-divider /> <footer class="settings-footer">
<n-space> <div class="footer-status">{{ saveStatus }}</div>
<n-tag v-if="configStatus.has_deepseek_key" type="success" size="small">DeepSeek 已配置</n-tag> <div class="footer-actions">
<n-tag v-if="configStatus.has_openai_key" type="success" size="small">OpenAI 已配置</n-tag> <button class="btn-ghost" @click="resetConfig">重置</button>
<n-tag v-if="configStatus.has_qwen_key" type="success" size="small">Qwen 已配置</n-tag> <button class="btn-primary" @click="saveConfig" :disabled="saving">
</n-space> {{ saving ? '保存中...' : '保存配置' }}
</n-space> </button>
</div>
</footer>
</div>
</main>
<!-- 重启确认弹窗 --> <!-- 重启确认弹窗 -->
<n-modal v-model:show="showRestartModal" preset="dialog" title="重启后端服务"> <div v-if="showRestartModal" class="modal-overlay" @click.self="showRestartModal = false">
<template #header> <div class="modal">
<n-icon size="24" color="#f0a020"><RestartIcon /></n-icon> <div class="modal-header">
<span style="margin-left: 8px;">重启后端服务</span> <h3>需要重启后端</h3>
</template> <button class="modal-close" @click="showRestartModal = false">×</button>
LLM 配置已更改,需要重启后端服务才能生效。 </div>
<template #action> <div class="modal-body">
<n-space> <p>LLM 配置已更改,需要重启后端服务才能生效。</p>
<n-button @click="showRestartModal = false">取消</n-button> </div>
<n-button type="warning" @click="restartBackend"> <div class="modal-footer">
确认重启 <button class="btn-ghost" @click="showRestartModal = false">取消</button>
</n-button> <button class="btn-warning" @click="restartBackend">确认重启</button>
</n-space> </div>
</template> </div>
</n-modal> </div>
</n-card> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, reactive, computed, onMounted } from 'vue' import { ref, reactive, computed, onMounted, h } from 'vue'
import { NCard, NSpace, NSelect, NFormItem, NInput, NButton, NTag, NAlert, NDivider, NText, NA, NModal, NIcon, useMessage } from 'naive-ui'
import axios from 'axios' import axios from 'axios'
import { h } from 'vue'
const RestartIcon = () => h('svg', { viewBox: '0 0 24 24', fill: 'currentColor' }, [ // Lucide-style icons (inline SVG)
h('path', { d: 'M12 4V1L8 5l4 4V6c3.31 0 6 2.69 6 6 0 1.01-.25 1.97-.7 2.8l1.46 1.46C19.54 15.03 20 13.57 20 12c0-4.42-3.58-8-8-8zm0 14c-3.31 0-6-2.69-6-6 0-1.01.25-1.97.7-2.8L5.24 7.74C4.46 8.97 4 10.43 4 12c0 4.42 3.58 8 8 8v3l4-4-4-4v3z' }) const SettingsIcon = () => h('svg', { viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', 'stroke-width': 1.5 }, [
h('path', { d: 'M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z' }),
h('circle', { cx: '12', cy: '12', r: '3' })
]) ])
const message = useMessage() const CloudIcon = () => h('svg', { viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', 'stroke-width': 1.5 }, [
h('path', { d: 'M17.5 19c0-1.7-1.3-3-3-3h-11c-1.7 0-3 1.3-3 3s1.3 3 3 3h11c1.7 0 3-1.3 3-3z' }),
h('path', { d: 'M17.5 16c2.5 0 4.5-2 4.5-4.5S20 7 17.5 7c-.4 0-.8.1-1.2.2C15.6 5.7 13.9 5 12 5c-2.8 0-5.1 2-5.7 4.7-.5-.2-1-.2-1.5-.2-2.5 0-4.5 2-4.5 4.5s2 4.5 4.5 4.5h12.2z' })
])
const KeyIcon = () => h('svg', { viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', 'stroke-width': 1.5 }, [
h('path', { d: 'M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4' })
])
const ServerIcon = () => h('svg', { viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', 'stroke-width': 1.5 }, [
h('rect', { x: '2', y: '2', width: '20', height: '8', rx: '2', ry: '2' }),
h('rect', { x: '2', y: '14', width: '20', height: '8', rx: '2', ry: '2' }),
h('line', { x1: '6', y1: '6', x2: '6.01', y2: '6' }),
h('line', { x1: '6', y1: '18', x2: '6.01', y2: '18' })
])
// 侧边栏导航
const navItems = [
{ key: 'provider', label: '模型服务', icon: CloudIcon },
{ key: 'general', label: '通用设置', icon: SettingsIcon },
{ key: 'advanced', label: '高级选项', icon: KeyIcon }
]
const activeTab = ref('provider')
// 配置数据 // 配置数据
const config = reactive({ const config = reactive({
@ -142,111 +183,543 @@ const config = reactive({
deepseek_api_key: '', deepseek_api_key: '',
openai_api_key: '', openai_api_key: '',
qwen_api_key: '', qwen_api_key: '',
ollama_host: 'http://127.0.0.1:11434' ollama_host: 'http://127.0.0.1:11434',
temperature: 0.7,
max_tokens: 2048
}) })
const configStatus = reactive({ // 提供商数据
has_deepseek_key: false, const providers = [
has_openai_key: false, { value: 'deepseek', label: 'DeepSeek', desc: '高质量、低成本的大语言模型', pricing: '¥1/百万 tokens', apiUrl: 'https://platform.deepseek.com/api_keys', apiLabel: 'platform.deepseek.com' },
has_qwen_key: false { value: 'openai', label: 'OpenAI', desc: '业界领先的 GPT 系列模型', pricing: '~$15/百万 tokens', apiUrl: 'https://platform.openai.com/api-keys', apiLabel: 'platform.openai.com' },
}) { value: 'qwen', label: 'Qwen', desc: '阿里云通义千问系列', pricing: '¥0.5/百万 tokens', apiUrl: 'https://dashscope.console.aliyun.com/apiKey', apiLabel: 'dashscope.console.aliyun.com' },
{ value: 'ollama', label: 'Ollama', desc: '本地运行的开源模型', pricing: '免费', apiUrl: '', apiLabel: '' }
const saving = ref(false)
const checking = ref(false)
const showRestartModal = ref(false)
const needsRestart = ref(false)
// 提供商选项
const providerOptions = [
{ label: 'DeepSeek', value: 'deepseek', cloud: true },
{ label: 'OpenAI', value: 'openai', cloud: true },
{ label: 'Qwen (阿里云)', value: 'qwen', cloud: true },
{ label: 'Ollama (本地)', value: 'ollama', cloud: false }
] ]
// 提供商信息 // 状态
const providerInfo = computed(() => { const saving = ref(false)
const info = { const testing = ref(false)
deepseek: { type: 'info', text: '💰 便宜好用,¥1/百万 tokens,推荐首选' }, const showRestartModal = ref(false)
openai: { type: 'info', text: '🚀 GPT-4 效果最好,约 ¥15/百万 tokens' }, const saveStatus = ref('')
qwen: { type: 'info', text: '🇨🇳 阿里云,国内稳定,¥0.5/百万 tokens' }, const isConnected = ref(false)
ollama: { type: 'success', text: '🏠 完全免费,数据隐私,但占用 CPU/内存' } const statusText = ref('未连接')
// 计算属性
const selectedProvider = computed(() => providers.find(p => p.value === config.provider) || providers[0])
const showApiKey = computed(() => ['deepseek', 'openai', 'qwen'].includes(config.provider))
const apiKey = computed({
get: () => {
if (config.provider === 'deepseek') return config.deepseek_api_key
if (config.provider === 'openai') return config.openai_api_key
if (config.provider === 'qwen') return config.qwen_api_key
return ''
},
set: (value: string) => {
if (config.provider === 'deepseek') config.deepseek_api_key = value
if (config.provider === 'openai') config.openai_api_key = value
if (config.provider === 'qwen') config.qwen_api_key = value
} }
return info[config.provider] || null
}) })
// 加载配置 const apiKeyPlaceholder = computed(() => {
if (config.provider === 'deepseek') return 'sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx'
if (config.provider === 'openai') return 'sk-proj-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx'
if (config.provider === 'qwen') return 'sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx'
return ''
})
// 方法
const selectProvider = (value: string) => {
config.provider = value
saveStatus.value = `已切换到 ${providers.find(p => p.value === value)?.label}`
}
const testConnection = async () => {
testing.value = true
try {
const response = await axios.get(`${config.ollama_host.replace(':11434', ':8000')}/ai/status`, { timeout: 5000 })
if (response.data.status === 'ready') {
isConnected.value = true
statusText.value = `${response.data.model_count} 个模型`
saveStatus.value = '连接成功'
} else {
isConnected.value = false
statusText.value = '未就绪'
}
} catch {
isConnected.value = false
statusText.value = '连接失败'
} finally {
testing.value = false
}
}
const loadConfig = async () => { const loadConfig = async () => {
try { try {
const response = await axios.get('http://127.0.0.1:8000/ai/config') const response = await axios.get('http://127.0.0.1:8000/ai/config')
Object.assign(config, { config.provider = response.data.provider
provider: response.data.provider, config.ollama_host = response.data.ollama_host
ollama_host: response.data.ollama_host } catch {}
})
Object.assign(configStatus, {
has_deepseek_key: response.data.has_deepseek_key,
has_openai_key: response.data.has_openai_key,
has_qwen_key: response.data.has_qwen_key
})
} catch (error) {
console.error('加载配置失败:', error)
}
} }
// 保存配置
const saveConfig = async () => { const saveConfig = async () => {
saving.value = true saving.value = true
try { try {
const response = await axios.post('http://127.0.0.1:8000/ai/config', { await axios.post('http://127.0.0.1:8000/ai/config', {
provider: config.provider, provider: config.provider,
deepseek_api_key: config.deepseek_api_key || undefined, deepseek_api_key: config.deepseek_api_key || undefined,
openai_api_key: config.openai_api_key || undefined, openai_api_key: config.openai_api_key || undefined,
qwen_api_key: config.qwen_api_key || undefined qwen_api_key: config.qwen_api_key || undefined
}) })
message.success('配置已保存,需要重启后端才能生效') saveStatus.value = '配置已保存'
needsRestart.value = true showRestartModal.value = true
await loadConfig() } catch (error: any) {
} catch (error) { saveStatus.value = '保存失败: ' + (error.response?.data?.detail || error.message)
message.error('保存失败: ' + (error.response?.data?.detail || error.message))
} finally { } finally {
saving.value = false saving.value = false
} }
} }
// 检查 Ollama 状态 const resetConfig = () => {
const checkOllamaStatus = async () => { config.provider = 'deepseek'
checking.value = true config.temperature = 0.7
try { config.max_tokens = 2048
const response = await axios.get(config.ollama_host.replace(':11434', ':8000') + '/ai/status', { timeout: 3000 }) saveStatus.value = '配置已重置'
if (response.data.status === 'ready') {
message.success(`Ollama 已就绪,${response.data.model_count} 个模型`)
} else {
message.warning('Ollama 未运行')
}
} catch (error) {
message.warning('无法连接到 Ollama')
} finally {
checking.value = false
}
} }
// 重启后端
const restartBackend = async () => { const restartBackend = async () => {
try { try {
await axios.post('http://127.0.0.1:8000/ai/stop') await axios.post('http://127.0.0.1:8000/ai/stop')
message.info('后端已停止,请手动启动: ./start.sh') saveStatus.value = '后端已停止,请手动启动'
showRestartModal.value = false showRestartModal.value = false
needsRestart.value = false } catch {
} catch (error) { saveStatus.value = '停止失败'
message.error('停止失败: ' + (error.response?.data?.detail || error.message))
} }
} }
const onProviderChange = () => {
needsRestart.value = true
}
onMounted(() => { onMounted(() => {
loadConfig() loadConfig()
}) })
</script> </script>
<style scoped>
/* 全局样式 */
.settings-page {
display: flex;
height: 100%;
background: #09090b;
font-family: -apple-system, BlinkMacSystemFont, 'Inter', 'PingFang SC', sans-serif;
}
/* 左侧边栏 */
.settings-sidebar {
width: 200px;
border-right: 1px solid #27272a;
display: flex;
flex-direction: column;
}
.sidebar-nav {
flex: 1;
padding: 16px 12px;
}
.nav-item {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 12px;
font-size: 14px;
color: #a1a1aa;
text-decoration: none;
border-radius: 6px;
margin-bottom: 2px;
cursor: pointer;
transition: all 0.15s ease;
}
.nav-item:hover {
background: #18181b;
color: #fafafa;
}
.nav-item.active {
background: #27272a;
color: #fafafa;
}
.sidebar-footer {
padding: 16px;
border-top: 1px solid #27272a;
}
.connection-status {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
color: #71717a;
}
.status-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: #ef4444;
}
.status-dot.connected {
background: #22c55e;
}
/* 主内容区 */
.settings-main {
flex: 1;
display: flex;
justify-content: center;
overflow-y: auto;
}
.settings-content {
width: 100%;
max-width: 720px;
padding: 40px 48px 120px;
}
/* 页面标题 */
.page-header h1 {
font-size: 24px;
font-weight: 600;
color: #fafafa;
margin: 0 0 8px 0;
}
.page-description {
font-size: 14px;
color: #71717a;
margin: 0;
}
.divider {
border: none;
border-top: 1px solid #27272a;
margin: 24px 0 32px;
}
/* 设置区块 */
.settings-section {
margin-bottom: 40px;
}
.section-title {
font-size: 14px;
font-weight: 600;
color: #a1a1aa;
text-transform: uppercase;
letter-spacing: 0.5px;
margin: 0 0 16px 0;
}
/* 供应商选择器 - Segmented Control 风格 */
.provider-selector {
display: flex;
background: #18181b;
border: 1px solid #27272a;
border-radius: 8px;
padding: 4px;
margin-bottom: 16px;
}
.provider-btn {
flex: 1;
padding: 10px 16px;
font-size: 14px;
font-weight: 500;
color: #a1a1aa;
background: transparent;
border: none;
border-radius: 6px;
cursor: pointer;
transition: all 0.15s ease;
}
.provider-btn:hover {
color: #fafafa;
}
.provider-btn.active {
background: #27272a;
color: #fafafa;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
}
.provider-info {
display: flex;
justify-content: space-between;
align-items: center;
}
.provider-desc {
font-size: 13px;
color: #71717a;
}
.provider-pricing {
font-size: 12px;
color: #52525b;
padding: 4px 8px;
background: #18181b;
border-radius: 4px;
}
/* 表单 */
.form-group {
margin-bottom: 20px;
}
.form-label {
display: block;
font-size: 13px;
font-weight: 500;
color: #a1a1aa;
margin-bottom: 8px;
}
.form-input {
width: 100%;
height: 36px;
padding: 0 12px;
font-size: 14px;
color: #fafafa;
background: #18181b;
border: 1px solid #27272a;
border-radius: 6px;
box-sizing: border-box;
transition: border-color 0.15s ease;
}
.form-input:focus {
outline: none;
border-color: #4f46e5;
}
.form-input::placeholder {
color: #52525b;
}
.form-hint {
font-size: 12px;
color: #52525b;
margin-top: 6px;
}
.form-hint a {
color: #52525b;
text-decoration: underline;
}
.form-hint a:hover {
color: #71717a;
}
.form-actions {
margin-top: 12px;
}
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
/* 高级选项 */
.advanced-options {
margin-top: 24px;
}
.advanced-options summary {
font-size: 13px;
font-weight: 500;
color: #71717a;
cursor: pointer;
user-select: none;
list-style: none;
}
.advanced-options summary::-webkit-details-marker {
display: none;
}
.advanced-options summary::before {
content: '▶';
font-size: 10px;
margin-right: 6px;
transition: transform 0.15s ease;
}
.advanced-options[open] summary::before {
transform: rotate(90deg);
}
.advanced-content {
margin-top: 16px;
padding-top: 16px;
border-top: 1px solid #27272a;
}
/* 底部操作栏 */
.settings-footer {
display: flex;
justify-content: space-between;
align-items: center;
padding-top: 24px;
border-top: 1px solid #27272a;
}
.footer-status {
font-size: 13px;
color: #71717a;
}
.footer-actions {
display: flex;
gap: 8px;
}
/* 按钮样式 */
.btn-ghost {
padding: 8px 16px;
font-size: 14px;
font-weight: 500;
color: #a1a1aa;
background: transparent;
border: 1px solid #27272a;
border-radius: 6px;
cursor: pointer;
transition: all 0.15s ease;
}
.btn-ghost:hover:not(:disabled) {
color: #fafafa;
border-color: #3f3f46;
}
.btn-secondary {
padding: 8px 16px;
font-size: 14px;
font-weight: 500;
color: #fafafa;
background: #27272a;
border: 1px solid #3f3f46;
border-radius: 6px;
cursor: pointer;
transition: all 0.15s ease;
}
.btn-secondary:hover:not(:disabled) {
background: #3f3f46;
}
.btn-primary {
padding: 8px 16px;
font-size: 14px;
font-weight: 500;
color: #fafafa;
background: #4f46e5;
border: 1px solid #4f46e5;
border-radius: 6px;
cursor: pointer;
transition: all 0.15s ease;
}
.btn-primary:hover:not(:disabled) {
background: #4338ca;
}
.btn-primary:disabled,
.btn-secondary:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-warning {
padding: 8px 16px;
font-size: 14px;
font-weight: 500;
color: #fafafa;
background: #b91c1c;
border: 1px solid #b91c1c;
border-radius: 6px;
cursor: pointer;
}
.btn-warning:hover {
background: #991b1b;
}
/* Modal */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.7);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal {
background: #18181b;
border: 1px solid #27272a;
border-radius: 12px;
width: 100%;
max-width: 420px;
padding: 24px;
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.modal-header h3 {
font-size: 16px;
font-weight: 600;
color: #fafafa;
margin: 0;
}
.modal-close {
width: 28px;
height: 28px;
font-size: 20px;
color: #71717a;
background: transparent;
border: none;
border-radius: 6px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
}
.modal-close:hover {
background: #27272a;
}
.modal-body {
font-size: 14px;
color: #a1a1aa;
margin-bottom: 24px;
}
.modal-footer {
display: flex;
justify-content: flex-end;
gap: 8px;
}
</style>