Luxx/dashboard/src/views/SettingsView.vue

394 lines
18 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>设置</h1>
<div class="section">
<div class="section-header">
<h2>用户信息</h2>
</div>
<div v-if="loadingUser" class="loading-small"><div class="spinner-small"></div>加载中...</div>
<div v-else class="user-info-card">
<div class="info-item"><span class="label">用户名</span><span class="value">{{ userForm.username || '-' }}</span></div>
<div class="info-item"><span class="label">邮箱</span><span class="value">{{ userForm.email || '-' }}</span></div>
<div class="card-actions">
<button @click="openUserModal" class="btn-primary">编辑</button>
<button @click="handleLogout" class="btn-danger">退出登录</button>
</div>
</div>
</div>
<!-- 用户信息模态框 -->
<div v-if="showUserModal" class="modal-overlay" @click.self="closeUserModal">
<div class="modal">
<h2>编辑用户信息</h2>
<div class="form-group"><label>用户名</label><input v-model="userFormEdit.username" placeholder="输入用户名" /></div>
<div class="form-group"><label>邮箱</label><input v-model="userFormEdit.email" type="email" placeholder="输入邮箱" /></div>
<div class="form-group"><label>新密码 <span class="optional">(留空不修改)</span></label><input v-model="userFormEdit.password" type="password" placeholder="输入新密码" /></div>
<div v-if="userFormError" class="error">{{ userFormError }}</div>
<div class="modal-actions">
<button @click="closeUserModal" class="btn-secondary">取消</button>
<button @click="updateUser" :disabled="savingUser" class="btn-primary">{{ savingUser ? '保存中...' : '保存' }}</button>
</div>
</div>
</div>
<div class="section">
<div class="section-header">
<h2>LLM Provider</h2>
<button @click="showModal = true" class="btn-primary">+ 添加</button>
</div>
</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">API:</span><span class="value">{{ p.base_url }}</span></div>
<div class="info-row"><span class="label">模型:</span><span class="value">{{ p.default_model }}</span></div>
</div>
<div class="card-actions">
<button @click="editProvider(p)">编辑</button>
<button @click="testProvider(p)" :disabled="testing === p.id">{{ testing === p.id ? '测试中...' : '测试' }}</button>
<button @click="deleteProvider(p)" class="btn-danger">删除</button>
</div>
</div>
</div>
<!-- 测试结果弹窗 -->
<div v-if="testResult !== null || testing" class="modal-overlay" @click.self="testResult = null; testing = null">
<div class="modal result-modal" :class="{ success: testResult?.success === true, error: testResult?.success === false, loading: testing }">
<div v-if="testing" class="result-loading">
<div class="spinner-large"></div>
<p>测试连接中...</p>
</div>
<template v-else>
<div class="result-icon">
<span v-if="testResult.success">&#10003;</span>
<span v-else>&#10007;</span>
</div>
<h2>{{ testResult.success ? '连接成功' : '连接失败' }}</h2>
<pre v-if="testResult.json" class="result-json">{{ testResult.json }}</pre>
<div v-else class="result-message">{{ testResult.message }}</div>
<button @click="testResult = null" class="btn-primary">确定</button>
</template>
</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>Base URL</label><input v-model="form.base_url" placeholder="https://api.deepseek.com/chat/completions" /></div>
<div class="form-group"><label>API Key</label><input v-model="form.api_key" type="text" :placeholder="editing ? '留空则保持原密码' : 'sk-...'" /></div>
<div class="form-group">
<label>模型名称</label>
<input v-model="form.default_model" placeholder="deepseek-chat / gpt-4" required />
</div>
<div class="form-group">
<label>最大 Tokens</label>
<input v-model.number="form.max_tokens" type="number" placeholder="8192" min="1" />
<span class="hint">单次回复最大 token 数,默认 8192</span>
</div>
<div class="form-group">
<label class="switch-card" :class="{ active: form.is_default }">
<div class="switch-content">
<span class="switch-title">设为默认 Provider</span>
<span class="switch-desc">新会话将默认使用此 Provider</span>
</div>
<label class="switch">
<input v-model="form.is_default" type="checkbox" />
<span class="slider"></span>
</label>
</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 '../utils/api.js'
import { useAuth } from '../utils/useAuth.js'
import { authAPI } from '../utils/api.js'
import { useRouter } from 'vue-router'
const router = useRouter()
const { user, logout } = useAuth()
const handleLogout = async () => {
if (!confirm('确定要退出登录吗?')) return
await logout(authAPI)
router.push('/auth')
}
const userForm = ref({ username: '', email: '', password: '' })
const userFormEdit = ref({ username: '', email: '', password: '' })
const showUserModal = ref(false)
const savingUser = ref(false)
const userFormError = ref('')
const loadingUser = ref(false)
const fetchUserInfo = async () => {
loadingUser.value = true
try {
const res = await authAPI.getMe()
if (res.success && res.data) {
userForm.value.username = res.data.username || ''
userForm.value.email = res.data.email || ''
}
} catch (e) {
console.error('获取用户信息失败:', e)
} finally {
loadingUser.value = false
}
}
const openUserModal = () => {
userFormEdit.value = { ...userForm.value, password: '' }
userFormError.value = ''
showUserModal.value = true
}
const closeUserModal = () => {
showUserModal.value = false
userFormError.value = ''
}
const updateUser = async () => {
savingUser.value = true
userFormError.value = ''
try {
alert('用户信息已保存')
userForm.value.username = userFormEdit.value.username
userForm.value.email = userFormEdit.value.email
closeUserModal()
} catch (e) {
userFormError.value = e.message || '保存失败'
} finally {
savingUser.value = false
}
}
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 testResult = ref(null)
const formError = ref('')
const form = ref({
name: '', base_url: '', api_key: '', default_model: '', max_tokens: 8192, 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: '', base_url: '', api_key: '', default_model: '', max_tokens: 8192, is_default: false }
formError.value = ''
}
const editProvider = async (p) => {
editing.value = p.id
try {
const res = await providersAPI.get(p.id)
if (res.success && res.data) {
form.value = {
name: res.data.name,
base_url: res.data.base_url,
api_key: res.data.api_key || '',
default_model: res.data.default_model,
max_tokens: res.data.max_tokens || 8192,
is_default: res.data.is_default
}
}
} catch (e) {
console.error('获取Provider详情失败:', e)
}
showModal.value = true
}
const saveProvider = async () => {
if (!form.value.base_url || !form.value.api_key || !form.value.default_model) {
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
testResult.value = null
try {
const res = await providersAPI.test(p.id)
const isSuccess = res.success === true
testResult.value = {
success: isSuccess,
message: res.message || (isSuccess ? '连接成功' : '连接失败'),
json: JSON.stringify(res, null, 2)
}
} catch (e) {
testResult.value = {
success: false,
message: e.message || '测试失败',
json: JSON.stringify(e.response?.data || { error: e.message }, null, 2)
}
} 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(() => {
fetchUserInfo()
fetchProviders()
})
</script>
<style scoped>
.settings { padding: 0; }
.settings h1 { font-size: 2rem; margin: 0 0 2rem; color: var(--text-h); }
.section { margin-bottom: 2rem; }
.section h2 { font-size: 1.25rem; margin: 0; color: var(--text-h); }
.user-info-card { background: var(--bg); border: 1px solid var(--border); border-radius: 12px; padding: 1.25rem; margin-top: 1rem; }
.user-info-card .info-item { display: flex; gap: 1rem; margin-bottom: 0.75rem; }
.user-info-card .info-item:last-of-type { margin-bottom: 1rem; }
.user-info-card .label { color: var(--text); min-width: 60px; }
.user-info-card .value { color: var(--text-h); font-weight: 500; }
.user-info-card .card-actions { margin-top: 1rem; padding-top: 1rem; border-top: 1px solid var(--border); }
.optional { color: var(--text); font-weight: normal; font-size: 0.85rem; }
.section-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem; }
.section-header h2 { margin: 0; }
.section-header .btn-primary { min-width: 80px; text-align: center; }
.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: var(--accent-bg); color: var(--accent); border: 1px solid var(--accent-border); padding: 0.5rem 1rem; border-radius: 6px; cursor: pointer; transition: all 0.2s; }
.btn-danger:hover { background: var(--accent); color: white; }
.loading, .empty, .error { text-align: center; padding: 4rem; color: var(--text); }
.error { background: var(--accent-bg); border-radius: 12px; color: var(--accent); }
.loading-small { padding: 2rem; text-align: center; color: var(--text); }
.spinner-small { width: 24px; height: 24px; border: 3px solid var(--border); border-top-color: var(--accent); border-radius: 50%; animation: spin 1s linear infinite; margin: 0 auto 0.5rem; }
.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; display: flex; flex-direction: column; }
.card-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem; }
.card-title { display: flex; align-items: center; gap: 0.5rem; min-width: 0; }
.card-title h3 { margin: 0; color: var(--text-h); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.badge { padding: 0.25rem 0.75rem; border-radius: 20px; font-size: 0.75rem; background: var(--code-bg); color: var(--text); flex-shrink: 0; }
.badge.default { background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%); color: white; }
.switch { position: relative; display: inline-block; width: 48px; height: 26px; flex-shrink: 0; }
.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; flex: 1; }
.info-row { display: flex; gap: 0.5rem; margin-bottom: 0.5rem; font-size: 0.9rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.info-row .label { color: var(--text); min-width: 50px; flex-shrink: 0; }
.info-row .value { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.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; min-width: 60px; text-align: center; }
.card-actions button.btn-primary { background: var(--accent); color: white; border: none; }
.card-actions button.btn-danger { background: var(--accent-bg); color: var(--accent); border: 1px solid var(--accent-border); }
.card-actions button.btn-danger:hover { background: var(--accent); color: white; }
.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; }
.result-modal { text-align: center; }
.result-modal.loading { min-height: 150px; display: flex; flex-direction: column; align-items: center; justify-content: center; }
.result-loading { display: flex; flex-direction: column; align-items: center; gap: 1rem; }
.spinner-large { width: 48px; height: 48px; border: 4px solid var(--border); border-top-color: var(--accent); border-radius: 50%; animation: spin 1s linear infinite; }
.result-modal .result-icon { width: 64px; height: 64px; border-radius: 50%; display: flex; align-items: center; justify-content: center; margin: 0 auto 1rem; font-size: 2rem; }
.result-modal.success .result-icon { background: #dcfce7; color: #16a34a; }
.result-modal.error .result-icon { background: var(--accent-bg); color: var(--accent); }
.result-modal h2 { margin: 0 0 0.5rem; color: var(--text-h); }
.result-modal .result-status { color: var(--accent); font-size: 0.9rem; margin-bottom: 0.5rem; font-weight: 500; }
.result-modal .result-message { color: var(--text); margin-bottom: 1.5rem; }
.result-modal .result-json { background: var(--code-bg); border: 1px solid var(--border); border-radius: 8px; padding: 1rem; margin: 1rem 0; max-height: 200px; overflow: auto; font-size: 0.75rem; text-align: left; white-space: pre-wrap; word-break: break-all; color: var(--text-h); }
.result-modal .btn-primary { width: 100%; }
.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; }
.switch-card { display: flex; align-items: center; justify-content: space-between; }
.switch-card input { display: none; }
.switch-content { display: flex; flex-direction: column; gap: 0.25rem; }
.switch-title { font-weight: 600; color: var(--text-h); }
.switch-desc { font-size: 0.85rem; color: var(--text); }
.switch-card .switch { position: relative; display: inline-block; width: 48px; height: 26px; flex-shrink: 0; }
.switch-card .switch input { opacity: 0; width: 0; height: 0; }
.switch-card .slider { position: absolute; cursor: pointer; inset: 0; background-color: #ccc; transition: 0.3s; border-radius: 26px; }
.switch-card .slider:before { position: absolute; content: ""; height: 20px; width: 20px; left: 3px; bottom: 3px; background-color: white; transition: 0.3s; border-radius: 50%; }
.switch-card input:checked + .slider { background-color: var(--accent); }
.switch-card input:checked + .slider:before { transform: translateX(22px); }
.modal-actions { display: flex; justify-content: flex-end; gap: 1rem; margin-top: 1.5rem; }
.form-group .hint { font-size: 0.85rem; color: var(--text); margin-top: 4px; display: block; }
.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>