Luxx/dashboard/src/views/SettingsView.vue

225 lines
10 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="settings">
<h1>LLM Provider 设置</h1>
<p class="subtitle">配置您的 AI 模型提供商</p>
<div class="actions">
<button @click="showModal = true" class="btn-primary">+ 添加 Provider</button>
</div>
<div v-if="loading" class="loading"><div class="spinner"></div>加载中...</div>
<div v-else-if="error" class="error">{{ error }}</div>
<div v-else-if="!providers.length" class="empty">暂无 Provider点击上方按钮添加</div>
<div v-else class="grid">
<div v-for="p in providers" :key="p.id" class="card">
<div class="card-header">
<div class="card-title">
<h3>{{ p.name }}</h3>
<span v-if="p.is_default" class="badge default">⭐ 默认</span>
</div>
<label class="switch">
<input type="checkbox" :checked="p.enabled" @change="toggleEnabled(p)" />
<span class="slider"></span>
</label>
</div>
<div class="card-info">
<div class="info-row"><span class="label">类型:</span> {{ p.provider_type }}</div>
<div class="info-row"><span class="label">API:</span> {{ p.base_url }}</div>
<div class="info-row"><span class="label">模型:</span> {{ p.default_model }}</div>
</div>
<div class="card-actions">
<button @click="testProvider(p)" :disabled="testing === p.id">🔗 测试</button>
<button @click="editProvider(p)">✏️ 编辑</button>
<button @click="deleteProvider(p)" class="btn-danger">🗑️ 删除</button>
</div>
</div>
</div>
<!-- 添加/编辑模态框 -->
<div v-if="showModal" class="modal-overlay" @click.self="closeModal">
<div class="modal">
<h2>{{ editing ? '编辑 Provider' : '添加 Provider' }}</h2>
<div class="form-group"><label>名称</label><input v-model="form.name" placeholder="如: 我的 DeepSeek" /></div>
<div class="form-group">
<label>类型</label>
<select v-model="form.provider_type">
<option value="openai">OpenAI 兼容</option>
<option value="deepseek">DeepSeek</option>
<option value="glm">智谱 GLM</option>
<option value="anthropic">Anthropic</option>
<option value="custom">自定义</option>
</select>
</div>
<div class="form-group"><label>Base URL</label><input v-model="form.base_url" placeholder="https://api.deepseek.com/v1" /></div>
<div class="form-group"><label>API Key</label><input v-model="form.api_key" type="password" placeholder="sk-..." /></div>
<div class="form-group"><label>默认模型</label><input v-model="form.default_model" placeholder="gpt-4 / deepseek-chat" /></div>
<div class="form-group">
<label class="radio-card" :class="{ active: form.is_default }">
<input v-model="form.is_default" type="checkbox" />
<div class="radio-content">
<span class="radio-icon">⭐</span>
<div class="radio-text">
<span class="radio-title">设为默认 Provider</span>
<span class="radio-desc">新会话将默认使用此 Provider</span>
</div>
</div>
</label>
</div>
<div v-if="formError" class="error">{{ formError }}</div>
<div class="modal-actions">
<button @click="closeModal" class="btn-secondary">取消</button>
<button @click="saveProvider" :disabled="saving" class="btn-primary">{{ saving ? '保存中...' : '保存' }}</button>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { providersAPI } from '../services/api.js'
const providers = ref([])
const loading = ref(true)
const error = ref('')
const showModal = ref(false)
const editing = ref(null)
const saving = ref(false)
const testing = ref(null)
const formError = ref('')
const form = ref({
name: '', provider_type: 'openai', base_url: '', api_key: '', default_model: '', is_default: false
})
const fetchProviders = async () => {
loading.value = true
error.value = ''
try {
const res = await providersAPI.list()
if (res.success) providers.value = res.data.providers || []
else throw new Error(res.message)
} catch (e) { error.value = e.message }
finally { loading.value = false }
}
const closeModal = () => {
showModal.value = false
editing.value = null
form.value = { name: '', provider_type: 'openai', base_url: '', api_key: '', default_model: '', is_default: false }
formError.value = ''
}
const editProvider = (p) => {
editing.value = p.id
form.value = {
name: p.name, provider_type: p.provider_type, base_url: p.base_url,
api_key: p.api_key, default_model: p.default_model, is_default: p.is_default
}
showModal.value = true
}
const saveProvider = async () => {
if (!form.value.name || !form.value.base_url || !form.value.api_key) {
formError.value = '请填写所有必填项'
return
}
saving.value = true
formError.value = ''
try {
const data = { ...form.value }
let res
if (editing.value) res = await providersAPI.update(editing.value, data)
else res = await providersAPI.create(data)
if (res.success) { closeModal(); fetchProviders() }
else throw new Error(res.message)
} catch (e) { formError.value = e.message }
finally { saving.value = false }
}
const testProvider = async (p) => {
testing.value = p.id
try {
const res = await providersAPI.test(p.id)
alert(res.data?.message || (res.data?.success ? '连接成功' : '连接失败'))
} catch (e) { alert('测试失败: ' + e.message) }
finally { testing.value = null }
}
const deleteProvider = async (p) => {
if (!confirm(`删除「${p.name}」?`)) return
try {
const res = await providersAPI.delete(p.id)
if (res.success) fetchProviders()
else alert(res.message)
} catch (e) { alert('删除失败: ' + e.message) }
}
const toggleEnabled = async (p) => {
try {
const res = await providersAPI.update(p.id, { enabled: !p.enabled })
if (res.success) fetchProviders()
else alert(res.message)
} catch (e) { alert('更新失败: ' + e.message) }
}
onMounted(fetchProviders)
</script>
<style scoped>
.settings { padding: 0; }
.settings h1 { font-size: 2rem; margin: 0 0 0.5rem; color: var(--text-h); }
.subtitle { color: var(--text); margin: 0 0 2rem; }
.actions { margin-bottom: 2rem; }
.btn-primary { padding: 0.75rem 1.5rem; background: var(--accent); color: white; border: none; border-radius: 8px; cursor: pointer; }
.btn-secondary { padding: 0.75rem 1.5rem; background: var(--bg); color: var(--text); border: 1px solid var(--border); border-radius: 8px; cursor: pointer; }
.btn-danger { background: #dc2626; color: white; border: none; padding: 0.5rem 1rem; border-radius: 6px; cursor: pointer; }
.loading, .empty, .error { text-align: center; padding: 4rem; color: var(--text); }
.error { background: #fef2f2; border-radius: 12px; color: #dc2626; }
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(350px, 1fr)); gap: 1.5rem; }
.card { background: var(--bg); border: 1px solid var(--border); border-radius: 12px; padding: 1.5rem; }
.card-header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 1rem; }
.card-title { display: flex; align-items: center; gap: 0.5rem; }
.card-title h3 { margin: 0; color: var(--text-h); }
.badge { padding: 0.25rem 0.75rem; border-radius: 20px; font-size: 0.75rem; background: var(--code-bg); color: var(--text); }
.badge.default { background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%); color: white; }
.switch { position: relative; display: inline-block; width: 48px; height: 26px; }
.switch input { opacity: 0; width: 0; height: 0; }
.slider { position: absolute; cursor: pointer; inset: 0; background-color: #ccc; transition: 0.3s; border-radius: 26px; }
.slider:before { position: absolute; content: ""; height: 20px; width: 20px; left: 3px; bottom: 3px; background-color: white; transition: 0.3s; border-radius: 50%; }
input:checked + .slider { background-color: #16a34a; }
input:checked + .slider:before { transform: translateX(22px); }
.card-info { margin-bottom: 1rem; }
.info-row { display: flex; gap: 0.5rem; margin-bottom: 0.5rem; font-size: 0.9rem; }
.label { color: var(--text); min-width: 50px; }
.card-actions { display: flex; gap: 0.5rem; flex-wrap: wrap; }
.card-actions button { padding: 0.5rem 1rem; background: var(--bg); border: 1px solid var(--border); border-radius: 6px; cursor: pointer; }
.modal-overlay { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.5); display: flex; align-items: center; justify-content: center; z-index: 1000; }
.modal { background: var(--bg); border-radius: 16px; padding: 2rem; width: 100%; max-width: 500px; }
.modal h2 { margin: 0 0 1.5rem; color: var(--text-h); }
.form-group { margin-bottom: 1.25rem; }
.form-group label { display: block; margin-bottom: 0.5rem; font-weight: 500; color: var(--text-h); }
.form-group input, .form-group select { width: 100%; padding: 0.75rem; border: 1px solid var(--border); border-radius: 8px; font-size: 1rem; background: var(--bg); box-sizing: border-box; }
.form-group.checkbox label { display: flex; align-items: center; gap: 0.5rem; cursor: pointer; }
.radio-card { display: flex; align-items: center; padding: 1rem; border: 2px solid var(--border); border-radius: 12px; cursor: pointer; transition: all 0.2s; }
.radio-card:hover { border-color: var(--accent); }
.radio-card.active { border-color: var(--accent); background: var(--accent-bg); }
.radio-card input { display: none; }
.radio-content { display: flex; align-items: center; gap: 1rem; width: 100%; }
.radio-icon { font-size: 1.5rem; }
.radio-text { display: flex; flex-direction: column; }
.radio-title { font-weight: 600; color: var(--text-h); }
.radio-desc { font-size: 0.85rem; color: var(--text); }
.modal-actions { display: flex; justify-content: flex-end; gap: 1rem; margin-top: 1.5rem; }
.spinner { width: 48px; height: 48px; border: 4px solid var(--border); border-top-color: var(--accent); border-radius: 50%; animation: spin 1s linear infinite; margin: 0 auto 1rem; }
@keyframes spin { to { transform: rotate(360deg); } }
</style>