Luxx/dashboard/src/views/SettingsView.vue

621 lines
26 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="settings-section">
<div class="section-title">
<span class="section-icon">👤</span>
<span class="section-text">用户信息</span>
</div>
<div class="settings-card">
<div v-if="loadingUser" class="loading-small"><div class="spinner-small"></div>加载中...</div>
<template v-else>
<div class="settings-row">
<div class="row-label">
<span class="label-icon">用户名</span>
</div>
<div class="row-value">{{ userForm.username || '-' }}</div>
</div>
<div class="settings-row">
<div class="row-label">
<span class="label-icon">邮箱</span>
</div>
<div class="row-value">{{ userForm.email || '-' }}</div>
</div>
<div class="settings-row actions">
<button @click="openUserModal" class="btn-action">编辑资料</button>
<button @click="handleLogout" class="btn-action btn-logout">退出登录</button>
</div>
</template>
</div>
</div>
<!-- 外观设置 -->
<div class="settings-section">
<div class="section-title">
<span class="section-icon">🎨</span>
<span class="section-text">外观</span>
</div>
<div class="settings-card">
<div class="settings-row">
<div class="row-label">
<span class="row-title">夜间模式</span>
<span class="row-desc">切换深色/浅色主题</span>
</div>
<div class="row-value">
<label class="switch" @click.prevent="toggleTheme">
<input type="checkbox" v-model="isDark" />
<span class="slider"></span>
</label>
</div>
</div>
</div>
</div>
<!-- 模型设置 -->
<div class="settings-section">
<div class="section-title">
<span class="section-icon">🤖</span>
<span class="section-text">模型设置</span>
</div>
<div class="settings-card">
<div class="settings-row">
<div class="row-label">默认 Provider</div>
<div class="row-value">
<select v-model="modelSettings.default_provider" class="inline-select">
<option v-for="p in providers" :key="p.id" :value="p.id">
{{ p.name }} ({{ p.default_model }})
</option>
</select>
</div>
</div>
<div class="settings-row">
<div class="row-label">温度 (Temperature)</div>
<div class="row-value">
<div class="input-with-hint">
<input v-model.number="modelSettings.temperature" type="number" min="0" max="2" step="0.1" class="inline-input" />
<span class="hint-inline">控制随机性,较低值更确定,较高值更有创造性</span>
</div>
</div>
</div>
<div class="settings-row">
<div class="row-label">最大 Tokens</div>
<div class="row-value">
<div class="input-with-hint">
<input v-model.number="modelSettings.max_tokens" type="number" min="100" max="32000" class="inline-input" />
<span class="hint-inline">单次回复最大 token 数</span>
</div>
</div>
</div>
<div class="settings-row">
<div class="row-label">
<span class="row-title">推理模式</span>
<span class="row-desc">使用 CoT 推理,消耗更多 token 但更准确</span>
</div>
<div class="row-value">
<label class="switch" @click.prevent="modelSettings.thinking_enabled = !modelSettings.thinking_enabled">
<input v-model="modelSettings.thinking_enabled" type="checkbox" />
<span class="slider"></span>
</label>
</div>
</div>
<div class="settings-row actions">
<button @click="saveModelSettings" class="btn-primary">保存设置</button>
</div>
</div>
</div>
<!-- 系统提示词 -->
<div class="settings-section">
<div class="section-title">
<span class="section-icon">💬</span>
<span class="section-text">系统提示词</span>
</div>
<div class="settings-card">
<div class="settings-row full">
<textarea v-model="modelSettings.system_prompt" rows="4" placeholder="You are a helpful assistant."></textarea>
<span class="hint-block">设置默认系统提示词,可在新建会话时覆盖</span>
</div>
<div class="settings-row actions">
<button @click="saveSystemPrompt" class="btn-primary">保存提示词</button>
</div>
</div>
</div>
<!-- LLM Provider 管理 -->
<div class="settings-section">
<div class="section-title">
<span class="section-icon">🔌</span>
<span class="section-text">LLM Provider</span>
<button @click="showModal = true" class="btn-add">+ 添加</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="settings-table-container">
<table class="settings-table">
<thead>
<tr>
<th>名称</th>
<th>API / 模型</th>
<th class="action-col">启用</th>
<th class="ops-col">操作</th>
</tr>
</thead>
<tbody>
<tr v-for="p in providers" :key="p.id">
<td class="name-col">
<div class="provider-name">{{ p.name }}</div>
<div class="provider-badges">
<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>
</td>
<td class="info-col">
<div class="info-item">{{ p.base_url }}</div>
<div class="info-item sub">模型: {{ p.default_model }}</div>
<div class="info-item sub">最大Tokens: {{ p.max_tokens || 8192 }}</div>
</td>
<td class="switch-col">
<label class="switch" @click.prevent="toggleEnabled(p)">
<input type="checkbox" :checked="p.enabled" />
<span class="slider"></span>
</label>
</td>
<td class="ops-col">
<div class="ops-buttons">
<button @click="editProvider(p)" class="btn-op">编辑</button>
<button @click="testProvider(p)" :disabled="testing === p.id" class="btn-op">
{{ testing === p.id ? '测试中...' : '测试' }}
</button>
<button @click="deleteProvider(p)" class="btn-op btn-danger">删除</button>
</div>
</td>
</tr>
</tbody>
</table>
</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 } 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 { logout } = useAuth()
// 夜间模式
const isDark = ref(localStorage.getItem('theme') === 'dark')
const toggleTheme = () => {
isDark.value = !isDark.value
if (isDark.value) {
document.documentElement.setAttribute('data-theme', 'dark')
localStorage.setItem('theme', 'dark')
} else {
document.documentElement.removeAttribute('data-theme')
localStorage.setItem('theme', 'light')
}
}
onMounted(() => {
if (localStorage.getItem('theme') === 'dark') {
document.documentElement.setAttribute('data-theme', 'dark')
isDark.value = true
}
})
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 || []
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>
/* 页面容器 */
.page-container.settings { padding: 1.5rem 12.5%; box-sizing: border-box; min-height: 100%; overflow-y: auto; }
/* 通用设置部分 */
.settings-section { margin-bottom: 1.5rem; }
.section-title { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.75rem; padding: 0.5rem 0; }
.section-icon { font-size: 1rem; }
.section-text { font-size: 1rem; font-weight: 700; color: var(--text-primary); }
.btn-add { margin-left: auto; padding: 0.4rem 0.8rem; background: var(--accent-primary); color: white; border: none; border-radius: 6px; font-size: 0.8rem; cursor: pointer; transition: all 0.2s; }
.btn-add:hover { background: var(--accent-primary-hover); }
/* 设置卡片 */
.settings-card { background: var(--bg-primary); border: 1px solid var(--border-light); border-radius: 12px; overflow: hidden; }
/* 设置行 */
.settings-row { display: flex; align-items: center; padding: 0.85rem 1rem; border-bottom: 1px solid var(--border-light); }
.settings-row:last-child { border-bottom: none; }
.settings-row.full { flex-direction: column; align-items: stretch; }
.settings-row.actions { justify-content: flex-end; gap: 0.5rem; background: var(--bg-secondary); }
.row-label { min-width: 140px; color: var(--text-secondary); font-size: 0.85rem; flex-shrink: 0; }
.row-title { display: block; font-weight: 500; color: var(--text-primary); font-size: 0.9rem; }
.row-desc { display: block; font-size: 0.75rem; color: var(--text-secondary); margin-top: 0.15rem; }
.row-value { flex: 1; color: var(--text-primary); font-size: 0.9rem; }
/* 内联输入框 */
.inline-select, .inline-input { padding: 0.5rem 0.75rem; border: 1px solid var(--border-input); border-radius: 6px; background: var(--bg-input); color: var(--text-primary); font-size: 0.85rem; min-width: 180px; }
.inline-input { width: 120px; }
.inline-input { width: 120px; }
.input-with-hint { display: flex; align-items: center; gap: 1rem; flex-wrap: wrap; }
.hint-inline { font-size: 0.75rem; color: var(--text-tertiary); }
textarea { width: 100%; padding: 0.75rem; border: 1px solid var(--border-input); border-radius: 8px; background: var(--bg-input); color: var(--text-primary); font-size: 0.85rem; resize: vertical; min-height: 80px; box-sizing: border-box; }
.hint-block { font-size: 0.75rem; color: var(--text-tertiary); margin-top: 0.5rem; }
/* 表格容器 */
.settings-table-container { background: var(--bg-primary); border: 1px solid var(--border-light); border-radius: 12px; overflow: hidden; }
.settings-table { width: 100%; border-collapse: collapse; }
.settings-table th { text-align: left; padding: 0.85rem 1rem; background: var(--bg-secondary); font-weight: 600; font-size: 0.8rem; color: var(--text-secondary); border-bottom: 1px solid var(--border-light); }
.settings-table td { padding: 0.85rem 1rem; border-bottom: 1px solid var(--border-light); vertical-align: middle; }
.settings-table tr:last-child td { border-bottom: none; }
.settings-table tr:hover td { background: var(--bg-hover); }
/* 列宽 */
.name-col { width: 15%; min-width: 120px; }
.info-col { width: 55%; min-width: 150px; }
.switch-col { text-align: center; width: 10%; }
.action-col { text-align: center; width: 10%; }
.ops-col { width: 10%; min-width: 180px; }
/* Provider 单元格 */
.provider-name { font-weight: 600; font-size: 0.9rem; color: var(--text-primary); }
.provider-badges { display: flex; gap: 0.35rem; margin-top: 0.35rem; }
.badge { display: inline-block; padding: 0.15rem 0.5rem; border-radius: 20px; font-size: 0.65rem; font-weight: 500; }
.badge.default { background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%); color: white; }
.badge.enabled { background: rgba(52, 211, 153, 0.2); color: #16a34a; }
.badge.disabled { background: var(--bg-tertiary); color: var(--text-tertiary); }
.info-item { font-size: 0.8rem; color: var(--text-primary); word-break: break-all; }
.info-item.sub { font-size: 0.75rem; color: var(--text-secondary); margin-top: 0.2rem; }
/* 开关样式已移至全局 style.css */
/* 操作按钮 */
.ops-buttons { display: flex; flex-wrap: nowrap; gap: 0.5rem; }
.btn-op { padding: 0.4rem 0.75rem; background: var(--bg-secondary); border: 1px solid var(--border-light); border-radius: 6px; font-size: 0.8rem; color: var(--text-secondary); cursor: pointer; transition: all 0.2s; white-space: nowrap; }
.btn-op:hover { background: var(--bg-hover); color: var(--text-primary); }
.btn-op.btn-danger { color: var(--danger-color); border-color: var(--danger-bg); }
.btn-op.btn-danger:hover { background: var(--danger-bg); }
/* 用户信息区域按钮 */
.btn-action { padding: 0.45rem 0.9rem; background: var(--bg-secondary); border: 1px solid var(--border-light); border-radius: 6px; font-size: 0.8rem; color: var(--text-primary); cursor: pointer; transition: all 0.2s; }
.btn-action:hover { background: var(--bg-hover); border-color: var(--accent-primary); }
.btn-action.btn-logout { color: var(--danger-color); }
.btn-action.btn-logout:hover { background: var(--danger-bg); border-color: var(--danger-color); }
/* 主要按钮 */
.btn-primary { padding: 0.5rem 1rem; background: var(--accent-primary); color: white; border: none; border-radius: 6px; font-size: 0.85rem; cursor: pointer; transition: all 0.2s; }
.btn-primary:hover { background: var(--accent-primary-hover); }
.btn-secondary { padding: 0.5rem 1rem; background: var(--bg-secondary); color: var(--text-primary); border: 1px solid var(--border-light); border-radius: 6px; font-size: 0.85rem; cursor: pointer; }
.btn-secondary:hover { background: var(--bg-hover); }
/* 模态框 */
.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-primary); border-radius: 16px; padding: 1.25rem; width: 100%; max-width: 400px; }
.modal h2 { margin: 0 0 1.25rem; color: var(--text-primary); 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-light); border-top-color: var(--accent-primary); 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: rgba(52, 211, 153, 0.2); color: #16a34a; }
.result-modal.error .result-icon { background: var(--danger-bg); color: var(--danger-color); }
.result-modal h2 { margin: 0 0 0.75rem; }
.result-modal .result-message { color: var(--text-secondary); margin-bottom: 1rem; font-size: 0.9rem; }
.result-modal .result-json { background: var(--bg-code); border: 1px solid var(--border-light); 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; }
.form-group { margin-bottom: 1rem; }
.form-group label { display: block; margin-bottom: 0.5rem; font-weight: 500; color: var(--text-primary); font-size: 0.9rem; }
.form-group input { width: 100%; padding: 0.65rem; border: 1px solid var(--border-input); border-radius: 8px; background: var(--bg-input); box-sizing: border-box; color: var(--text-primary); font-size: 0.9rem; }
.form-group .hint { font-size: 0.75rem; color: var(--text-tertiary); margin-top: 4px; display: block; }
.switch-card { display: flex; align-items: center; justify-content: space-between; padding: 1rem 1.25rem; background: var(--bg-secondary); border: 1px solid var(--border-light); border-radius: 10px; cursor: pointer; transition: all 0.2s; }
.switch-card:hover { border-color: var(--accent-primary); }
.switch-card.active { border-color: var(--accent-primary); background: var(--accent-primary-light); }
.switch-content { display: flex; flex-direction: column; gap: 0.25rem; }
.switch-title { font-weight: 600; color: var(--text-primary); }
.switch-desc { font-size: 0.8rem; color: var(--text-secondary); }
.optional { color: var(--text-tertiary); font-weight: normal; font-size: 0.8rem; }
.error { color: var(--danger-color); background: var(--danger-bg); padding: 0.75rem; border-radius: 8px; margin-top: 0.75rem; font-size: 0.85rem; }
/* 加载状态 */
.loading, .empty-card { text-align: center; padding: 3rem; color: var(--text-secondary); background: var(--bg-primary); border: 1px solid var(--border-light); border-radius: 12px; }
.loading-small { padding: 1.5rem; text-align: center; color: var(--text-secondary); }
.spinner-small { width: 24px; height: 24px; border: 3px solid var(--border-light); border-top-color: var(--accent-primary); border-radius: 50%; animation: spin 1s linear infinite; margin: 0 auto 0.5rem; }
.spinner { width: 40px; height: 40px; border: 3px solid var(--border-light); border-top-color: var(--accent-primary); border-radius: 50%; animation: spin 1s linear infinite; margin: 0 auto 1rem; }
</style>