Files
Nexus/frontend/src/pages/SettingsPage.vue
T
Nexus Agent 632891e6e5 fix(settings): 表单校验与 422 中文错误信息
v-form 保存前 validate;validation_errors_zh 翻译 Pydantic msg;value 数字 coerce。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-08 14:52:59 +08:00

929 lines
36 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-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;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>