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:
parent
0e252281cd
commit
3376c224e3
|
|
@ -1,140 +1,181 @@
|
|||
<template>
|
||||
<n-card title="🤖 AI 模型配置" size="small">
|
||||
<n-space vertical>
|
||||
<!-- 提供商选择 -->
|
||||
<n-form-item label="LLM 提供商">
|
||||
<n-select
|
||||
v-model:value="config.provider"
|
||||
:options="providerOptions"
|
||||
@update:value="onProviderChange"
|
||||
style="width: 200px"
|
||||
<div class="settings-page">
|
||||
<!-- 左侧窄边栏 -->
|
||||
<aside class="settings-sidebar">
|
||||
<nav class="sidebar-nav">
|
||||
<a
|
||||
v-for="item in navItems"
|
||||
:key="item.key"
|
||||
:class="['nav-item', { active: activeTab === item.key }]"
|
||||
@click="activeTab = item.key"
|
||||
>
|
||||
<template #label="{ option }">
|
||||
<n-space align="center">
|
||||
<span>{{ option.label }}</span>
|
||||
<n-tag v-if="option.cloud" type="info" size="small">云端</n-tag>
|
||||
<n-tag v-else type="success" size="small">本地</n-tag>
|
||||
</n-space>
|
||||
</template>
|
||||
</n-select>
|
||||
</n-form-item>
|
||||
<component :is="item.icon" :size="18" :stroke-width="1.5" />
|
||||
<span>{{ item.label }}</span>
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
<!-- 提供商说明 -->
|
||||
<n-alert v-if="providerInfo" :type="providerInfo.type" size="small">
|
||||
{{ providerInfo.text }}
|
||||
</n-alert>
|
||||
<!-- 底部状态 -->
|
||||
<div class="sidebar-footer">
|
||||
<div class="connection-status">
|
||||
<span :class="['status-dot', { connected: isConnected }]"></span>
|
||||
<span class="status-text">{{ statusText }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- API 密钥输入 -->
|
||||
<n-divider />
|
||||
<n-text depth="3" style="font-size: 12px;">API 密钥配置</n-text>
|
||||
<!-- 主内容区 -->
|
||||
<main class="settings-main">
|
||||
<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
|
||||
v-model:value="config.deepseek_api_key"
|
||||
<!-- 分割线 -->
|
||||
<hr class="divider">
|
||||
|
||||
<!-- 供应商选择 -->
|
||||
<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"
|
||||
show-password-on="click"
|
||||
placeholder="sk-..."
|
||||
style="width: 300px"
|
||||
/>
|
||||
<template #feedback>
|
||||
<n-text depth="3" style="font-size: 11px;">
|
||||
获取地址: <n-a href="https://platform.deepseek.com/api_keys" target="_blank">platform.deepseek.com</n-a>
|
||||
</n-text>
|
||||
</template>
|
||||
</n-form-item>
|
||||
class="form-input"
|
||||
:placeholder="apiKeyPlaceholder"
|
||||
>
|
||||
<div class="form-hint">
|
||||
获取地址:<a :href="selectedProvider.apiUrl" target="_blank" rel="noopener">{{ selectedProvider.apiLabel }}</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<n-form-item v-if="config.provider === 'openai'" label="OpenAI API Key">
|
||||
<n-input
|
||||
v-model:value="config.openai_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://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"
|
||||
<!-- Ollama 地址配置 -->
|
||||
<div v-if="config.provider === 'ollama'" class="form-group">
|
||||
<label class="form-label">服务地址</label>
|
||||
<input
|
||||
v-model="config.ollama_host"
|
||||
type="text"
|
||||
class="form-input"
|
||||
placeholder="http://127.0.0.1:11434"
|
||||
style="width: 300px"
|
||||
/>
|
||||
<template #feedback>
|
||||
<n-button text type="primary" @click="checkOllamaStatus" :loading="checking">
|
||||
🔄 检测状态
|
||||
</n-button>
|
||||
</template>
|
||||
</n-form-item>
|
||||
>
|
||||
<div class="form-actions">
|
||||
<button class="btn-secondary" @click="testConnection" :disabled="testing">
|
||||
{{ testing ? '测试中...' : '测试连接' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 保存按钮 -->
|
||||
<n-space>
|
||||
<n-button type="primary" @click="saveConfig" :loading="saving">
|
||||
保存配置
|
||||
</n-button>
|
||||
<n-button v-if="needsRestart" type="warning" @click="showRestartModal = true">
|
||||
需要重启后端
|
||||
</n-button>
|
||||
</n-space>
|
||||
<!-- 高级选项(折叠) -->
|
||||
<details class="advanced-options">
|
||||
<summary>高级选项</summary>
|
||||
<div class="advanced-content">
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label class="form-label">Temperature</label>
|
||||
<input v-model.number="config.temperature" type="number" step="0.1" min="0" max="2" class="form-input">
|
||||
</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 />
|
||||
<n-space>
|
||||
<n-tag v-if="configStatus.has_deepseek_key" type="success" size="small">DeepSeek 已配置</n-tag>
|
||||
<n-tag v-if="configStatus.has_openai_key" type="success" size="small">OpenAI 已配置</n-tag>
|
||||
<n-tag v-if="configStatus.has_qwen_key" type="success" size="small">Qwen 已配置</n-tag>
|
||||
</n-space>
|
||||
</n-space>
|
||||
<!-- 底部操作栏 -->
|
||||
<footer class="settings-footer">
|
||||
<div class="footer-status">{{ saveStatus }}</div>
|
||||
<div class="footer-actions">
|
||||
<button class="btn-ghost" @click="resetConfig">重置</button>
|
||||
<button class="btn-primary" @click="saveConfig" :disabled="saving">
|
||||
{{ saving ? '保存中...' : '保存配置' }}
|
||||
</button>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- 重启确认弹窗 -->
|
||||
<n-modal v-model:show="showRestartModal" preset="dialog" title="重启后端服务">
|
||||
<template #header>
|
||||
<n-icon size="24" color="#f0a020"><RestartIcon /></n-icon>
|
||||
<span style="margin-left: 8px;">重启后端服务</span>
|
||||
</template>
|
||||
LLM 配置已更改,需要重启后端服务才能生效。
|
||||
<template #action>
|
||||
<n-space>
|
||||
<n-button @click="showRestartModal = false">取消</n-button>
|
||||
<n-button type="warning" @click="restartBackend">
|
||||
确认重启
|
||||
</n-button>
|
||||
</n-space>
|
||||
</template>
|
||||
</n-modal>
|
||||
</n-card>
|
||||
<div v-if="showRestartModal" class="modal-overlay" @click.self="showRestartModal = false">
|
||||
<div class="modal">
|
||||
<div class="modal-header">
|
||||
<h3>需要重启后端</h3>
|
||||
<button class="modal-close" @click="showRestartModal = false">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>LLM 配置已更改,需要重启后端服务才能生效。</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn-ghost" @click="showRestartModal = false">取消</button>
|
||||
<button class="btn-warning" @click="restartBackend">确认重启</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { NCard, NSpace, NSelect, NFormItem, NInput, NButton, NTag, NAlert, NDivider, NText, NA, NModal, NIcon, useMessage } from 'naive-ui'
|
||||
import { ref, reactive, computed, onMounted, h } from 'vue'
|
||||
import axios from 'axios'
|
||||
import { h } from 'vue'
|
||||
|
||||
const RestartIcon = () => h('svg', { viewBox: '0 0 24 24', fill: 'currentColor' }, [
|
||||
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' })
|
||||
// Lucide-style icons (inline SVG)
|
||||
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({
|
||||
|
|
@ -142,111 +183,543 @@ const config = reactive({
|
|||
deepseek_api_key: '',
|
||||
openai_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,
|
||||
has_openai_key: false,
|
||||
has_qwen_key: false
|
||||
})
|
||||
|
||||
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 providers = [
|
||||
{ value: 'deepseek', label: 'DeepSeek', desc: '高质量、低成本的大语言模型', pricing: '¥1/百万 tokens', apiUrl: 'https://platform.deepseek.com/api_keys', apiLabel: 'platform.deepseek.com' },
|
||||
{ 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 providerInfo = computed(() => {
|
||||
const info = {
|
||||
deepseek: { type: 'info', text: '💰 便宜好用,¥1/百万 tokens,推荐首选' },
|
||||
openai: { type: 'info', text: '🚀 GPT-4 效果最好,约 ¥15/百万 tokens' },
|
||||
qwen: { type: 'info', text: '🇨🇳 阿里云,国内稳定,¥0.5/百万 tokens' },
|
||||
ollama: { type: 'success', text: '🏠 完全免费,数据隐私,但占用 CPU/内存' }
|
||||
// 状态
|
||||
const saving = ref(false)
|
||||
const testing = ref(false)
|
||||
const showRestartModal = ref(false)
|
||||
const saveStatus = ref('')
|
||||
const isConnected = ref(false)
|
||||
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 () => {
|
||||
try {
|
||||
const response = await axios.get('http://127.0.0.1:8000/ai/config')
|
||||
Object.assign(config, {
|
||||
provider: response.data.provider,
|
||||
ollama_host: response.data.ollama_host
|
||||
})
|
||||
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)
|
||||
}
|
||||
config.provider = response.data.provider
|
||||
config.ollama_host = response.data.ollama_host
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// 保存配置
|
||||
const saveConfig = async () => {
|
||||
saving.value = true
|
||||
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,
|
||||
deepseek_api_key: config.deepseek_api_key || undefined,
|
||||
openai_api_key: config.openai_api_key || undefined,
|
||||
qwen_api_key: config.qwen_api_key || undefined
|
||||
})
|
||||
message.success('配置已保存,需要重启后端才能生效')
|
||||
needsRestart.value = true
|
||||
await loadConfig()
|
||||
} catch (error) {
|
||||
message.error('保存失败: ' + (error.response?.data?.detail || error.message))
|
||||
saveStatus.value = '配置已保存'
|
||||
showRestartModal.value = true
|
||||
} catch (error: any) {
|
||||
saveStatus.value = '保存失败: ' + (error.response?.data?.detail || error.message)
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 检查 Ollama 状态
|
||||
const checkOllamaStatus = async () => {
|
||||
checking.value = true
|
||||
try {
|
||||
const response = await axios.get(config.ollama_host.replace(':11434', ':8000') + '/ai/status', { timeout: 3000 })
|
||||
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 resetConfig = () => {
|
||||
config.provider = 'deepseek'
|
||||
config.temperature = 0.7
|
||||
config.max_tokens = 2048
|
||||
saveStatus.value = '配置已重置'
|
||||
}
|
||||
|
||||
// 重启后端
|
||||
const restartBackend = async () => {
|
||||
try {
|
||||
await axios.post('http://127.0.0.1:8000/ai/stop')
|
||||
message.info('后端已停止,请手动启动: ./start.sh')
|
||||
saveStatus.value = '后端已停止,请手动启动'
|
||||
showRestartModal.value = false
|
||||
needsRestart.value = false
|
||||
} catch (error) {
|
||||
message.error('停止失败: ' + (error.response?.data?.detail || error.message))
|
||||
} catch {
|
||||
saveStatus.value = '停止失败'
|
||||
}
|
||||
}
|
||||
|
||||
const onProviderChange = () => {
|
||||
needsRestart.value = true
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadConfig()
|
||||
})
|
||||
</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>
|
||||
|
|
|
|||
Loading…
Reference in New Issue