672 lines
26 KiB
Vue
672 lines
26 KiB
Vue
<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<token>/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>
|