632891e6e5
v-form 保存前 validate;validation_errors_zh 翻译 Pydantic msg;value 数字 coerce。 Co-authored-by: Cursor <cursoragent@cursor.com>
929 lines
36 KiB
Vue
929 lines
36 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-form ref="systemSettingsFormRef" @submit.prevent="saveSettings">
|
||
<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('连接池大小'), numberRange(1, 1000, '连接池大小')]" />
|
||
</v-col>
|
||
<v-col cols="6">
|
||
<v-text-field v-model="settings.db_max_overflow" label="溢出连接数" variant="outlined" density="compact" type="number" :rules="[required('溢出连接数'), numberRange(1, 1000, '溢出连接数')]" />
|
||
</v-col>
|
||
</v-row>
|
||
</v-card-text>
|
||
<v-card-actions class="px-4 pb-4">
|
||
<v-spacer />
|
||
<v-btn type="submit" color="primary" variant="flat" :loading="saving">保存设置</v-btn>
|
||
</v-card-actions>
|
||
</v-form>
|
||
</v-card>
|
||
|
||
<TerminalQuickCommandsSettings />
|
||
|
||
<v-card elevation="0" border rounded="lg" class="mb-4">
|
||
<v-card-title class="d-flex align-center">
|
||
<v-icon class="mr-2">mdi-bell-ring-outline</v-icon>
|
||
界面提示
|
||
</v-card-title>
|
||
<v-divider />
|
||
<v-card-text>
|
||
<p class="text-caption text-medium-emphasis mb-3">
|
||
脚本批量执行全部结束时,在浏览器播放提示音(仅当前登录的管理员会话,与 Telegram 推送无关)。
|
||
</p>
|
||
<v-select
|
||
v-model="scriptExecCompleteSound"
|
||
:items="scriptCompleteSoundOptions"
|
||
item-title="title"
|
||
item-value="value"
|
||
label="脚本执行完成提示音"
|
||
variant="outlined"
|
||
density="compact"
|
||
hide-details
|
||
class="mb-3"
|
||
:loading="scriptSoundSaving"
|
||
:disabled="scriptSoundSaving"
|
||
@update:model-value="onScriptExecSoundChange"
|
||
/>
|
||
<v-btn
|
||
size="small"
|
||
variant="tonal"
|
||
prepend-icon="mdi-volume-high"
|
||
:disabled="scriptExecCompleteSound === 'off' || scriptSoundSaving"
|
||
@click="previewScriptExecSound"
|
||
>
|
||
试听
|
||
</v-btn>
|
||
</v-card-text>
|
||
</v-card>
|
||
|
||
<!-- 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="telegramTokenDraft"
|
||
label="Bot Token"
|
||
variant="outlined"
|
||
density="compact"
|
||
class="mb-3"
|
||
:placeholder="telegramTokenSet ? '已配置(留空保持不变)' : '输入 Bot Token'"
|
||
:type="showToken ? 'text' : 'password'"
|
||
:append-inner-icon="showToken ? 'mdi-eye-off' : 'mdi-eye'"
|
||
@click:append-inner="toggleTelegramTokenVisibility"
|
||
/>
|
||
<v-chip v-if="telegramTokenSet && !telegramTokenDraft" size="small" color="success" variant="tonal" class="mb-3">Token 已配置</v-chip>
|
||
<v-text-field v-model="settings.telegram_chat_id" label="Chat ID" variant="outlined" density="compact" class="mb-3" />
|
||
<p class="text-caption text-medium-emphasis mb-3">
|
||
在 Telegram 向 Bot 发送任意消息后,可点击下方按钮自动检测 Chat ID。
|
||
</p>
|
||
<div class="d-flex flex-wrap ga-2 mb-4">
|
||
<v-btn size="small" variant="tonal" prepend-icon="mdi-radar" @click="detectTelegramChats" :loading="detectingTgChats">检测 Chat ID</v-btn>
|
||
<v-btn size="small" variant="tonal" prepend-icon="mdi-message-text" @click="testTelegram" :loading="testingTg">发送测试消息</v-btn>
|
||
<v-btn size="small" variant="tonal" color="primary" prepend-icon="mdi-content-save" @click="saveTelegramConfig" :loading="savingTelegram">保存 Telegram 配置</v-btn>
|
||
</div>
|
||
|
||
<v-divider class="my-3" />
|
||
<div class="text-subtitle-2 mb-1">告警通知项目</div>
|
||
<p class="text-caption text-medium-emphasis mb-3">
|
||
关闭后该类告警仍记录日志,但不发送 Telegram 消息。Bot Token 未配置时全部静默。
|
||
</p>
|
||
<template v-for="(groupItems, groupName) in notifyToggleGroups" :key="groupName">
|
||
<div class="text-caption text-medium-emphasis font-weight-medium mt-2 mb-1">{{ groupName }}</div>
|
||
<v-switch
|
||
v-for="item in groupItems"
|
||
:key="item.key"
|
||
:model-value="notifyToggles[item.key] ?? true"
|
||
:label="item.label"
|
||
:hint="item.desc"
|
||
persistent-hint
|
||
color="primary"
|
||
density="compact"
|
||
hide-details="auto"
|
||
class="mb-1"
|
||
:disabled="notifyToggleSaving === item.key"
|
||
@update:model-value="(v: boolean | null) => toggleNotify(item.key, Boolean(v))"
|
||
/>
|
||
</template>
|
||
|
||
<v-divider class="my-4" />
|
||
<div class="text-subtitle-2 mb-1">Layer 3 宿主机巡检</div>
|
||
<p class="text-caption text-medium-emphasis mb-3">
|
||
每分钟由宿主机 cron 执行 <code>health_monitor.sh</code>:连续 3 次 /health 失败则自动重启并推送 Telegram。
|
||
配置保存后会自动同步至 .env;亦可手动同步。未同步时脚本会从数据库读取 Telegram 配置。
|
||
</p>
|
||
<div class="d-flex flex-wrap align-center ga-2 mb-3">
|
||
<v-chip
|
||
size="small"
|
||
:color="patrolStatus?.telegram_layer3_ready ? 'success' : 'warning'"
|
||
variant="tonal"
|
||
>
|
||
{{ patrolStatus?.telegram_layer3_ready ? '巡检 Telegram 就绪' : '请先配置 Telegram' }}
|
||
</v-chip>
|
||
<v-chip
|
||
v-if="patrolStatus"
|
||
size="small"
|
||
:color="patrolStatus.telegram_env_synced ? 'success' : 'grey'"
|
||
variant="tonal"
|
||
>
|
||
{{ patrolStatus.telegram_env_synced ? '.env 已同步' : '.env 未同步(可用 DB 回退)' }}
|
||
</v-chip>
|
||
</div>
|
||
<div class="d-flex flex-wrap ga-2">
|
||
<v-btn
|
||
size="small"
|
||
variant="tonal"
|
||
prepend-icon="mdi-sync"
|
||
@click="syncPatrolTelegram"
|
||
:loading="syncingPatrol"
|
||
:disabled="!patrolStatus?.telegram_app_configured"
|
||
>
|
||
同步巡检 Telegram
|
||
</v-btn>
|
||
<v-btn size="small" variant="text" prepend-icon="mdi-refresh" @click="loadPatrolStatus" :loading="patrolLoading">
|
||
刷新状态
|
||
</v-btn>
|
||
</div>
|
||
<p v-if="patrolStatus" class="text-caption text-medium-emphasis mt-2 mb-0">
|
||
宿主机安装 cron:<code>{{ patrolStatus.cron_install_command }}</code> · 日志:<code>{{ patrolStatus.health_log_path }}</code>
|
||
</p>
|
||
</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" :loading="revealingApiKey" />
|
||
<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 白名单(仅白名单 IP 可登录)"
|
||
color="primary"
|
||
density="compact"
|
||
hide-details
|
||
class="mb-4"
|
||
@update:model-value="toggleAllowlist"
|
||
/>
|
||
|
||
<div class="text-subtitle-2 mb-2">代理订阅</div>
|
||
<p class="text-caption text-medium-emphasis mb-2">
|
||
填写 SS/VMess/VLESS 等订阅链接,保存后每 2 小时自动刷新节点 IP 并合并进白名单。
|
||
</p>
|
||
<v-text-field
|
||
v-model="subscriptionUrl"
|
||
label="订阅 URL"
|
||
variant="outlined"
|
||
density="compact"
|
||
class="mb-2"
|
||
placeholder="https://example.com/sub?token=..."
|
||
/>
|
||
<div class="d-flex flex-wrap ga-2 mb-3">
|
||
<v-btn size="small" variant="tonal" prepend-icon="mdi-cloud-download" @click="parseSubscription" :loading="parsingSubscription">解析预览</v-btn>
|
||
<v-btn size="small" variant="tonal" color="primary" prepend-icon="mdi-content-save" @click="saveAllowlist" :loading="savingAllowlist">保存白名单</v-btn>
|
||
</div>
|
||
|
||
<div v-if="subscriptionIps.length" class="mb-4">
|
||
<div class="text-caption text-medium-emphasis mb-2">
|
||
订阅节点 {{ subscriptionIps.length }} 个
|
||
<span v-if="allowlistLastRefresh"> · 上次刷新 {{ allowlistLastRefresh }}</span>
|
||
</div>
|
||
<div class="d-flex flex-wrap ga-1">
|
||
<v-chip v-for="ip in subscriptionIpsPreview" :key="ip" size="x-small" variant="tonal" label>{{ ip }}</v-chip>
|
||
<v-chip v-if="subscriptionIps.length > subscriptionIpsPreview.length" size="x-small" variant="text" label>
|
||
+{{ subscriptionIps.length - subscriptionIpsPreview.length }} 更多
|
||
</v-chip>
|
||
</div>
|
||
</div>
|
||
<div v-else-if="subscriptionUrl" class="text-caption text-medium-emphasis mb-4">
|
||
{{ allowlistLastRefresh ? `上次刷新 ${allowlistLastRefresh},暂无节点` : '等待首次刷新…保存订阅 URL 后将自动拉取' }}
|
||
</div>
|
||
|
||
<v-divider class="my-3" />
|
||
|
||
<div class="text-subtitle-2 mb-2">手动 IP</div>
|
||
<v-textarea
|
||
v-model="manualIpList"
|
||
label="手动 IP / CIDR(每行一个)"
|
||
variant="outlined"
|
||
density="compact"
|
||
rows="4"
|
||
class="mb-2"
|
||
placeholder="1.2.3.4 10.0.0.0/24"
|
||
/>
|
||
<div class="text-caption text-medium-emphasis">
|
||
手动 IP 与订阅节点合并生效;共 {{ allowlistTotalCount }} 条(去重后)。
|
||
</div>
|
||
</v-card-text>
|
||
</v-card>
|
||
</v-col>
|
||
</v-row>
|
||
|
||
<!-- Subscription parse preview -->
|
||
<v-dialog v-model="subParseDialog" max-width="520">
|
||
<v-card border>
|
||
<v-card-title>订阅解析预览</v-card-title>
|
||
<v-card-text>
|
||
<div class="text-body-2 mb-2">共解析出 <strong>{{ subParseHosts.length }}</strong> 个节点 IP / 域名:</div>
|
||
<div class="d-flex flex-wrap ga-1 mb-2" style="max-height: 240px; overflow-y: auto">
|
||
<v-chip v-for="h in subParseHosts" :key="h" size="small" variant="tonal" label>{{ h }}</v-chip>
|
||
</div>
|
||
<div class="text-caption text-medium-emphasis">点击「保存白名单」后才会写入订阅 URL 并触发后台刷新。</div>
|
||
</v-card-text>
|
||
<v-card-actions>
|
||
<v-spacer />
|
||
<v-btn variant="text" @click="subParseDialog = false">关闭</v-btn>
|
||
<v-btn color="primary" variant="flat" @click="subParseDialog = false; saveAllowlist()">保存白名单</v-btn>
|
||
</v-card-actions>
|
||
</v-card>
|
||
</v-dialog>
|
||
|
||
<!-- Telegram Chat ID picker -->
|
||
<v-dialog v-model="tgChatDialog" max-width="480">
|
||
<v-card border>
|
||
<v-card-title>选择 Chat ID</v-card-title>
|
||
<v-card-text>
|
||
<div v-if="!tgChats.length" class="text-body-2 text-medium-emphasis">
|
||
未检测到对话。请先在 Telegram 向 Bot 发送一条消息,然后重新检测。
|
||
</div>
|
||
<v-list v-else density="compact" lines="two">
|
||
<v-list-item
|
||
v-for="chat in tgChats"
|
||
:key="chat.id"
|
||
:title="chat.title || chat.username || `Chat ${chat.id}`"
|
||
:subtitle="`${chat.type}${chat.username ? ' · @' + chat.username : ''} · ID ${chat.id}`"
|
||
@click="applyTelegramChat(chat)"
|
||
/>
|
||
</v-list>
|
||
</v-card-text>
|
||
<v-card-actions>
|
||
<v-spacer />
|
||
<v-btn variant="text" @click="tgChatDialog = false">关闭</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, computed } from 'vue'
|
||
import { usePageActivateRefresh } from '@/composables/usePageActivateRefresh'
|
||
import { http } from '@/api'
|
||
import { useAuthStore } from '@/stores/auth'
|
||
import { useSnackbar } from '@/composables/useSnackbar'
|
||
import { required, numberRange, matchOther } from '@/utils/validation'
|
||
import TerminalQuickCommandsSettings from '@/components/TerminalQuickCommandsSettings.vue'
|
||
import type {
|
||
SettingsResponse,
|
||
AllowlistResponse,
|
||
SettingItem,
|
||
TelegramChatItem,
|
||
TelegramChatsResponse,
|
||
OpsPatrolStatusResponse,
|
||
OpsPatrolSyncResponse,
|
||
SubscriptionParseResponse,
|
||
IpAllowlistSaveRequest,
|
||
} from '@/types/api'
|
||
import { NOTIFY_TOGGLE_ITEMS } from '@/types/api'
|
||
import {
|
||
SCRIPT_COMPLETE_SOUND_OPTIONS,
|
||
DEFAULT_SCRIPT_COMPLETE_SOUND,
|
||
normalizeScriptCompleteSound,
|
||
type ScriptCompleteSoundId,
|
||
} from '@/utils/scriptCompleteSound'
|
||
import { useScriptCompleteSound } from '@/composables/useScriptCompleteSound'
|
||
|
||
defineOptions({ name: 'SettingsPage' })
|
||
|
||
const snackbar = useSnackbar()
|
||
const { saveToServer: saveScriptSoundToServer, previewSound } = useScriptCompleteSound()
|
||
|
||
const auth = useAuthStore()
|
||
|
||
const scriptCompleteSoundOptions = SCRIPT_COMPLETE_SOUND_OPTIONS
|
||
const scriptExecCompleteSound = ref<ScriptCompleteSoundId>(DEFAULT_SCRIPT_COMPLETE_SOUND)
|
||
const scriptSoundSaving = ref(false)
|
||
|
||
// ── 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 telegramTokenDraft = ref('')
|
||
const telegramTokenSet = ref(false)
|
||
const saving = ref(false)
|
||
const systemSettingsFormRef = ref<{ validate: () => Promise<{ valid: boolean }> } | null>(null)
|
||
const testingTg = ref(false)
|
||
const detectingTgChats = ref(false)
|
||
const tgChatDialog = ref(false)
|
||
const tgChats = ref<TelegramChatItem[]>([])
|
||
const showToken = ref(false)
|
||
const savingTelegram = ref(false)
|
||
const notifyToggles = ref<Record<string, boolean>>({})
|
||
const notifyToggleSaving = ref<string | null>(null)
|
||
const patrolStatus = ref<OpsPatrolStatusResponse | null>(null)
|
||
const patrolLoading = ref(false)
|
||
const syncingPatrol = ref(false)
|
||
|
||
const notifyToggleGroups = computed(() => {
|
||
const groups: Record<string, typeof NOTIFY_TOGGLE_ITEMS> = {}
|
||
for (const item of NOTIFY_TOGGLE_ITEMS) {
|
||
if (!groups[item.group]) groups[item.group] = []
|
||
groups[item.group].push(item)
|
||
}
|
||
return groups
|
||
})
|
||
|
||
function parseNotifyEnabled(val: unknown): boolean {
|
||
const s = String(val ?? 'true').trim().toLowerCase()
|
||
return !['false', '0', 'no', 'off'].includes(s)
|
||
}
|
||
|
||
// ── 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 revealingApiKey = ref(false)
|
||
const revealingTelegramToken = ref(false)
|
||
|
||
// ── IP Allowlist ──
|
||
const ipAllowlistEnabled = ref(false)
|
||
const subscriptionUrl = ref('')
|
||
const manualIpList = ref('')
|
||
const subscriptionIps = ref<string[]>([])
|
||
const allowlistLastRefresh = ref<string | null>(null)
|
||
const allowlistTotalCount = ref(0)
|
||
const savingAllowlist = ref(false)
|
||
const parsingSubscription = ref(false)
|
||
const subParseDialog = ref(false)
|
||
const subParseHosts = ref<string[]>([])
|
||
|
||
const subscriptionIpsPreview = computed(() => subscriptionIps.value.slice(0, 24))
|
||
|
||
// ── Data loading ──
|
||
async function loadSettings(silent = false) {
|
||
if (!silent) 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 === 'telegram_bot_token') {
|
||
telegramTokenSet.value = Boolean(item.value_set)
|
||
telegramTokenDraft.value = ''
|
||
continue
|
||
}
|
||
if (key === 'script_exec_complete_sound') {
|
||
scriptExecCompleteSound.value = normalizeScriptCompleteSound(item.value)
|
||
continue
|
||
}
|
||
if (key && NOTIFY_TOGGLE_ITEMS.some(n => n.key === key)) {
|
||
notifyToggles.value[key] = parseNotifyEnabled(item.value)
|
||
continue
|
||
}
|
||
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 {
|
||
if (!silent) settingsLoading.value = false
|
||
}
|
||
}
|
||
|
||
async function loadAllowlist() {
|
||
try {
|
||
const res = await http.get<AllowlistResponse>('/settings/ip-allowlist')
|
||
ipAllowlistEnabled.value = res.enabled || false
|
||
subscriptionUrl.value = res.subscription_url || ''
|
||
manualIpList.value = (res.manual_ips || []).join('\n')
|
||
subscriptionIps.value = res.subscription_ips || []
|
||
allowlistLastRefresh.value = res.last_refresh || null
|
||
allowlistTotalCount.value = res.total_count ?? (
|
||
new Set([...(res.manual_ips || []), ...(res.subscription_ips || [])]).size
|
||
)
|
||
} catch {
|
||
snackbar('加载白名单失败', 'error')
|
||
}
|
||
}
|
||
|
||
// ── Actions ──
|
||
async function saveSettings() {
|
||
const form = systemSettingsFormRef.value
|
||
if (form) {
|
||
const { valid } = await form.validate()
|
||
if (!valid) return
|
||
}
|
||
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'].includes(key)
|
||
)
|
||
for (const [key, value] of entries) {
|
||
await http.put(`/settings/${key}`, { value: String(value ?? '') })
|
||
}
|
||
snackbar('设置已保存')
|
||
} catch (e: unknown) {
|
||
const msg = e instanceof Error ? e.message : '保存失败'
|
||
snackbar(msg, 'error')
|
||
} finally { saving.value = false }
|
||
}
|
||
|
||
async function persistTelegramConfig(options?: { requireToken?: boolean; requireChatId?: boolean }): Promise<boolean> {
|
||
const tokenUpdate = telegramTokenDraft.value.trim()
|
||
const chatId = String(settings.value.telegram_chat_id || '').trim()
|
||
|
||
if (options?.requireToken && !tokenUpdate && !telegramTokenSet.value) {
|
||
snackbar('请先填写 Bot Token', 'error')
|
||
return false
|
||
}
|
||
if (options?.requireChatId && !chatId) {
|
||
snackbar('请先填写或检测 Chat ID', 'error')
|
||
return false
|
||
}
|
||
|
||
if (tokenUpdate) {
|
||
await http.put('/settings/telegram_bot_token', { value: tokenUpdate })
|
||
telegramTokenSet.value = true
|
||
telegramTokenDraft.value = ''
|
||
showToken.value = false
|
||
}
|
||
if (chatId) {
|
||
await http.put('/settings/telegram_chat_id', { value: chatId })
|
||
}
|
||
return true
|
||
}
|
||
|
||
async function loadPatrolStatus(silent = false) {
|
||
if (!silent) patrolLoading.value = true
|
||
try {
|
||
patrolStatus.value = await http.get<OpsPatrolStatusResponse>('/settings/ops-patrol/status')
|
||
} catch {
|
||
patrolStatus.value = null
|
||
} finally {
|
||
if (!silent) patrolLoading.value = false
|
||
}
|
||
}
|
||
|
||
async function syncPatrolTelegram() {
|
||
syncingPatrol.value = true
|
||
try {
|
||
if (!await persistTelegramConfig({ requireToken: true, requireChatId: true })) return
|
||
const res = await http.post<OpsPatrolSyncResponse>('/settings/ops-patrol/sync-telegram', {})
|
||
patrolStatus.value = {
|
||
...(patrolStatus.value || {} as OpsPatrolStatusResponse),
|
||
telegram_layer3_ready: res.telegram_layer3_ready,
|
||
telegram_env_synced: res.env_status.env_synced,
|
||
env_status: res.env_status,
|
||
telegram_app_configured: true,
|
||
}
|
||
snackbar('巡检 Telegram 已同步至 .env')
|
||
await loadPatrolStatus()
|
||
} catch (e: unknown) {
|
||
snackbar(e instanceof Error ? e.message : '同步失败', 'error')
|
||
} finally {
|
||
syncingPatrol.value = false
|
||
}
|
||
}
|
||
|
||
async function saveTelegramConfig() {
|
||
savingTelegram.value = true
|
||
try {
|
||
const tokenUpdate = telegramTokenDraft.value.trim()
|
||
const chatId = String(settings.value.telegram_chat_id || '').trim()
|
||
if (!tokenUpdate && !chatId && !telegramTokenSet.value) {
|
||
snackbar('请先填写 Bot Token 或 Chat ID', 'warning')
|
||
return
|
||
}
|
||
await persistTelegramConfig()
|
||
await loadPatrolStatus()
|
||
snackbar('Telegram 配置已保存')
|
||
} catch (e: unknown) {
|
||
snackbar(e instanceof Error ? e.message : '保存失败', 'error')
|
||
} finally {
|
||
savingTelegram.value = false
|
||
}
|
||
}
|
||
|
||
async function onScriptExecSoundChange(value: ScriptCompleteSoundId | null) {
|
||
if (!value || scriptSoundSaving.value) return
|
||
const prev = scriptExecCompleteSound.value
|
||
scriptExecCompleteSound.value = value
|
||
scriptSoundSaving.value = true
|
||
try {
|
||
await saveScriptSoundToServer(value)
|
||
snackbar('提示音设置已保存')
|
||
} catch (e: unknown) {
|
||
scriptExecCompleteSound.value = prev
|
||
snackbar(e instanceof Error ? e.message : '保存失败', 'error')
|
||
} finally {
|
||
scriptSoundSaving.value = false
|
||
}
|
||
}
|
||
|
||
function previewScriptExecSound() {
|
||
previewSound(scriptExecCompleteSound.value)
|
||
}
|
||
|
||
async function toggleNotify(key: string, enabled: boolean) {
|
||
const prev = notifyToggles.value[key]
|
||
notifyToggles.value[key] = enabled
|
||
notifyToggleSaving.value = key
|
||
try {
|
||
await http.put(`/settings/${key}`, { value: enabled ? 'true' : 'false' })
|
||
} catch (e: unknown) {
|
||
notifyToggles.value[key] = prev
|
||
snackbar(e instanceof Error ? e.message : '保存失败,已撤销', 'error')
|
||
} finally {
|
||
notifyToggleSaving.value = null
|
||
}
|
||
}
|
||
|
||
async function testTelegram() {
|
||
testingTg.value = true
|
||
try {
|
||
if (!await persistTelegramConfig({ requireToken: true, requireChatId: true })) return
|
||
await http.post('/settings/telegram/test')
|
||
snackbar('测试消息已发送')
|
||
} catch (e: unknown) {
|
||
snackbar(e instanceof Error ? e.message : '发送失败', 'error')
|
||
} finally { testingTg.value = false }
|
||
}
|
||
|
||
async function detectTelegramChats() {
|
||
detectingTgChats.value = true
|
||
try {
|
||
if (!await persistTelegramConfig({ requireToken: true })) return
|
||
const res = await http.get<TelegramChatsResponse>('/settings/telegram/chats')
|
||
tgChats.value = res.chats || []
|
||
if (!tgChats.value.length) {
|
||
snackbar('未检测到对话,请先向 Bot 发送一条消息后再试', 'warning')
|
||
return
|
||
}
|
||
tgChatDialog.value = true
|
||
} catch (e: unknown) {
|
||
snackbar(e instanceof Error ? e.message : '检测失败', 'error')
|
||
} finally {
|
||
detectingTgChats.value = false
|
||
}
|
||
}
|
||
|
||
async function applyTelegramChat(chat: TelegramChatItem) {
|
||
const chatId = String(chat.id)
|
||
settings.value.telegram_chat_id = chatId
|
||
tgChatDialog.value = false
|
||
try {
|
||
await http.put('/settings/telegram_chat_id', { value: chatId })
|
||
snackbar(`已选择 Chat ID: ${chatId}`)
|
||
} catch (e: unknown) {
|
||
snackbar(e instanceof Error ? e.message : '保存 Chat ID 失败', 'error')
|
||
}
|
||
}
|
||
|
||
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
|
||
}
|
||
revealingApiKey.value = true
|
||
try {
|
||
const res = await http.post<{ value: string }>('/settings/api-key/reveal', {})
|
||
apiKeyValue.value = res.value
|
||
showApiKey.value = true
|
||
} catch (e: unknown) {
|
||
snackbar(e instanceof Error ? e.message : '获取 API Key 失败', 'error')
|
||
} finally {
|
||
revealingApiKey.value = false
|
||
}
|
||
}
|
||
|
||
async function toggleTelegramTokenVisibility() {
|
||
if (showToken.value) {
|
||
showToken.value = false
|
||
if (telegramTokenSet.value) {
|
||
telegramTokenDraft.value = ''
|
||
}
|
||
return
|
||
}
|
||
if (telegramTokenDraft.value.trim()) {
|
||
showToken.value = true
|
||
return
|
||
}
|
||
if (!telegramTokenSet.value) {
|
||
showToken.value = true
|
||
return
|
||
}
|
||
revealingTelegramToken.value = true
|
||
try {
|
||
const res = await http.post<{ value: string }>('/settings/telegram/reveal-token', {})
|
||
telegramTokenDraft.value = res.value
|
||
showToken.value = true
|
||
} catch (e: unknown) {
|
||
snackbar(e instanceof Error ? e.message : '获取 Bot Token 失败', 'error')
|
||
} finally {
|
||
revealingTelegramToken.value = false
|
||
}
|
||
}
|
||
|
||
function copyApiKey() {
|
||
if (!showApiKey.value || !apiKeyValue.value) {
|
||
snackbar('请先点击眼睛图标显示 API Key', 'warning')
|
||
return
|
||
}
|
||
navigator.clipboard.writeText(apiKeyValue.value)
|
||
snackbar('已复制到剪贴板')
|
||
}
|
||
|
||
async function toggleAllowlist() {
|
||
try {
|
||
await http.post(`/settings/ip-allowlist/toggle?enabled=${ipAllowlistEnabled.value ? 'true' : 'false'}`)
|
||
} catch (e: unknown) {
|
||
snackbar(e instanceof Error ? e.message : '操作失败', 'error')
|
||
}
|
||
}
|
||
|
||
async function parseSubscription() {
|
||
const url = subscriptionUrl.value.trim()
|
||
if (!url) {
|
||
snackbar('请先填写订阅 URL', 'error')
|
||
return
|
||
}
|
||
parsingSubscription.value = true
|
||
try {
|
||
const res = await http.post<SubscriptionParseResponse>('/settings/ip-allowlist/parse-subscription', { url })
|
||
subParseHosts.value = res.hosts || []
|
||
if (!subParseHosts.value.length) {
|
||
snackbar('未能解析出任何节点', 'warning')
|
||
return
|
||
}
|
||
subParseDialog.value = true
|
||
} catch (e: unknown) {
|
||
snackbar(e instanceof Error ? e.message : '解析失败', 'error')
|
||
} finally {
|
||
parsingSubscription.value = false
|
||
}
|
||
}
|
||
|
||
async function saveAllowlist() {
|
||
savingAllowlist.value = true
|
||
try {
|
||
const manual_ips = manualIpList.value.split('\n').map(s => s.trim()).filter(Boolean)
|
||
const payload: IpAllowlistSaveRequest = {
|
||
manual_ips,
|
||
subscription_url: subscriptionUrl.value.trim(),
|
||
enabled: ipAllowlistEnabled.value,
|
||
}
|
||
await http.post('/settings/ip-allowlist', payload)
|
||
snackbar('白名单已保存')
|
||
subParseDialog.value = false
|
||
await loadAllowlist()
|
||
} catch (e: unknown) {
|
||
snackbar(e instanceof Error ? e.message : '保存失败', 'error')
|
||
} finally {
|
||
savingAllowlist.value = false
|
||
}
|
||
}
|
||
|
||
|
||
async function refreshSettingsPage(silent = false) {
|
||
await Promise.all([
|
||
loadSettings(silent),
|
||
loadAllowlist(),
|
||
loadPatrolStatus(silent),
|
||
auth.fetchProfile(),
|
||
])
|
||
}
|
||
|
||
usePageActivateRefresh((silent) => refreshSettingsPage(silent))
|
||
</script>
|