Files
Xboard/admin-frontend/src/views/nodes/NodeEditorDialog.vue
T
yinjianm ff50030364 feat(api): 新增节点墙检测自动托管与显隐
新增定时墙检测命令与节点托管字段,自动为开启托管的父
节点创建检测任务,并在 blocked 时自动隐藏节点、normal
时仅恢复由墙检测自动隐藏的节点

更新自动上线服务以尊重 blocked 与自动隐藏状态,避免疑
似被墙节点被重新发布;同时补齐管理端墙检测托管开关、
刷新入口、批量设置与相关测试和知识库同步
2026-04-28 00:51:49 +08:00

430 lines
14 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 { computed, nextTick, reactive, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
import { Plus } from '@element-plus/icons-vue'
import { saveNode } from '@/api/admin'
import type { AdminNodeItem, AdminNodeRouteItem, AdminNodeType, AdminServerGroupItem } from '@/types/api'
import NodeEditorProtocolSection from './NodeEditorProtocolSection.vue'
import {
createEmptyNodeForm,
createNodeRateRange,
getNodeProtocolLabel,
getNodeProtocolOptions,
toNodeFormModel,
toNodeSavePayload,
type NodeFormModel,
validateNodeForm,
} from '@/utils/nodeEditor'
const props = defineProps<{
visible: boolean
mode: 'create' | 'edit'
node?: AdminNodeItem | null
groups: AdminServerGroupItem[]
routes: AdminNodeRouteItem[]
nodes: AdminNodeItem[]
}>()
const emit = defineEmits<{
'update:visible': [value: boolean]
success: [message: string]
}>()
const formRef = ref<FormInstance>()
const submitting = ref(false)
const form = reactive<NodeFormModel>(createEmptyNodeForm())
const protocolOptions = computed(() => getNodeProtocolOptions())
const dialogTitle = computed(() => props.mode === 'create' ? '新建节点' : '编辑节点')
const dialogDescription = computed(() => props.mode === 'create'
? '管理所有节点,包括添加、删除、编辑等操作。'
: '调整节点基础配置、协议细节与排序前置参数。')
const currentProtocolLabel = computed(() => getNodeProtocolLabel(form.type))
const parentNodeOptions = computed(() => props.nodes.filter((item) => item.id !== props.node?.id))
const rules = computed<FormRules<NodeFormModel>>(() => ({
type: [{ required: true, message: '请选择协议类型', trigger: 'change' }],
name: [{ required: true, message: '请输入节点名称', trigger: 'blur' }],
host: [{ required: true, message: '请输入节点地址', trigger: 'blur' }],
port: [{ required: true, message: '请输入连接端口', trigger: 'blur' }],
serverPort: [{ required: true, message: '请输入服务端口', trigger: 'blur' }],
rate: [
{
validator: (_rule, value, callback) => {
if (!Number.isFinite(Number(value)) || Number(value) <= 0) {
callback(new Error('请输入大于 0 的倍率'))
return
}
callback()
},
trigger: 'blur',
},
],
}))
function closeDialog() {
emit('update:visible', false)
}
function syncForm() {
Object.assign(form, toNodeFormModel(props.node))
}
function applyProtocolDefaults(type: AdminNodeType | '') {
if (!type) {
form.network = ''
form.tlsMode = 0
return
}
if (['vmess', 'vless', 'trojan'].includes(type) && !form.network) {
form.network = 'tcp'
}
if (!['vmess', 'vless', 'trojan'].includes(type)) {
form.network = ''
}
if (!['vmess', 'vless', 'trojan', 'hysteria', 'tuic', 'anytls', 'socks', 'naive', 'http'].includes(type)) {
form.tlsMode = 0
}
if (type === 'trojan' && form.tlsMode === 0) {
form.tlsMode = 1
}
}
function addRateRange() {
form.rateTimeRanges.push(createNodeRateRange())
}
function removeRateRange(index: number) {
if (form.rateTimeRanges.length === 1) {
form.rateTimeRanges.splice(0, 1, createNodeRateRange())
return
}
form.rateTimeRanges.splice(index, 1)
}
async function handleSubmit() {
const instance = formRef.value
if (!instance) {
return
}
const valid = await instance.validate().catch(() => false)
if (!valid) {
return
}
const validationMessage = validateNodeForm(form)
if (validationMessage) {
ElMessage.warning(validationMessage)
return
}
submitting.value = true
try {
await saveNode(toNodeSavePayload(form))
const message = props.mode === 'create' ? '节点已创建' : '节点已更新'
ElMessage.success(message)
emit('success', message)
closeDialog()
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '节点保存失败')
} finally {
submitting.value = false
}
}
watch(
() => [props.visible, props.node, props.mode],
([visible]) => {
if (!visible) {
return
}
syncForm()
applyProtocolDefaults(form.type)
nextTick(() => {
formRef.value?.clearValidate()
})
},
{ immediate: true },
)
watch(
() => form.type,
(value) => {
applyProtocolDefaults(value)
},
)
watch(
() => form.tlsMode,
(value) => {
if (value !== 2) {
form.realityServerName = ''
form.realityServerPort = ''
form.realityPublicKey = ''
form.realityPrivateKey = ''
form.realityShortId = ''
}
if (value === 0) {
form.tlsServerName = ''
form.tlsAllowInsecure = false
form.echEnabled = false
form.echConfig = ''
form.echQueryServerName = ''
form.echKey = ''
form.utlsEnabled = false
}
},
)
</script>
<template>
<ElDialog
:model-value="props.visible"
width="min(960px, calc(100vw - 24px))"
top="4vh"
destroy-on-close
class="node-editor-dialog"
@close="closeDialog"
@update:model-value="emit('update:visible', $event)"
>
<div class="node-editor-shell">
<header class="node-editor-hero">
<div class="hero-copy">
<div class="hero-copy__title">
<h2>{{ dialogTitle }}</h2>
<ElTag v-if="form.type" round effect="dark" class="protocol-badge">
{{ currentProtocolLabel }}
</ElTag>
</div>
<p>{{ dialogDescription }}</p>
</div>
<div class="hero-protocol">
<span class="hero-protocol__label">选择协议类型</span>
<ElSelect v-model="form.type" placeholder="选择协议类型">
<ElOption
v-for="option in protocolOptions"
:key="option.value"
:label="option.label"
:value="option.value"
>
<div class="protocol-option">
<span class="protocol-option__dot" :style="{ background: option.dotColor }" />
<span>{{ option.label }}</span>
</div>
</ElOption>
</ElSelect>
</div>
</header>
<ElForm
ref="formRef"
:model="form"
:rules="rules"
label-position="top"
class="node-editor-form"
>
<section class="form-section">
<div class="section-head">
<div>
<h3>基础信息</h3>
<p>先完成节点标识地址权限组与展示状态等通用配置</p>
</div>
</div>
<div class="form-grid">
<ElFormItem label="节点名称" prop="name">
<ElInput v-model="form.name" placeholder="请输入节点名称" />
</ElFormItem>
<ElFormItem label="基础倍率" prop="rate">
<ElInputNumber
v-model="form.rate"
:min="0.01"
:step="0.01"
:precision="2"
:controls="false"
class="full-width"
/>
</ElFormItem>
<ElFormItem label="启用动态倍率" class="form-grid--full">
<div class="switch-row">
<div>
<strong>根据时间段设置不同的倍率乘数</strong>
<span>关闭后仅使用基础倍率开启后可配置多个倍率区间</span>
</div>
<ElSwitch v-model="form.rateTimeEnable" />
</div>
</ElFormItem>
<ElFormItem label="自定义节点 ID(选填)">
<ElInput v-model="form.code" placeholder="请输入自定义节点 ID" />
</ElFormItem>
<ElFormItem label="节点标签">
<ElSelect
v-model="form.tags"
multiple
filterable
allow-create
default-first-option
collapse-tags
collapse-tags-tooltip
placeholder="输入后回车添加标签"
/>
</ElFormItem>
<ElFormItem label="权限组">
<ElSelect v-model="form.groupIds" multiple collapse-tags collapse-tags-tooltip placeholder="请选择权限组">
<ElOption
v-for="group in props.groups"
:key="group.id"
:label="group.name"
:value="group.id"
/>
</ElSelect>
</ElFormItem>
<ElFormItem label="父级节点">
<ElSelect v-model="form.parentId" clearable placeholder="无">
<ElOption
v-for="node in parentNodeOptions"
:key="node.id"
:label="`${node.name} (#${node.id})`"
:value="node.id"
/>
</ElSelect>
</ElFormItem>
<ElFormItem label="节点地址" prop="host" class="form-grid--full">
<ElInput v-model="form.host" placeholder="请输入节点域名或者 IP" />
</ElFormItem>
<ElFormItem label="连接端口" prop="port">
<ElInput v-model="form.port" placeholder="用户连接端口" />
</ElFormItem>
<ElFormItem label="服务端口" prop="serverPort">
<ElInput v-model="form.serverPort" placeholder="请输入服务端口" />
</ElFormItem>
<ElFormItem label="路由组" class="form-grid--full">
<ElSelect v-model="form.routeIds" multiple collapse-tags collapse-tags-tooltip placeholder="选择路由组">
<ElOption
v-for="route in props.routes"
:key="route.id"
:label="route.remarks"
:value="route.id"
/>
</ElSelect>
</ElFormItem>
<ElFormItem label="节点状态" class="form-grid--full">
<div class="switch-panel">
<label class="switch-card">
<div>
<strong>前台显示</strong>
<span>{{ form.autoOnline ? '已由自动上线托管,后台会按在线状态同步。' : '开启后节点会出现在可展示列表中。' }}</span>
</div>
<ElSwitch v-model="form.show" :disabled="form.autoOnline" />
</label>
<label class="switch-card">
<div>
<strong>自动上线</strong>
<span>开启后后台会自动同步显示状态在线显示离线隐藏</span>
</div>
<ElSwitch v-model="form.autoOnline" />
</label>
<label class="switch-card">
<div>
<strong>墙检测托管</strong>
<span>{{ form.parentId ? '子节点不独立检测,只控制是否随父节点自动隐藏或恢复。' : '开启后后台会自动检测并在疑似被墙时隐藏。' }}</span>
</div>
<ElSwitch v-model="form.gfwCheckEnabled" />
</label>
<label class="switch-card">
<div>
<strong>启用节点</strong>
<span>关闭后节点仍保留配置但视为停用状态</span>
</div>
<ElSwitch v-model="form.enabled" />
</label>
</div>
</ElFormItem>
</div>
</section>
<section v-if="form.rateTimeEnable" class="form-section">
<div class="section-head">
<div>
<h3>动态倍率</h3>
<p>按时间段定义倍率规则保存时会序列化为 `rate_time_ranges`</p>
</div>
<ElButton @click="addRateRange">
<ElIcon><Plus /></ElIcon>
添加时间段
</ElButton>
</div>
<div class="rate-list">
<article
v-for="(item, index) in form.rateTimeRanges"
:key="item.key"
class="rate-item"
>
<div class="rate-item__grid">
<ElFormItem label="开始时间">
<ElTimePicker v-model="item.start" value-format="HH:mm" format="HH:mm" placeholder="09:00" />
</ElFormItem>
<ElFormItem label="结束时间">
<ElTimePicker v-model="item.end" value-format="HH:mm" format="HH:mm" placeholder="18:00" />
</ElFormItem>
<ElFormItem label="倍率">
<ElInputNumber
v-model="item.rate"
:min="0.01"
:step="0.01"
:precision="2"
:controls="false"
class="full-width"
/>
</ElFormItem>
</div>
<div class="rate-item__footer">
<span>规则 {{ index + 1 }}</span>
<ElButton text type="danger" @click="removeRateRange(index)">删除</ElButton>
</div>
</article>
</div>
</section>
<section v-if="!form.type" class="form-placeholder">
<ElEmpty description="请选择协议类型后继续配置协议参数。">
<p class="placeholder-copy">不同协议会自动切换不同的安全层传输层与专属配置项</p>
</ElEmpty>
</section>
<NodeEditorProtocolSection v-else :form="form" />
</ElForm>
</div>
<template #footer>
<div class="node-editor-footer">
<span class="footer-hint">当前协议{{ form.type ? currentProtocolLabel : '未选择' }}</span>
<div class="footer-actions">
<ElButton @click="closeDialog">取消</ElButton>
<ElButton type="primary" :loading="submitting" @click="handleSubmit">
{{ props.mode === 'create' ? '提交' : '保存修改' }}
</ElButton>
</div>
</div>
</template>
</ElDialog>
</template>
<style lang="scss" src="./NodeEditorDialog.scss"></style>