初始提交:OCI 面板前端(含 GenAI 网关一期)
This commit is contained in:
@@ -0,0 +1,631 @@
|
||||
<script setup lang="ts">
|
||||
import { NButton, NInput, NSpin, NSwitch, NTabPane, NTabs, useMessage } from 'naive-ui'
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import {
|
||||
getNotifyEvents,
|
||||
getSecuritySetting,
|
||||
getTaskSetting,
|
||||
getTelegramSetting,
|
||||
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 NotifyTemplateModal from '@/components/settings/NotifyTemplateModal.vue'
|
||||
import { useAsync } from '@/composables/useAsync'
|
||||
import type { NotifyEventsSetting, NotifyTemplateItem, SecuritySetting } from '@/types/api'
|
||||
|
||||
const message = useMessage()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
// 设置页以 tab 组织:通知(管理 + 方式)/ 任务(抢机熔断阈值)/ 安全(WAF/网络/账号)
|
||||
const tab = ref('notify')
|
||||
|
||||
// OAuth 绑定回跳:成功/失败提示后清理 query,并停在安全 tab
|
||||
onMounted(() => {
|
||||
const { oauth, oauthError } = route.query
|
||||
if (oauth !== 'bound' && !oauthError) return
|
||||
tab.value = 'security'
|
||||
if (oauth === 'bound') message.success('外部身份绑定成功')
|
||||
if (typeof oauthError === 'string' && oauthError) message.error(oauthError)
|
||||
void router.replace({ query: {} })
|
||||
})
|
||||
|
||||
// ---- 通知管理:按事件类型的推送开关 ----
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 通知方式: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>
|
||||
</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>
|
||||
Reference in New Issue
Block a user