e3eb0e65a5
统一终端页与全局侧栏菜单;快捷命令仅在终端执行,增删改排序迁入系统设置;命令输入区加高与 Enter/Shift+Enter 行为;后端自定义命令优先与 reorder API。 Co-authored-by: Cursor <cursoragent@cursor.com>
435 lines
17 KiB
Vue
435 lines
17 KiB
Vue
<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>
|