Files
oci-portal-dash/src/views/SettingsView.vue
T
Wang Defa 5464d2dfea
CI / test (push) Successful in 27s
Release / release (push) Successful in 26s
发布 0.1.0:通知渠道、告警规则与多项体验修复
2026-07-10 17:31:19 +08:00

672 lines
26 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.
<script setup lang="ts">
import { NButton, NInput, NSpin, NSwitch, NTabPane, NTabs } from 'naive-ui'
import { computed, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import {
getNotifyEvents,
getSecuritySetting,
getTaskSetting,
getTelegramSetting,
listNotifyChannels,
listNotifyTemplates,
testTelegram,
updateNotifyEvents,
updateSecuritySetting,
updateTaskSetting,
updateTelegramSetting,
} from '@/api/settings'
import AppInputNumber from '@/components/AppInputNumber.vue'
import FootNote from '@/components/FootNote.vue'
import FormField from '@/components/FormField.vue'
import AboutTab from '@/components/settings/AboutTab.vue'
import AccountSecurityCard from '@/components/settings/AccountSecurityCard.vue'
import NotifyChannelCard from '@/components/settings/NotifyChannelCard.vue'
import NotifyTemplateModal from '@/components/settings/NotifyTemplateModal.vue'
import { useAsync } from '@/composables/useAsync'
import type {
NotifyChannelItem,
NotifyEventsSetting,
NotifyTemplateItem,
SecuritySetting,
} from '@/types/api'
import { useAuthStore } from '@/stores/auth'
import { useToast } from '@/composables/useToast'
const message = useToast()
const route = useRoute()
const router = useRouter()
const auth = useAuthStore()
// 设置页以 tab 组织:通知(管理 + 方式)/ 任务(抢机熔断阈值)/ 安全(WAF/网络/账号)
const tab = ref('notify')
// OAuth 绑定回跳:成功/失败提示后清理 query,并停在安全 tab;
// 绑定使令牌版本递增,fragment 携带的新 token 须先落地,否则后续请求 401
onMounted(() => {
const { oauth, oauthError } = route.query
if (oauth !== 'bound' && !oauthError) return
consumeBindToken()
tab.value = 'security'
if (oauth === 'bound') message.success('外部身份绑定成功')
if (typeof oauthError === 'string' && oauthError) message.error(oauthError)
void router.replace({ query: {} })
})
/** 消费绑定回跳 fragment 中的新会话 token(无感换发,不进服务端日志) */
function consumeBindToken() {
const hash = window.location.hash
if (!hash.startsWith('#oauthToken=')) return
const token = decodeURIComponent(hash.slice('#oauthToken='.length))
history.replaceState(null, '', window.location.pathname + window.location.search)
if (token) auth.setSession(token, new Date(Date.now() + 24 * 3600e3).toISOString())
}
// ---- 通知管理:按事件类型的推送开关 ----
const events = useAsync(getNotifyEvents)
const eventForm = ref<NotifyEventsSetting>({
taskFail: true,
taskRecover: true,
snatchSuccess: true,
tenantDead: true,
taskStop: true,
loginLock: true,
modelDeprecated: true,
logEventInstance: true,
logEventIdentity: true,
logEventPolicy: true,
logEventRegion: true,
logEventLogin: true,
})
const savingEvents = ref(false)
const eventRows: { key: keyof NotifyEventsSetting; label: string; hint: string }[] = [
{ key: 'taskFail', label: '任务失败', hint: '任务由成功转失败时推送(连续失败不重复发)' },
{ key: 'taskRecover', label: '任务恢复', hint: '任务由失败恢复正常时推送' },
{ key: 'snatchSuccess', label: '抢机成功', hint: '抢机任务达成目标台数时推送' },
{ key: 'tenantDead', label: '租户失联', hint: '测活失联集合变化时推送' },
{ key: 'taskStop', label: '任务停止', hint: '抢机连续鉴权失败熔断停止时推送' },
{ key: 'loginLock', label: '登录锁定', hint: '同一 IP+用户名连续失败达阈值时推送' },
{ key: 'modelDeprecated', label: '模型弃用预警', hint: 'AI 网关在池模型 30 天内被 OCI 弃用时随每日探测推送' },
{ key: 'logEventInstance', label: '实例生命周期', hint: '创建 / 终止 / 电源操作(Launch、Terminate、InstanceAction)' },
{ key: 'logEventIdentity', label: '用户与凭据', hint: '用户增删改、API Key 增删、能力变更' },
{ key: 'logEventPolicy', label: '策略变更', hint: 'IAM Policy 创建 / 修改 / 删除' },
{ key: 'logEventRegion', label: '区域订阅', hint: '订阅新区域(CreateRegionSubscription)' },
{ key: 'logEventLogin', label: '控制台登录', hint: 'OCI 控制台交互登录(InteractiveLogin)' },
]
/** 通知管理按业务域分组展示;新事件类型加入对应组即可 */
const eventGroups: { title: string; keys: (keyof NotifyEventsSetting)[] }[] = [
{ title: '任务', keys: ['taskFail', 'taskRecover', 'snatchSuccess', 'taskStop', 'modelDeprecated'] },
{ title: '租户与安全', keys: ['tenantDead', 'loginLock'] },
{
title: '云端事件 · 日志回传',
keys: ['logEventInstance', 'logEventIdentity', 'logEventPolicy', 'logEventRegion', 'logEventLogin'],
},
]
const enabledCount = computed(() => Object.values(eventForm.value).filter(Boolean).length)
// ---- 通知模板:每个事件可自定义推送 markdown ----
const templates = useAsync(listNotifyTemplates)
const tplShow = ref(false)
const tplCurrent = ref<NotifyTemplateItem | null>(null)
const tplHint = ref('')
/** eventRows 的 camelCase key → 模板 kind(snake_case) */
function tplKindOf(key: string) {
return key.replace(/[A-Z]/g, (m) => '_' + m.toLowerCase())
}
function openTemplate(row: { key: string; label: string; hint: string }) {
const kind = tplKindOf(row.key)
const item = (templates.data.value ?? []).find((t) => t.kind === kind)
if (!item) {
message.error('模板加载中,请稍后再试')
return
}
tplCurrent.value = item
tplHint.value = row.hint
tplShow.value = true
}
function onTemplateSaved(kind: string, template: string) {
const item = (templates.data.value ?? []).find((t) => t.kind === kind)
if (item) item.template = template
}
function rowsOf(keys: (keyof NotifyEventsSetting)[]) {
return eventRows.filter((r) => keys.includes(r.key))
}
watch(
() => events.data.value,
(data) => {
if (data) eventForm.value = { ...data }
},
)
async function saveEvents() {
savingEvents.value = true
try {
await updateNotifyEvents({ ...eventForm.value })
message.success('通知管理已保存')
void events.run({ silent: true })
} catch (e) {
message.error(e instanceof Error ? e.message : '保存失败')
} finally {
savingEvents.value = false
}
}
// ---- 通知方式:多渠道(webhook/ntfy/bark/smtp)----
const channels = useAsync(listNotifyChannels)
/** 渠道保存接口回全渠道最新视图,就地覆盖免二次拉取 */
function onChannelsSaved(items: NotifyChannelItem[]) {
channels.data.value = items
}
// ---- 通知方式:Telegram 渠道配置 ----
const setting = useAsync(getTelegramSetting)
const enabled = ref(false)
const chatId = ref('')
const botToken = ref('')
const saving = ref(false)
const testing = ref(false)
// 服务端数据到达后回填表单;token 不回明文,只以占位符提示已配置态
watch(
() => setting.data.value,
(data) => {
if (!data) return
enabled.value = data.enabled
chatId.value = data.chatId
botToken.value = ''
},
)
const tokenPlaceholder = computed(() => {
const data = setting.data.value
if (!data?.tokenSet) return '123456789:AAxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx'
return `已配置(尾号 ${data.tokenTail}),留空沿用`
})
// ---- 任务设置:抢机熔断阈值 ----
const taskSetting = useAsync(getTaskSetting)
const authFailLimit = ref<number | null>(3)
const savingTask = ref(false)
/** 抢机熔断流程步骤条;hot 标记可配置的关键一步 */
const CIRCUIT_STEPS = [
{ t: '连续失败', d: '抢机请求连续返回 NotAuthenticated', hot: false },
{ t: '达到阈值', d: '失败次数达到下方配置值', hot: true },
{ t: '熔断停止', d: '任务置为已熔断并退出调度', hot: false },
{ t: '推送与恢复', d: '发送「任务停止」通知,修复 Key 后在任务页重新启用', hot: false },
]
watch(
() => taskSetting.data.value,
(data) => {
if (data) authFailLimit.value = data.snatchAuthFailLimit
},
)
// ---- 安全设置:WAF 参数 / 真实IP请求头 / 面板地址 ----
const security = useAsync(getSecuritySetting)
const securityForm = ref<SecuritySetting>({
loginFailLimit: 5,
loginLockMinutes: 15,
ipRateRps: 10,
ipRateBurst: 30,
realIpHeader: '',
appUrl: '',
})
const savingSecurity = ref(false)
/** 真实IP请求头预设;设计稿 segmented 选项,预设外经「自定义」输入 */
const REALIP_PRESETS = [
{ label: '直连', value: '' },
{ label: 'X-Forwarded-For', value: 'X-Forwarded-For' },
{ label: 'X-Real-IP', value: 'X-Real-IP' },
{ label: 'CF-Connecting-IP', value: 'CF-Connecting-IP' },
]
const customHeaderMode = ref(false)
function pickHeader(v: string) {
customHeaderMode.value = false
securityForm.value.realIpHeader = v
}
function pickCustomHeader() {
if (customHeaderMode.value) return
customHeaderMode.value = true
// 从预设切入自定义时清空,避免误保存预设值
if (REALIP_PRESETS.some((p) => p.value === securityForm.value.realIpHeader))
securityForm.value.realIpHeader = ''
}
watch(
() => security.data.value,
(data) => {
if (!data) return
securityForm.value = { ...data }
customHeaderMode.value =
data.realIpHeader !== '' && !REALIP_PRESETS.some((p) => p.value === data.realIpHeader)
},
)
async function saveSecurity() {
savingSecurity.value = true
try {
await updateSecuritySetting({ ...securityForm.value })
message.success('安全设置已保存,立即生效')
void security.run({ silent: true })
} catch (e) {
message.error(e instanceof Error ? e.message : '保存失败')
} finally {
savingSecurity.value = false
}
}
async function saveTaskSetting() {
const limit = authFailLimit.value
if (limit == null || limit < 1 || limit > 10) {
message.error('熔断阈值须在 1-10 之间')
return
}
savingTask.value = true
try {
await updateTaskSetting({ snatchAuthFailLimit: limit })
message.success('任务设置已保存')
void taskSetting.run({ silent: true })
} catch (e) {
message.error(e instanceof Error ? e.message : '保存失败')
} finally {
savingTask.value = false
}
}
const initialLoading = computed(
() =>
(setting.loading.value && !setting.data.value) ||
(events.loading.value && !events.data.value) ||
(taskSetting.loading.value && !taskSetting.data.value) ||
(security.loading.value && !security.data.value),
)
async function save() {
saving.value = true
try {
const token = botToken.value.trim()
await updateTelegramSetting({
enabled: enabled.value,
chatId: chatId.value.trim(),
// 留空表示沿用已保存的 token
...(token ? { botToken: token } : {}),
})
message.success('通知设置已保存')
botToken.value = ''
void setting.run({ silent: true })
} catch (e) {
message.error(e instanceof Error ? e.message : '保存失败')
} finally {
saving.value = false
}
}
async function sendTest() {
testing.value = true
try {
await testTelegram()
message.success('测试消息已发送,请在 Telegram 查收')
} catch (e) {
message.error(e instanceof Error ? e.message : '发送失败')
} finally {
testing.value = false
}
}
</script>
<template>
<div class="flex flex-col gap-4 p-6 pb-8 max-md:p-3">
<div class="flex flex-wrap items-baseline gap-2.5">
<h1 class="page-title">设置</h1>
<span class="text-[13px] text-ink-3">面板级系统配置</span>
</div>
<NTabs v-model:value="tab" type="line">
<NTabPane name="notify" tab="通知" />
<NTabPane name="task" tab="任务" />
<NTabPane name="security" tab="安全" />
<NTabPane name="about" tab="关于" />
</NTabs>
<div v-if="initialLoading" class="panel flex max-w-[620px] items-center justify-center py-20">
<NSpin size="small" />
</div>
<div
v-else-if="tab === 'notify'"
class="grid max-w-[1180px] grid-cols-[7fr_5fr] items-start gap-4 max-lg:grid-cols-1"
>
<!-- :通知管理(按事件类型控制是否推送) -->
<div class="panel">
<div
class="flex flex-wrap items-center justify-between gap-3 border-b border-line-soft px-4.5 py-3.5"
>
<div class="min-w-0">
<div class="text-sm font-semibold">通知管理</div>
<div class="mt-0.5 text-xs text-ink-3">
按事件类型控制是否推送事件仅在状态变化时触发
</div>
</div>
<span
class="inline-flex items-center gap-1.5 rounded-full bg-wash px-2.5 py-[3px] text-[11.5px] text-ink-2"
>
<span class="h-1.5 w-1.5 rounded-full bg-ok" />
已开启 {{ enabledCount }} / {{ eventRows.length }}
</span>
</div>
<div class="px-4.5 pb-2">
<template v-for="group in eventGroups" :key="group.title">
<div class="pt-3.5 pb-1 text-[11px] font-semibold tracking-wider text-ink-3 uppercase">
{{ group.title }}
</div>
<div
v-for="row in rowsOf(group.keys)"
:key="row.key"
class="flex items-center justify-between gap-3 border-b border-line-soft py-2.5 last:border-b-0"
>
<div class="min-w-0">
<div class="text-[13px] font-medium">{{ row.label }}</div>
<div class="mt-0.5 text-xs text-ink-3">{{ row.hint }}</div>
</div>
<div class="flex flex-none items-center gap-2.5">
<button
type="button"
class="cursor-pointer text-xs text-accent hover:opacity-80"
@click="openTemplate(row)"
>
模板
</button>
<NSwitch v-model:value="eventForm[row.key]" size="small" />
</div>
</div>
</template>
</div>
<div class="flex items-center gap-2 border-t border-line-soft px-4.5 py-3.5">
<NButton size="small" type="primary" :loading="savingEvents" @click="saveEvents">
保存
</NButton>
<span class="text-xs text-ink-3">全部开关整体保存立即生效</span>
</div>
</div>
<!-- :通知方式 + 送达规则 -->
<div class="flex flex-col gap-4">
<div class="panel">
<div class="border-b border-line-soft px-4.5 py-3.5">
<div class="text-sm font-semibold">通知方式</div>
<div class="mt-0.5 text-xs text-ink-3">配置通知送达的渠道</div>
</div>
<div class="flex items-center gap-3 border-b border-line-soft px-4.5 py-3.5">
<span
class="flex h-8.5 w-8.5 flex-none items-center justify-center rounded-full bg-info text-[15px] font-bold text-white"
>
T
</span>
<div class="min-w-0 flex-1">
<div class="flex flex-wrap items-center gap-2">
<span class="text-[13px] font-medium">Telegram</span>
<span
class="inline-flex items-center gap-1.5 rounded-full bg-wash px-2 py-px text-[11.5px] text-ink-2"
>
<span
class="h-1.5 w-1.5 rounded-full"
:class="enabled ? 'bg-ok' : 'bg-ink-3/50'"
/>
{{ enabled ? '已启用' : '未启用' }}
</span>
</div>
<div class="mt-0.5 text-xs text-ink-3">开启的通知事件推送到指定 chat</div>
</div>
<NSwitch v-model:value="enabled" size="small" />
</div>
<div class="px-4.5 py-3.5">
<FormField
label="Bot Token"
hint="来自 @BotFather 的 bot 令牌;服务端加密存储,保存后不再回显明文"
>
<NInput
v-model:value="botToken"
type="password"
show-password-on="click"
:placeholder="tokenPlaceholder"
/>
</FormField>
<FormField label="Chat ID" hint="接收通知的会话 ID:个人为正整数,群组为负整数">
<NInput v-model:value="chatId" placeholder="如 123456789" />
</FormField>
<div class="mt-1 flex items-center gap-2">
<NButton size="small" type="primary" :loading="saving" @click="save">保存</NButton>
<NButton size="small" :loading="testing" @click="sendTest">发送测试消息</NButton>
</div>
<div class="mt-2 text-xs text-ink-3">测试使用已保存的配置修改后请先保存</div>
</div>
<FootNote>
获取 chat_id @BotFather 创建 bot 拿到 token bot 发一条消息群组则把 bot
拉进群); 浏览器打开 api.telegram.org/bot&lt;token&gt;/getUpdates,响应中
message.chat.id 即为 chat_id
</FootNote>
<!-- 多渠道: Telegram 并存,可同时启用任意多个 -->
<div class="border-t border-line-soft">
<NotifyChannelCard
v-for="ch in channels.data.value ?? []"
:key="ch.type"
:item="ch"
@saved="onChannelsSaved"
/>
</div>
</div>
<div class="panel px-4.5 py-3.5">
<div class="text-[13px] font-semibold">送达规则</div>
<div class="mt-1.5 text-xs leading-relaxed text-ink-3">
事件同时投递到全部已启用渠道发送为异步10 秒超时单渠道失败仅记后端日志
不影响其他渠道与任务执行同一事件按状态变化去重不会刷屏
云端事件另受日志回传链路约束——未建链路的租户不产生该类通知
</div>
</div>
</div>
</div>
<div v-else-if="tab === 'task'" class="flex max-w-[860px] flex-col gap-4">
<!-- 任务行为抢机熔断阈值 -->
<div class="panel">
<div class="border-b border-line-soft px-4.5 py-3.5">
<div class="text-sm font-semibold">抢机熔断</div>
<div class="mt-0.5 text-xs text-ink-3">
API Key 失效后自动停止无意义的重试,避免频繁鉴权失败触发云端风控
</div>
</div>
<div class="grid grid-cols-4 px-2.5 pt-4.5 pb-1 max-md:grid-cols-2 max-md:gap-y-4">
<div
v-for="(s, i) in CIRCUIT_STEPS"
:key="s.t"
class="relative px-2.5 text-center after:absolute after:top-[15px] after:left-[calc(50%+26px)] after:h-[1.5px] after:w-[calc(100%-52px)] after:bg-line last:after:hidden max-md:after:hidden"
>
<span
class="inline-flex h-[30px] w-[30px] items-center justify-center rounded-full border-[1.5px] text-[13px] font-semibold"
:class="
s.hot
? 'border-accent bg-accent/15 text-accent'
: 'border-line bg-wash text-ink-2'
"
>
{{ i + 1 }}
</span>
<div class="mt-2 text-[12.5px] font-semibold">{{ s.t }}</div>
<div class="mt-0.5 text-[11.5px] leading-normal text-ink-3">{{ s.d }}</div>
</div>
</div>
<div class="px-4.5 pt-3 pb-4.5">
<div class="flex flex-wrap items-center gap-4">
<AppInputNumber
v-model:value="authFailLimit"
:min="1"
:max="10"
size="large"
class="!w-28"
/>
<div class="min-w-0">
<div class="text-[13px] font-semibold">熔断阈值(连续 NotAuthenticated 次数)</div>
<div class="mt-0.5 text-xs text-ink-3">
范围 1-10,默认 3;保存后下一次抢机执行即按新阈值判定
</div>
</div>
</div>
<div class="mt-4 grid grid-cols-3 gap-3 max-md:grid-cols-1">
<div class="rounded-lg bg-wash px-3.5 py-3">
<div class="text-[11px] tracking-wider text-ink-3 uppercase">当前生效值</div>
<div class="mt-1 text-[13px] font-semibold">
{{ taskSetting.data.value ? `${taskSetting.data.value.snatchAuthFailLimit} 次` : '—' }}
</div>
</div>
<div class="rounded-lg bg-wash px-3.5 py-3">
<div class="text-[11px] tracking-wider text-ink-3 uppercase">受控任务</div>
<div class="mt-1 text-[13px] font-semibold">抢机任务(snatch)</div>
</div>
<div class="rounded-lg bg-wash px-3.5 py-3">
<div class="text-[11px] tracking-wider text-ink-3 uppercase">熔断后恢复方式</div>
<div class="mt-1 text-[13px] font-semibold">任务页手动启用</div>
</div>
</div>
<div class="mt-4 flex items-center gap-2">
<NButton size="small" type="primary" :loading="savingTask" @click="saveTaskSetting">
保存
</NButton>
<span class="text-xs text-ink-3">保存后立即生效,无需重启</span>
</div>
</div>
<FootNote>
熔断只针对鉴权类失败(API Key 失效);容量不足等业务失败不计入,抢机会持续重试
</FootNote>
</div>
</div>
<!-- 安全:双栏 —— 左访问防护 / 网络与地址,右账号安全与登录方式 -->
<div v-else-if="tab === 'security'" class="grid max-w-[1180px] grid-cols-2 items-start gap-4 max-lg:grid-cols-1">
<div class="flex flex-col gap-4">
<div class="panel">
<div class="border-b border-line-soft px-4.5 py-3.5">
<div class="text-sm font-semibold">访问防护</div>
<div class="mt-0.5 text-xs text-ink-3">
登录守卫与全局 IP 限速,超限返回 429;锁定事件可推送告警(通知管理 登录锁定)
</div>
</div>
<div class="grid grid-cols-2 gap-x-4 px-4.5 py-3.5 max-md:grid-cols-1">
<FormField label="登录失败阈值" hint="窗口内同一 IP+用户名连续失败达到该次数即锁定(1-20)">
<AppInputNumber v-model:value="securityForm.loginFailLimit" :min="1" :max="20" />
</FormField>
<FormField label="锁定时长(分钟)" hint="锁定持续时间,同时是失败计数窗口(1-1440)">
<AppInputNumber v-model:value="securityForm.loginLockMinutes" :min="1" :max="1440" />
</FormField>
<FormField label="限速(req/s)" hint="每 IP 令牌桶每秒补充令牌数(1-100)">
<AppInputNumber v-model:value="securityForm.ipRateRps" :min="1" :max="100" />
</FormField>
<FormField label="突发额度" hint="令牌桶容量,允许的瞬时突发请求数(1-500)">
<AppInputNumber v-model:value="securityForm.ipRateBurst" :min="1" :max="500" />
</FormField>
</div>
</div>
<div class="panel">
<div class="border-b border-line-soft px-4.5 py-3.5">
<div class="text-sm font-semibold">网络与地址</div>
<div class="mt-0.5 text-xs text-ink-3">
反代部署时必须正确选择真实IP请求头,否则限速与锁定会作用在反代 IP
</div>
</div>
<div class="px-4.5 py-3.5">
<FormField
label="真实IP请求头"
hint="Caddy/Nginx 反代选 X-Forwarded-For;Cloudflare 前置时选 CF-Connecting-IP;直连部署保持「直连」防伪造"
>
<div class="flex flex-wrap gap-2">
<button
v-for="opt in REALIP_PRESETS"
:key="opt.label"
type="button"
class="cursor-pointer rounded-md border px-3 py-1.5 text-xs"
:class="
!customHeaderMode && securityForm.realIpHeader === opt.value
? 'border-accent bg-accent/10 font-semibold text-accent'
: 'border-line bg-white text-ink-2 hover:border-ink-3'
"
@click="pickHeader(opt.value)"
>
{{ opt.label }}
</button>
<button
type="button"
class="cursor-pointer rounded-md border border-dashed px-3 py-1.5 text-xs"
:class="
customHeaderMode
? 'border-accent bg-accent/10 font-semibold text-accent'
: 'border-line bg-white text-ink-2 hover:border-ink-3'
"
@click="pickCustomHeader"
>
自定义
</button>
</div>
<NInput
v-if="customHeaderMode"
v-model:value="securityForm.realIpHeader"
class="mono mt-2 !w-70"
placeholder="自定义头名,如 X-Client-IP"
/>
<div v-if="customHeaderMode" class="mt-1.5 text-xs text-ink-3">
头名仅限字母数字与连字符
</div>
</FormField>
<FormField
label="面板地址"
hint="面板公网基址,优先于 PUBLIC_URL 环境变量;日志回传一键创建与 OAuth 回调以此拼接 URL"
>
<NInput v-model:value="securityForm.appUrl" placeholder="https://demo.example.com" />
</FormField>
<div class="mt-1 flex items-center gap-2">
<NButton size="small" type="primary" :loading="savingSecurity" @click="saveSecurity">
保存
</NButton>
<span class="text-xs text-ink-3">整体保存,立即生效,无需重启</span>
</div>
</div>
<FootNote>
登录锁定为内存计数,重启服务即清零;真实IP解析对 X-Forwarded-For
取链尾(反代追加的直连对端),链首可被客户端伪造不采信
</FootNote>
</div>
</div>
<!-- 账号安全:两步验证 / 外部身份绑定 / 登录方式配置 -->
<div class="flex flex-col gap-4">
<AccountSecurityCard />
</div>
</div>
<!-- 关于:构建信息与项目主页(v-else-if 挂载时才启动粒子动画) -->
<AboutTab v-else-if="tab === 'about'" />
<NotifyTemplateModal
v-model:show="tplShow"
:item="tplCurrent"
:hint="tplHint"
@saved="onTemplateSaved"
/>
</div>
</template>