Luxx/dashboard/src/views/SettingsView.vue

522 lines
22 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="page-container settings">
<div class="page-header">
<h1>设置</h1>
</div>
<!-- 用户信息 -->
<div class="page-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="page-card">
<div class="info-grid">
<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>
<div class="card-actions">
<button @click="openUserModal" class="btn-primary">编辑资料</button>
<button @click="handleLogout" class="btn-primary">退出登录</button>
</div>
</div>
</div>
<!-- 默认模型设置 -->
<div class="page-section">
<div class="section-header">
<h2>🤖 模型设置</h2>
</div>
<div class="page-card">
<div class="form-group">
<label>默认 Provider</label>
<select v-model="modelSettings.default_provider">
<option v-for="p in providers" :key="p.id" :value="p.id">
{{ p.name }} ({{ p.default_model }})
</option>
</select>
</div>
<div class="form-row">
<div class="form-group">
<label>温度 (Temperature)</label>
<input v-model.number="modelSettings.temperature" type="number" min="0" max="2" step="0.1" />
<span class="hint">控制随机性,较低值更确定,较高值更有创造性</span>
</div>
<div class="form-group">
<label>最大 Tokens</label>
<input v-model.number="modelSettings.max_tokens" type="number" min="100" max="32000" />
<span class="hint">单次回复最大 token 数</span>
</div>
</div>
<div class="form-group">
<label class="switch-card">
<div class="switch-content">
<span class="switch-title">启用推理模式</span>
<span class="switch-desc">使用 CoT 推理,消耗更多 token 但更准确</span>
</div>
<label class="switch">
<input v-model="modelSettings.thinking_enabled" type="checkbox" />
<span class="slider"></span>
</label>
</label>
</div>
<button @click="saveModelSettings" class="btn-primary">保存设置</button>
</div>
</div>
<!-- 系统提示词 -->
<div class="page-section">
<div class="section-header">
<h2>💬 系统提示词</h2>
</div>
<div class="page-card">
<div class="form-group">
<label>默认系统提示词</label>
<textarea v-model="modelSettings.system_prompt" rows="4" placeholder="You are a helpful assistant."></textarea>
<span class="hint">设置默认系统提示词,可在新建会话时覆盖</span>
</div>
<button @click="saveSystemPrompt" class="btn-primary">保存提示词</button>
</div>
</div>
<!-- LLM Provider 管理 -->
<div class="page-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-card">
<p>暂无 Provider点击上方按钮添加</p>
</div>
<div v-else class="page-grid">
<div v-for="p in providers" :key="p.id" class="page-card provider-card">
<div class="card-header">
<div class="card-title">
<h3>{{ p.name }}</h3>
<span v-if="p.is_default" class="badge default">默认</span>
<span v-if="p.enabled" class="badge enabled">启用</span>
<span v-else class="badge disabled">禁用</span>
</div>
<label class="switch">
<input type="checkbox" :checked="p.enabled" @change="toggleEnabled(p)" />
<span class="slider"></span>
</label>
</div>
<div class="info-list">
<div class="info-item">
<span class="label">API:</span>
<span class="value url">{{ p.base_url }}</span>
</div>
<div class="info-item">
<span class="label">模型:</span>
<span class="value">{{ p.default_model }}</span>
</div>
<div class="info-item">
<span class="label">最大Tokens:</span>
<span class="value">{{ p.max_tokens || 8192 }}</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">✓</span>
<span v-else>✗</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="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>
<!-- 添加/编辑 Provider 模态框 -->
<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, watch } from 'vue'
import { providersAPI, conversationsAPI } 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 modelSettings = ref({
default_provider: null,
temperature: 0.7,
max_tokens: 8192,
thinking_enabled: false,
system_prompt: 'You are a helpful assistant.'
})
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 saveModelSettings = async () => {
// 保存到本地或服务器
localStorage.setItem('modelSettings', JSON.stringify(modelSettings.value))
alert('模型设置已保存')
}
const saveSystemPrompt = async () => {
localStorage.setItem('defaultSystemPrompt', modelSettings.value.system_prompt)
alert('系统提示词已保存')
}
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 || []
// 设置默认 provider
const defaultProvider = providers.value.find(p => p.is_default)
if (defaultProvider && !modelSettings.value.default_provider) {
modelSettings.value.default_provider = defaultProvider.id
}
}
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()
// 加载本地设置
const savedSettings = localStorage.getItem('modelSettings')
if (savedSettings) {
try {
const parsed = JSON.parse(savedSettings)
modelSettings.value = { ...modelSettings.value, ...parsed }
} catch (e) {}
}
const savedPrompt = localStorage.getItem('defaultSystemPrompt')
if (savedPrompt) {
modelSettings.value.system_prompt = savedPrompt
}
})
</script>
<style scoped>
.section { margin-bottom: 1.5rem; }
.section-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.75rem; }
.section-header h2 { font-size: 1rem; margin: 0; color: var(--text-h); font-weight: 600; }
.card { background: var(--bg); border: 1px solid var(--border); border-radius: 12px; padding: 1.25rem; }
.card-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem; }
.card-title { display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap; }
.card-title h3 { margin: 0; color: var(--text-h); }
.info-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 1rem; margin-bottom: 1rem; }
.info-list { display: flex; flex-direction: column; gap: 0.5rem; margin-bottom: 1rem; }
.info-item { display: flex; gap: 0.75rem; font-size: 0.9rem; }
.info-item .label { color: var(--text); min-width: 70px; flex-shrink: 0; }
.info-item .value { color: var(--text-h); }
.info-item .value.url { font-size: 0.8rem; word-break: break-all; }
.card-actions { display: flex; gap: 0.5rem; flex-wrap: wrap; margin-top: 1rem; padding-top: 1rem; border-top: 1px solid var(--border); }
.form-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 1rem; }
.form-group { margin-bottom: 1rem; }
.form-group:last-child { margin-bottom: 0; }
.form-group label { display: block; margin-bottom: 0.5rem; font-weight: 500; color: var(--text-h); font-size: 0.9rem; }
.form-group input, .form-group select, .form-group textarea {
width: 100%;
padding: 0.65rem;
border: 1px solid var(--border);
border-radius: 8px;
font-size: 0.9rem;
background: var(--bg);
box-sizing: border-box;
color: var(--text-h);
}
.form-group textarea { resize: vertical; min-height: 100px; }
.form-group .hint { font-size: 0.75rem; color: var(--text); margin-top: 4px; display: block; }
.switch-card { display: flex; align-items: center; justify-content: space-between; padding: 0.75rem; background: var(--bg); border: 1px solid var(--border); border-radius: 8px; cursor: pointer; }
.switch-card.active { border-color: var(--accent); background: var(--accent-bg); }
.switch-content { display: flex; flex-direction: column; gap: 0.2rem; }
.switch-title { font-weight: 500; color: var(--text-h); font-size: 0.9rem; }
.switch-desc { font-size: 0.75rem; color: var(--text); }
.switch { position: relative; display: inline-block; width: 44px; height: 24px; 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: 24px; }
.slider:before { position: absolute; content: ""; height: 18px; width: 18px; left: 3px; bottom: 3px; background-color: white; transition: 0.3s; border-radius: 50%; }
input:checked + .slider { background-color: var(--accent); }
input:checked + .slider:before { transform: translateX(20px); }
.badge { padding: 0.2rem 0.6rem; border-radius: 20px; font-size: 0.7rem; font-weight: 500; }
.badge.default { background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%); color: white; }
.badge.enabled { background: #dcfce7; color: #16a34a; }
.badge.disabled { background: var(--bg); color: var(--text); }
.provider-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 1rem; }
.btn-primary { padding: 0.6rem 1.2rem; background: #2563eb; color: #ffffff; border: none; border-radius: 8px; cursor: pointer; font-size: 0.85rem; font-weight: 500; }
.btn-primary:hover { background: #1d4ed8; }
.btn-secondary { padding: 0.6rem 1.2rem; background: var(--bg); color: var(--text); border: 1px solid var(--border); border-radius: 8px; cursor: pointer; font-size: 0.85rem; }
.btn-danger { padding: 0.5rem 1rem; background: var(--accent-bg); color: var(--accent); border: 1px solid var(--accent-border); border-radius: 6px; cursor: pointer; font-size: 0.8rem; }
.btn-danger:hover { background: var(--accent); color: white; }
.btn-danger-outline { padding: 0.6rem 1.2rem; background: transparent; color: var(--accent); border: 1px solid var(--accent-border); border-radius: 8px; cursor: pointer; font-size: 0.85rem; }
.btn-danger-outline:hover { background: var(--accent-bg); }
.card-actions button { padding: 0.5rem 1rem; background: var(--bg); border: 1px solid var(--border); border-radius: 6px; cursor: pointer; font-size: 0.8rem; }
.card-actions button.btn-primary, .card-actions button.btn-secondary { all: unset; }
.card-actions button.btn-primary { padding: 0.6rem 1.2rem; background: #2563eb; color: #ffffff; border: none; border-radius: 8px; cursor: pointer; font-size: 0.85rem; font-weight: 500; display: inline-block; }
.card-actions button.btn-primary:hover { background: #1d4ed8; }
.card-actions button:not(.btn-primary):not(.btn-secondary):hover { background: var(--border); }
.loading, .empty-card, .error { text-align: center; padding: 3rem; color: var(--text); background: var(--bg); border: 1px solid var(--border); border-radius: 12px; }
.error { background: var(--accent-bg); color: var(--accent); }
.loading-small { padding: 1.5rem; 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; }
.spinner { width: 40px; height: 40px; border: 3px solid var(--border); border-top-color: var(--accent); border-radius: 50%; animation: spin 1s linear infinite; margin: 0 auto 1rem; }
.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: 1.5rem; width: 100%; max-width: 480px; }
.modal h2 { margin: 0 0 1.25rem; color: var(--text-h); font-size: 1.1rem; }
.result-modal { text-align: center; }
.result-modal.loading { min-height: 120px; display: flex; flex-direction: column; align-items: center; justify-content: center; }
.result-loading { display: flex; flex-direction: column; align-items: center; gap: 0.75rem; }
.spinner-large { width: 40px; height: 40px; border: 3px solid var(--border); border-top-color: var(--accent); border-radius: 50%; animation: spin 1s linear infinite; }
.result-modal .result-icon { width: 56px; height: 56px; border-radius: 50%; display: flex; align-items: center; justify-content: center; margin: 0 auto 1rem; font-size: 1.5rem; }
.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.75rem; }
.result-modal .result-message { color: var(--text); margin-bottom: 1rem; font-size: 0.9rem; }
.result-modal .result-json { background: var(--code-bg); border: 1px solid var(--border); border-radius: 8px; padding: 0.75rem; margin: 0.75rem 0; max-height: 150px; overflow: auto; font-size: 0.75rem; text-align: left; white-space: pre-wrap; word-break: break-all; }
.modal-actions { display: flex; justify-content: flex-end; gap: 0.75rem; margin-top: 1.25rem; }
.optional { color: var(--text); font-weight: normal; font-size: 0.8rem; }
@keyframes spin { to { transform: rotate(360deg); } }
</style>