Luxx/dashboard/src/views/SettingsView.vue

727 lines
28 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 v-if="loadingUser" class="loading-small"><div class="spinner-small"></div>加载中...</div>
<div v-else class="settings-table-container">
<table class="settings-table">
<thead>
<tr>
<th class="setting-key-col">项目</th>
<th>值</th>
</tr>
</thead>
<tbody>
<tr>
<td class="setting-key-col"><div class="setting-label">用户名</div></td>
<td>{{ userForm.username || '-' }}</td>
</tr>
<tr>
<td class="setting-key-col"><div class="setting-label">邮箱</div></td>
<td>{{ userForm.email || '-' }}</td>
</tr>
</tbody>
<tfoot>
<tr>
<td colspan="2" class="table-footer">
<button @click="openUserModal" class="btn-op">编辑资料</button>
<button @click="handleLogout" class="btn-op btn-danger">退出登录</button>
</td>
</tr>
</tfoot>
</table>
</div>
</div>
<!-- 外观设置 -->
<div class="settings-section">
<div class="section-title">
<span class="section-icon">🎨</span>
<span class="section-text">外观</span>
</div>
<div class="settings-table-container">
<table class="settings-table">
<thead>
<tr>
<th class="setting-key-col">设置项</th>
<th>值</th>
</tr>
</thead>
<tbody>
<tr>
<td class="setting-key-col">
<div class="setting-label">夜间模式</div>
<div class="setting-desc">切换深色/浅色主题</div>
</td>
<td>
<label class="switch" @click.prevent="toggleTheme">
<input type="checkbox" v-model="isDark" />
<span class="slider"></span>
</label>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- 模型设置 -->
<div class="settings-section">
<div class="section-title">
<span class="section-icon">🤖</span>
<span class="section-text">模型设置</span>
</div>
<div class="settings-table-container">
<table class="settings-table">
<thead>
<tr>
<th class="setting-key-col">设置项</th>
<th>值</th>
</tr>
</thead>
<tbody>
<tr>
<td class="setting-key-col">
<div class="setting-label">默认 Provider</div>
<div class="setting-desc">选择默认使用的 LLM Provider</div>
</td>
<td>
<select v-model="modelSettings.default_provider_id" class="inline-select" @change="saveDefaultProvider">
<option :value="null" disabled>选择 Provider</option>
<option v-for="p in providers" :key="p.id" :value="p.id">
{{ p.name }} ({{ p.default_model }})
</option>
</select>
</td>
</tr>
<tr>
<td class="setting-key-col"><div class="setting-label">温度 (Temperature)</div></td>
<td><input v-model.number="modelSettings.temperature" type="number" min="0" max="2" step="0.1" class="inline-input" /></td>
</tr>
<tr>
<td class="setting-key-col"><div class="setting-label">最大 Tokens</div></td>
<td><input v-model.number="modelSettings.max_tokens" type="number" min="100" max="32000" class="inline-input" /></td>
</tr>
<tr>
<td class="setting-key-col">
<div class="setting-label">推理模式</div>
<div class="setting-desc">使用 CoT 推理,消耗更多 token 但更准确</div>
</td>
<td>
<label class="switch" @click.prevent="modelSettings.thinking_enabled = !modelSettings.thinking_enabled">
<input v-model="modelSettings.thinking_enabled" type="checkbox" />
<span class="slider"></span>
</label>
</td>
</tr>
<tr>
<td class="setting-key-col">
<div class="setting-label">系统提示词</div>
<div class="setting-desc">设置默认系统提示词,可在新建会话时覆盖</div>
</td>
<td>
<textarea v-model="modelSettings.system_prompt" rows="3" placeholder="You are a helpful assistant." class="table-textarea"></textarea>
</td>
</tr>
</tbody>
<tfoot>
<tr>
<td colspan="2" class="table-footer">
<button @click="saveModelSettings" class="btn-primary">保存设置</button>
</td>
</tr>
</tfoot>
</table>
</div>
</div>
<!-- LLM Provider 管理 -->
<div class="settings-section">
<div class="section-title">
<span class="section-icon">🔌</span>
<span class="section-text">LLM Provider</span>
</div>
<div v-if="loading" class="loading"><div class="spinner"></div>加载中...</div>
<div v-else-if="error" class="error">{{ error }}</div>
<div v-else class="settings-table-container">
<table class="settings-table">
<thead>
<tr>
<th>名称</th>
<th>API / 模型</th>
<th class="switch-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 badge-default">默认</span>
<span v-if="p.enabled" class="badge badge-enabled">启用</span>
<span v-else class="badge 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>
<tfoot>
<tr>
<td colspan="4" class="table-footer">
<button @click="showModal = true" class="btn-primary">+ 添加 Provider</button>
</td>
</tr>
</tfoot>
</table>
</div>
</div>
<!-- 用户管理 (仅管理员可见) -->
<div v-if="isAdmin" class="settings-section">
<div class="section-title">
<span class="section-icon">👥</span>
<span class="section-text">用户管理</span>
</div>
<div v-if="loadingUsers" class="loading"><div class="spinner"></div>加载中...</div>
<div v-else-if="usersError" class="error">{{ usersError }}</div>
<div v-else-if="users.length" class="settings-table-container">
<table class="settings-table">
<thead>
<tr>
<th>用户名</th>
<th>邮箱</th>
<th>角色</th>
<th>权限级别</th>
<th class="ops-col">操作</th>
</tr>
</thead>
<tbody>
<tr v-for="u in users" :key="u.id">
<td class="name-col">{{ u.username }}</td>
<td class="info-col">{{ u.email || '-' }}</td>
<td>{{ u.role }}</td>
<td>
<select v-model="u.permission_level" class="inline-select permission-select" @change="updateUserPermission(u)">
<option :value="1">只读 (READ_ONLY)</option>
<option :value="2">写入 (WRITE)</option>
<option :value="3">执行 (EXECUTE)</option>
<option :value="4">管理员 (ADMIN)</option>
</select>
</td>
<td class="ops-col">
<span class="permission-label">{{ getPermissionName(u.permission_level) }}</span>
</td>
</tr>
</tbody>
</table>
</div>
<div v-else class="empty-card">暂无用户</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 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, user: currentUser } = 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 isAdmin = ref(false)
const users = ref([])
const loadingUsers = ref(false)
const usersError = ref('')
const modelSettings = ref({
default_provider_id: null,
temperature: 0.7,
max_tokens: 8192,
thinking_enabled: false,
system_prompt: 'You are a helpful assistant.'
})
const settingsLoaded = 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 saveModelSettings = async () => {
try {
await authAPI.updateSettings({
default_provider_id: modelSettings.value.default_provider_id,
temperature: modelSettings.value.temperature,
max_tokens: modelSettings.value.max_tokens,
thinking_enabled: modelSettings.value.thinking_enabled,
system_prompt: modelSettings.value.system_prompt
})
alert('设置已保存')
} catch (e) {
alert('保存失败: ' + e.message)
}
}
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
})
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 fetchSettings = async () => {
try {
const res = await authAPI.getSettings()
if (res.success && res.data) {
modelSettings.value = {
default_provider_id: res.data.default_provider_id,
temperature: res.data.temperature,
max_tokens: res.data.max_tokens,
thinking_enabled: res.data.thinking_enabled,
system_prompt: res.data.system_prompt
}
settingsLoaded.value = true
}
} catch (e) {
// 首次使用可能没有设置,用默认值
console.warn('获取设置失败,使用默认值:', e)
}
}
const closeModal = () => {
showModal.value = false
editing.value = null
form.value = { name: '', base_url: '', api_key: '', default_model: '', max_tokens: 8192 }
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: '', // 不显示原密码
default_model: res.data.default_model,
max_tokens: res.data.max_tokens || 8192
}
}
} catch (e) {
console.error('获取Provider详情失败:', e)
}
showModal.value = true
}
const saveProvider = async () => {
if (!form.value.base_url || !form.value.default_model) {
formError.value = '请填写必填项Base URL 和模型名称)'
return
}
// 编辑时 api_key 可以为空(表示不修改密码)
if (!editing.value && !form.value.api_key) {
formError.value = '请填写 API Key'
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) }
}
const saveDefaultProvider = async () => {
try {
await authAPI.updateSettings({ default_provider_id: modelSettings.value.default_provider_id })
} catch (e) {
alert('设置默认 Provider 失败: ' + e.message)
}
}
const fetchUsers = async () => {
loadingUsers.value = true
usersError.value = ''
try {
const res = await authAPI.listUsers()
if (res.success) {
users.value = res.data.users || []
} else if (res.detail === 'Admin permission required' || res.message === 'Admin permission required') {
isAdmin.value = false
} else {
usersError.value = res.message || res.detail || '获取用户列表失败'
}
} catch (e) {
// 403 错误静默处理,设置为非管理员
isAdmin.value = false
loadingUsers.value = false
}
}
const updateUserPermission = async (user) => {
try {
const res = await authAPI.updateUserPermission(user.id, { permission_level: user.permission_level })
if (!res.success) {
alert(res.message || '更新权限失败')
}
} catch (e) {
alert('更新权限失败: ' + e.message)
}
}
const getPermissionName = (level) => {
const names = { 1: '只读', 2: '写入', 3: '执行', 4: '管理员' }
return names[level] || '未知'
}
onMounted(() => {
fetchUserInfo()
fetchSettings()
fetchProviders()
fetchUsers()
// 检查是否是管理员
const userData = localStorage.getItem('user')
if (userData) {
try {
const user = JSON.parse(userData)
isAdmin.value = user.permission_level === 4
} catch (e) {}
}
})
</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); }
/* 内联输入框 */
.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; }
.inline-input { width: 120px; }
/* 表格容器 */
.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: 60%; min-width: 200px; }
.switch-col { text-align: center; width: 80px; }
.ops-col { width: 15%; min-width: 180px; text-align: center; }
.setting-key-col { width: 25%; min-width: 160px; }
/* 设置项标签 */
.setting-label { font-weight: 500; color: var(--text-primary); font-size: 0.9rem; }
.setting-desc { font-size: 0.75rem; color: var(--text-secondary); margin-top: 0.15rem; }
/* 表格内 textarea */
.table-textarea { width: 100%; padding: 0.5rem; border: 1px solid var(--border-input); border-radius: 6px; background: var(--bg-input); color: var(--text-primary); font-size: 0.85rem; resize: vertical; min-height: 60px; box-sizing: border-box; }
/* 表格底部 */
.table-footer { text-align: right; padding: 0.75rem 1rem; background: var(--bg-secondary); border-top: 1px solid var(--border-light); }
/* 空行提示 */
/* 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: #fef3c7; color: #92400e; border: 1px solid #fcd34d; }
.badge-enabled { background: #d1fae5; color: #065f46; border: 1px solid #6ee7b7; }
.badge-disabled { background: var(--bg-tertiary); color: var(--text-tertiary); border: 1px solid var(--border-light); }
.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; }
/* 操作按钮 */
.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-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; }
.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; }
/* 权限选择器 */
.permission-select { min-width: 140px; }
.permission-label { font-size: 0.8rem; color: var(--text-secondary); }
</style>