Files
Nexus/frontend/src/pages/SettingsPage.vue
T
Your Name e3eb0e65a5 fix(ui): 仪表盘/审计/调度/重试修复与终端快捷命令改版
统一终端页与全局侧栏菜单;快捷命令仅在终端执行,增删改排序迁入系统设置;命令输入区加高与 Enter/Shift+Enter 行为;后端自定义命令优先与 reorder API。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-01 21:56:07 +08:00

435 lines
17 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>
<v-container fluid class="pa-6">
<v-row>
<!-- System Settings -->
<v-col cols="12" lg="6">
<v-card elevation="0" border rounded="lg" class="mb-4">
<v-card-title class="d-flex align-center">
<v-icon class="mr-2">mdi-cog</v-icon>
系统设置
</v-card-title>
<v-progress-linear v-if="settingsLoading" indeterminate color="primary" />
<v-divider />
<v-card-text>
<v-text-field v-model="settings.system_name" label="系统名称" variant="outlined" density="compact" class="mb-3" :rules="[required('系统名称')]" />
<v-text-field v-model="settings.system_title" label="系统标题" variant="outlined" density="compact" class="mb-3" :rules="[required('系统标题')]" />
<v-divider class="my-4" />
<div class="text-subtitle-2 mb-3">告警阈值</div>
<v-row dense>
<v-col cols="4">
<v-text-field v-model="settings.cpu_alert_threshold" label="CPU %" variant="outlined" density="compact" type="number" :rules="[required('阈值'), numberRange(1, 100, '阈值')]" />
</v-col>
<v-col cols="4">
<v-text-field v-model="settings.mem_alert_threshold" label="内存 %" variant="outlined" density="compact" type="number" :rules="[required('阈值'), numberRange(1, 100, '阈值')]" />
</v-col>
<v-col cols="4">
<v-text-field v-model="settings.disk_alert_threshold" label="磁盘 %" variant="outlined" density="compact" type="number" :rules="[required('阈值'), numberRange(1, 100, '阈值')]" />
</v-col>
</v-row>
<v-divider class="my-4" />
<div class="text-subtitle-2 mb-3">连接池</div>
<v-row dense>
<v-col cols="6">
<v-text-field v-model="settings.db_pool_size" label="连接池大小" variant="outlined" density="compact" type="number" :rules="[required(), isNumber()]" />
</v-col>
<v-col cols="6">
<v-text-field v-model="settings.db_max_overflow" label="溢出连接数" variant="outlined" density="compact" type="number" :rules="[required(), isNumber()]" />
</v-col>
</v-row>
</v-card-text>
<v-card-actions class="px-4 pb-4">
<v-spacer />
<v-btn color="primary" variant="flat" @click="saveSettings" :loading="saving">保存设置</v-btn>
</v-card-actions>
</v-card>
<TerminalQuickCommandsSettings />
<!-- Telegram Settings -->
<v-card elevation="0" border rounded="lg">
<v-card-title class="d-flex align-center">
<v-icon class="mr-2">mdi-send</v-icon>
Telegram 推送
</v-card-title>
<v-divider />
<v-card-text>
<v-text-field v-model="settings.telegram_bot_token" label="Bot Token" variant="outlined" density="compact" class="mb-3" :type="showToken ? 'text' : 'password'" :append-inner-icon="showToken ? 'mdi-eye' : 'mdi-eye-off'" @click:append-inner="showToken = !showToken" />
<v-text-field v-model="settings.telegram_chat_id" label="Chat ID" variant="outlined" density="compact" class="mb-3" />
<v-btn size="small" variant="tonal" prepend-icon="mdi-message-text" @click="testTelegram" :loading="testingTg">发送测试消息</v-btn>
</v-card-text>
</v-card>
</v-col>
<!-- Security Settings -->
<v-col cols="12" lg="6">
<!-- Password Change -->
<v-card elevation="0" border rounded="lg" class="mb-4">
<v-card-title class="d-flex align-center">
<v-icon class="mr-2">mdi-lock</v-icon>
修改密码
</v-card-title>
<v-divider />
<v-card-text>
<v-text-field v-model="pwForm.current" label="当前密码" variant="outlined" density="compact" type="password" class="mb-3" :rules="[required('密码')]" />
<v-text-field v-model="pwForm.new_pw" label="新密码" variant="outlined" density="compact" type="password" class="mb-3" :rules="[required('密码')]" />
<v-text-field v-model="pwForm.confirm" label="确认新密码" variant="outlined" density="compact" type="password" class="mb-3" :rules="[required('确认密码'), matchOther(() => pwForm.new_pw)]" />
<v-text-field
v-if="auth.admin?.totp_enabled"
v-model="pwForm.totp_code"
label="TOTP 验证码"
variant="outlined"
density="compact"
type="text"
inputmode="numeric"
maxlength="6"
class="mb-3"
:rules="auth.admin?.totp_enabled ? [required('验证码')] : []"
/>
</v-card-text>
<v-card-actions class="px-4 pb-4">
<v-spacer />
<v-btn color="primary" variant="flat" @click="changePassword" :loading="changingPw">修改密码</v-btn>
</v-card-actions>
</v-card>
<!-- TOTP -->
<v-card elevation="0" border rounded="lg" class="mb-4">
<v-card-title class="d-flex align-center">
<v-icon class="mr-2">mdi-shield-key</v-icon>
双因素认证 (TOTP)
</v-card-title>
<v-divider />
<v-card-text>
<div class="d-flex align-center mb-3">
<span class="text-body-2">状态</span>
<v-chip :color="auth.admin?.totp_enabled ? 'success' : 'grey'" size="small" variant="tonal" label class="ml-2">
{{ auth.admin?.totp_enabled ? '已启用' : '未启用' }}
</v-chip>
</div>
<div v-if="totpQr" class="text-center mb-3">
<img :src="totpQr" alt="TOTP QR" style="max-width: 200px" />
<div v-if="totpSecret" class="d-flex align-center justify-center ga-2 mt-2">
<code class="text-caption pa-1 bg-grey-lighten-4 rounded" style="user-select: all">{{ totpSecret }}</code>
<v-btn size="x-small" variant="text" icon="mdi-content-copy" @click="copyTotpSecret" />
</div>
<div class="text-caption text-medium-emphasis mt-1">用认证器 App 扫描二维码或手动输入密钥</div>
</div>
<div v-if="!auth.admin?.totp_enabled">
<v-btn v-if="!totpQr" size="small" variant="tonal" prepend-icon="mdi-qrcode" @click="setupTotp">生成二维码</v-btn>
<div v-else class="d-flex ga-2 align-center">
<v-text-field v-model="totpCode" label="验证码" variant="outlined" density="compact" style="max-width: 160px" />
<v-btn color="primary" variant="flat" size="small" @click="enableTotp" :loading="enablingTotp">启用</v-btn>
</div>
</div>
<v-btn v-else size="small" variant="tonal" color="error" prepend-icon="mdi-shield-off" @click="disableTotp">禁用 TOTP</v-btn>
</v-card-text>
</v-card>
<!-- API Key -->
<v-card elevation="0" border rounded="lg" class="mb-4">
<v-card-title class="d-flex align-center">
<v-icon class="mr-2">mdi-key</v-icon>
API Key
</v-card-title>
<v-divider />
<v-card-text>
<div class="d-flex align-center ga-2">
<v-text-field :model-value="showApiKey ? apiKeyValue : '••••••••'" label="API Key" variant="outlined" density="compact" readonly :type="showApiKey ? 'text' : 'password'" style="max-width: 320px" />
<v-btn variant="text" size="small" :icon="showApiKey ? 'mdi-eye-off' : 'mdi-eye'" @click="revealApiKey" />
<v-btn variant="text" size="small" icon="mdi-content-copy" @click="copyApiKey" />
</div>
</v-card-text>
</v-card>
<!-- IP Allowlist -->
<v-card elevation="0" border rounded="lg">
<v-card-title class="d-flex align-center">
<v-icon class="mr-2">mdi-ip-network</v-icon>
IP 白名单
</v-card-title>
<v-divider />
<v-card-text>
<v-switch v-model="ipAllowlistEnabled" label="启用 IP 白名单" color="primary" density="compact" hide-details class="mb-3" @update:model-value="toggleAllowlist" />
<v-textarea v-model="ipList" label="IP 列表(每行一个)" variant="outlined" density="compact" rows="4" class="mb-2" />
<v-btn size="small" variant="tonal" @click="saveAllowlist" :loading="savingAllowlist">保存</v-btn>
</v-card-text>
</v-card>
</v-col>
</v-row>
<!-- API Key Reveal Dialog -->
<v-dialog v-model="showRevealDialog" max-width="400">
<v-card border>
<v-card-title>验证身份</v-card-title>
<v-card-text>
<div class="text-body-2 text-medium-emphasis mb-3">请输入当前密码以查看 API Key</div>
<v-text-field v-model="revealPassword" label="当前密码" variant="outlined" density="compact" type="password" autofocus @keydown.enter="doRevealApiKey" :rules="[required('密码')]" />
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="showRevealDialog = false">取消</v-btn>
<v-btn color="primary" variant="flat" @click="doRevealApiKey" :loading="revealingKey">确认</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- TOTP Disable Dialog -->
<v-dialog v-model="showDisableTotp" max-width="400">
<v-card border>
<v-card-title>禁用 TOTP</v-card-title>
<v-card-text>
<v-text-field v-model="disableTotpPassword" label="当前密码" variant="outlined" density="compact" type="password" class="mb-2" :rules="[required()]" />
<v-text-field v-model="disableTotpCode" label="验证码" variant="outlined" density="compact" :rules="[required()]" />
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="showDisableTotp = false">取消</v-btn>
<v-btn color="error" variant="flat" @click="doDisableTotp" :loading="disablingTotp">禁用</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</v-container>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { http } from '@/api'
import { useAuthStore } from '@/stores/auth'
import { useSnackbar } from '@/composables/useSnackbar'
import { required, numberRange, isNumber, matchOther } from '@/utils/validation'
import TerminalQuickCommandsSettings from '@/components/TerminalQuickCommandsSettings.vue'
import type { SettingsResponse, AllowlistResponse, SettingItem } from '@/types/api'
const snackbar = useSnackbar()
const auth = useAuthStore()
// ── Settings ──
const settingsLoading = ref(false)
const settings = ref({
system_name: '', system_title: '',
cpu_alert_threshold: 80, mem_alert_threshold: 80, disk_alert_threshold: 80,
db_pool_size: 10, db_max_overflow: 20,
telegram_bot_token: '', telegram_chat_id: '',
})
const saving = ref(false)
const testingTg = ref(false)
const showToken = ref(false)
// ── Password ──
const pwForm = ref({ current: '', new_pw: '', confirm: '', totp_code: '' })
const changingPw = ref(false)
// ── TOTP ──
const totpQr = ref('')
const totpSecret = ref('')
const totpCode = ref('')
const enablingTotp = ref(false)
const showDisableTotp = ref(false)
const disableTotpPassword = ref('')
const disableTotpCode = ref('')
const disablingTotp = ref(false)
// ── API Key ──
const showApiKey = ref(false)
const apiKeyValue = ref('')
const showRevealDialog = ref(false)
const revealPassword = ref('')
const revealingKey = ref(false)
// ── IP Allowlist ──
const ipAllowlistEnabled = ref(false)
const ipList = ref('')
const savingAllowlist = ref(false)
// ── Data loading ──
async function loadSettings() {
settingsLoading.value = true
try {
const res = await http.get<SettingsResponse>('/settings/')
const items: SettingItem[] = Array.isArray(res) ? res : []
const knownKeys = Object.keys(settings.value)
for (const item of items) {
const key = item.key
if (key && knownKeys.includes(key)) {
const val = item.value
// Type-cast numeric strings back to numbers for number inputs
if (typeof val === 'string' && /^\d+(\.\d+)?$/.test(val)) {
(settings.value as Record<string, unknown>)[key] = Number(val)
} else {
(settings.value as Record<string, unknown>)[key] = val
}
}
}
} catch {
snackbar('加载设置失败', 'error')
} finally {
settingsLoading.value = false
}
}
async function loadAllowlist() {
try {
const res = await http.get<AllowlistResponse>('/settings/ip-allowlist')
ipAllowlistEnabled.value = res.enabled || false
ipList.value = ([...(res.manual_ips || []), ...(res.subscription_ips || [])]).join('\n')
} catch {
snackbar('加载白名单失败', 'error')
}
}
// ── Actions ──
async function saveSettings() {
saving.value = true
try {
const entries = Object.entries(settings.value).filter(([key]) =>
['system_name', 'system_title', 'cpu_alert_threshold', 'mem_alert_threshold', 'disk_alert_threshold', 'db_pool_size', 'db_max_overflow', 'telegram_bot_token', 'telegram_chat_id'].includes(key)
)
for (const [key, value] of entries) {
await http.put(`/settings/${key}`, { value })
}
snackbar('设置已保存')
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : '保存失败'
snackbar(msg, 'error')
} finally { saving.value = false }
}
async function testTelegram() {
testingTg.value = true
try {
await http.post('/settings/telegram/test')
snackbar('测试消息已发送')
} catch (e: any) { snackbar(e.message || '发送失败', 'error') }
finally { testingTg.value = false }
}
async function changePassword() {
if (pwForm.value.new_pw !== pwForm.value.confirm) {
snackbar('两次密码不一致', 'error')
return
}
changingPw.value = true
try {
const body: Record<string, string> = {
current_password: pwForm.value.current,
new_password: pwForm.value.new_pw,
}
if (auth.admin?.totp_enabled && pwForm.value.totp_code) {
body.totp_code = pwForm.value.totp_code
}
await http.put('/auth/password', body)
pwForm.value = { current: '', new_pw: '', confirm: '', totp_code: '' }
snackbar('密码已修改')
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : '修改失败'
snackbar(msg, 'error')
} finally { changingPw.value = false }
}
async function setupTotp() {
try {
const res = await http.post<{ qr_code: string; secret?: string }>('/auth/totp/setup')
totpQr.value = res.qr_code
totpSecret.value = res.secret || ''
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : '生成失败'
snackbar(msg, 'error')
}
}
async function enableTotp() {
enablingTotp.value = true
try {
await http.post('/auth/totp/enable', { code: totpCode.value })
await auth.fetchProfile()
totpQr.value = ''
totpCode.value = ''
snackbar('TOTP 已启用')
} catch (e: any) { snackbar(e.message || '启用失败', 'error') }
finally { enablingTotp.value = false }
}
function disableTotp() {
showDisableTotp.value = true
}
async function doDisableTotp() {
disablingTotp.value = true
try {
await http.post('/auth/totp/disable', {
admin_id: auth.admin?.id,
current_password: disableTotpPassword.value,
totp_code: disableTotpCode.value,
})
await auth.fetchProfile()
showDisableTotp.value = false
disableTotpPassword.value = ''
disableTotpCode.value = ''
snackbar('TOTP 已禁用')
} catch (e: any) { snackbar(e.message || '禁用失败', 'error') }
finally { disablingTotp.value = false }
}
function copyTotpSecret() {
if (totpSecret.value) {
navigator.clipboard.writeText(totpSecret.value)
snackbar('密钥已复制')
}
}
async function revealApiKey() {
if (showApiKey.value) { showApiKey.value = false; return }
showRevealDialog.value = true
revealPassword.value = ''
}
async function doRevealApiKey() {
if (!revealPassword.value) {
snackbar('请输入密码', 'error')
return
}
revealingKey.value = true
try {
const res = await http.post<{ api_key: string }>('/settings/api-key/reveal', { current_password: revealPassword.value })
apiKeyValue.value = res.api_key
showApiKey.value = true
showRevealDialog.value = false
revealPassword.value = ''
} catch (e: any) { snackbar(e.message || '验证失败', 'error') }
finally { revealingKey.value = false }
}
function copyApiKey() {
if (apiKeyValue.value) {
navigator.clipboard.writeText(apiKeyValue.value)
snackbar('已复制到剪贴板')
}
}
async function toggleAllowlist() {
try {
await http.post('/settings/ip-allowlist/toggle', { enabled: ipAllowlistEnabled.value })
} catch (e: any) { snackbar(e.message || '操作失败', 'error') }
}
async function saveAllowlist() {
savingAllowlist.value = true
try {
const ips = ipList.value.split('\n').map(s => s.trim()).filter(Boolean)
await http.post('/settings/ip-allowlist', { ips })
snackbar('白名单已保存')
} catch (e: any) { snackbar(e.message || '保存失败', 'error') }
finally { savingAllowlist.value = false }
}
onMounted(() => {
loadSettings()
loadAllowlist()
auth.fetchProfile()
})
</script>