mirror of
https://gitcode.com/JianFeeeee/webui4frpc.git
synced 2026-09-20 17:07:57 +00:00
348 lines
9.7 KiB
Vue
348 lines
9.7 KiB
Vue
<template>
|
||
<div class="local-node" :class="{ selected, conflicted }">
|
||
<!-- source handle (right) connects to remote -->
|
||
<Handle type="source" :position="Position.Right" />
|
||
|
||
<div class="node-head">
|
||
<span class="node-icon">⬢</span>
|
||
<input v-model="nameField" class="name-input" />
|
||
<button
|
||
class="del-btn"
|
||
title="删除"
|
||
@click.stop="emit('remove', props.data.name)"
|
||
>
|
||
×
|
||
</button>
|
||
</div>
|
||
|
||
<div class="fields">
|
||
<label>IP <input v-model="ipField" /></label>
|
||
<label>端口 <input v-model.number="portField" type="number" /></label>
|
||
<label
|
||
>协议
|
||
<select v-model="protocolField">
|
||
<option value="tcp">tcp</option>
|
||
<option value="udp">udp</option>
|
||
<option value="http">http</option>
|
||
<option value="https">https</option>
|
||
</select>
|
||
</label>
|
||
<label class="adv-check">
|
||
<input v-model="localOnlyField" type="checkbox" /> 仅本机转发
|
||
<span class="hint">(勾选=直接在本机拉起,不进集群;不勾选=127.0.0.1 替换为本机实际地址交给集群)</span>
|
||
</label>
|
||
</div>
|
||
|
||
<div class="adv">
|
||
<button class="adv-toggle" @click.stop="showAdv = !showAdv">
|
||
{{ showAdv ? '▾ 收起高级' : '▸ 高级' }}
|
||
</button>
|
||
<div v-if="showAdv" class="adv-body">
|
||
<label class="adv-check">
|
||
<input v-model="useEncryptionField" type="checkbox" /> 加密
|
||
(useEncryption)
|
||
</label>
|
||
<label class="adv-check">
|
||
<input v-model="useCompressionField" type="checkbox" /> 压缩
|
||
(useCompression)
|
||
</label>
|
||
<label>限速 <input v-model="bandwidthLimitField" placeholder="如 1MB" /></label>
|
||
<label>连接池 <input v-model.number="poolCountField" type="number" min="0" /></label>
|
||
<label>元数据(JSON) <input v-model="metadatasField" placeholder='{"env":"prod"}' /></label>
|
||
<label>注解(JSON) <input v-model="annotationsField" placeholder='{"owner":"ops"}' /></label>
|
||
|
||
<div class="adv-sep">HTTP/HTTPS 路由</div>
|
||
<label>域名(逗号分隔) <input v-model="customDomainsField" placeholder="app.example.com" /></label>
|
||
<label>子域 <input v-model="subdomainField" placeholder="app (需 frps subdomain_host)" /></label>
|
||
<label>路径路由(JSON) <input v-model="locationsField" placeholder='["/api","/admin"]' /></label>
|
||
<label>Host 改写 <input v-model="hostHeaderRewriteField" placeholder="backend.internal" /></label>
|
||
<label>请求头(JSON) <input v-model="httpHeadersField" placeholder='{"X-Custom":"val"}' /></label>
|
||
<label>BasicAuth 用户 <input v-model="basicAuthUserField" placeholder="user" /></label>
|
||
<label>BasicAuth 密码 <input v-model="basicAuthPasswordField" type="password" placeholder="pass" /></label>
|
||
|
||
<div class="adv-sep">负载均衡 (M3)</div>
|
||
<label>LB 组 <input v-model="lbGroupField" placeholder="如 web (同组自动负载均衡)" /></label>
|
||
<label>组密钥 <input v-model="lbGroupKeyField" placeholder="组内相同" /></label>
|
||
|
||
<div class="adv-sep">健康检查 (M3)</div>
|
||
<label
|
||
>类型
|
||
<select v-model="healthCheckTypeField">
|
||
<option value="">关闭</option>
|
||
<option value="tcp">tcp</option>
|
||
<option value="http">http</option>
|
||
</select>
|
||
</label>
|
||
<label>HTTP 路径 <input v-model="healthCheckPathField" placeholder="/healthz" /></label>
|
||
<label>超时(秒) <input v-model.number="healthCheckTimeoutField" type="number" min="0" placeholder="默认3" /></label>
|
||
<label>失败次数 <input v-model.number="healthCheckMaxFailedField" type="number" min="0" placeholder="默认1" /></label>
|
||
<label>间隔(秒) <input v-model.number="healthCheckIntervalField" type="number" min="0" placeholder="默认10" /></label>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { Handle, Position } from '@vue-flow/core'
|
||
import { computed, ref } from 'vue'
|
||
import type { CanvasLocal } from '../types'
|
||
|
||
const props = defineProps<{
|
||
data: CanvasLocal
|
||
selected?: boolean
|
||
conflicted?: boolean
|
||
}>()
|
||
|
||
const emit = defineEmits<{
|
||
(e: 'update:data', data: CanvasLocal): void
|
||
(e: 'remove', name: string): void
|
||
}>()
|
||
|
||
const field = <K extends keyof CanvasLocal>(key: K) =>
|
||
computed({
|
||
get: () => props.data[key],
|
||
set: (val: CanvasLocal[K]) => {
|
||
emit('update:data', { ...props.data, [key]: val })
|
||
},
|
||
})
|
||
|
||
const nameField = field('name')
|
||
const ipField = field('ip')
|
||
const portField = field('port')
|
||
const protocolField = field('protocol')
|
||
const localOnlyField = field('localOnly')
|
||
const useEncryptionField = field('useEncryption')
|
||
const useCompressionField = field('useCompression')
|
||
const bandwidthLimitField = field('bandwidthLimit')
|
||
const poolCountField = field('poolCount')
|
||
|
||
// map fields shown as JSON strings in the UI
|
||
const showAdv = ref(false)
|
||
const metadatasField = computed({
|
||
get: () => JSON.stringify(props.data.metadatas ?? {}),
|
||
set: (v: string) => {
|
||
let parsed: Record<string, string> = {}
|
||
if (v.trim()) {
|
||
try {
|
||
parsed = JSON.parse(v)
|
||
} catch {
|
||
return // keep old value on invalid JSON
|
||
}
|
||
}
|
||
emit('update:data', { ...props.data, metadatas: parsed })
|
||
},
|
||
})
|
||
const annotationsField = computed({
|
||
get: () => JSON.stringify(props.data.annotations ?? {}),
|
||
set: (v: string) => {
|
||
let parsed: Record<string, string> = {}
|
||
if (v.trim()) {
|
||
try {
|
||
parsed = JSON.parse(v)
|
||
} catch {
|
||
return
|
||
}
|
||
}
|
||
emit('update:data', { ...props.data, annotations: parsed })
|
||
},
|
||
})
|
||
const customDomainsField = field('customDomains')
|
||
const subdomainField = field('subdomain')
|
||
const hostHeaderRewriteField = field('hostHeaderRewrite')
|
||
const basicAuthUserField = field('basicAuthUser')
|
||
const basicAuthPasswordField = field('basicAuthPassword')
|
||
const lbGroupField = field('lbGroup')
|
||
const lbGroupKeyField = field('lbGroupKey')
|
||
const healthCheckTypeField = field('healthCheckType')
|
||
const healthCheckPathField = field('healthCheckPath')
|
||
const healthCheckTimeoutField = field('healthCheckTimeout')
|
||
const healthCheckMaxFailedField = field('healthCheckMaxFailed')
|
||
const healthCheckIntervalField = field('healthCheckInterval')
|
||
|
||
// locations is an array shown as JSON in the UI
|
||
const locationsField = computed({
|
||
get: () => JSON.stringify(props.data.locations ?? []),
|
||
set: (v: string) => {
|
||
let parsed: string[] = []
|
||
if (v.trim()) {
|
||
try {
|
||
const arr = JSON.parse(v)
|
||
if (Array.isArray(arr)) parsed = arr.map(String)
|
||
else return
|
||
} catch {
|
||
return
|
||
}
|
||
}
|
||
emit('update:data', { ...props.data, locations: parsed })
|
||
},
|
||
})
|
||
// httpHeaders is a map shown as JSON
|
||
const httpHeadersField = computed({
|
||
get: () => JSON.stringify(props.data.httpHeaders ?? {}),
|
||
set: (v: string) => {
|
||
let parsed: Record<string, string> = {}
|
||
if (v.trim()) {
|
||
try {
|
||
parsed = JSON.parse(v)
|
||
} catch {
|
||
return
|
||
}
|
||
}
|
||
emit('update:data', { ...props.data, httpHeaders: parsed })
|
||
},
|
||
})
|
||
</script>
|
||
|
||
<style scoped lang="scss">
|
||
.local-node {
|
||
min-width: 190px;
|
||
border-radius: 14px 14px 14px 4px;
|
||
background: #e8f5e9;
|
||
border: 2px solid #4caf50;
|
||
box-shadow: 0 2px 8px rgba(76, 175, 80, 0.18);
|
||
padding: 8px 10px;
|
||
|
||
&.selected {
|
||
border-color: #ffd54f;
|
||
box-shadow: 0 0 0 3px rgba(255, 213, 79, 0.45);
|
||
}
|
||
|
||
&.conflicted {
|
||
border-color: #f5222d;
|
||
box-shadow: 0 0 0 3px rgba(245, 34, 45, 0.4);
|
||
animation: conflict-pulse 1.2s ease-in-out infinite;
|
||
}
|
||
}
|
||
|
||
@keyframes conflict-pulse {
|
||
0%,
|
||
100% {
|
||
box-shadow: 0 0 0 3px rgba(245, 34, 45, 0.4);
|
||
}
|
||
50% {
|
||
box-shadow: 0 0 0 5px rgba(245, 34, 45, 0.7);
|
||
}
|
||
}
|
||
|
||
.node-head {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
|
||
.node-icon {
|
||
color: #4caf50;
|
||
font-size: 16px;
|
||
}
|
||
}
|
||
|
||
.name-input {
|
||
flex: 1;
|
||
min-width: 0;
|
||
border: none;
|
||
background: transparent;
|
||
font-weight: 600;
|
||
font-size: 14px;
|
||
color: #2e7d32;
|
||
&:focus {
|
||
outline: 1px solid #4caf50;
|
||
}
|
||
}
|
||
|
||
.del-btn {
|
||
border: none;
|
||
background: transparent;
|
||
color: #81c784;
|
||
font-size: 16px;
|
||
cursor: pointer;
|
||
line-height: 1;
|
||
&:hover {
|
||
color: #c62828;
|
||
}
|
||
}
|
||
|
||
.fields {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 4px;
|
||
margin-top: 6px;
|
||
|
||
label {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 4px;
|
||
font-size: 11px;
|
||
color: #558b2f;
|
||
|
||
input,
|
||
select {
|
||
flex: 1;
|
||
min-width: 0;
|
||
border: 1px solid #a5d6a7;
|
||
border-radius: 6px;
|
||
padding: 2px 6px;
|
||
font-size: 12px;
|
||
background: #fff;
|
||
color: #333;
|
||
&:focus {
|
||
outline: none;
|
||
border-color: #4caf50;
|
||
}
|
||
}
|
||
|
||
input[type='number'] {
|
||
width: 64px;
|
||
flex: 0 0 64px;
|
||
}
|
||
}
|
||
|
||
.adv {
|
||
margin-top: 6px;
|
||
.adv-toggle {
|
||
border: 1px dashed #a5d6a7;
|
||
background: transparent;
|
||
color: #558b2f;
|
||
font-size: 11px;
|
||
border-radius: 6px;
|
||
padding: 2px 8px;
|
||
cursor: pointer;
|
||
}
|
||
.adv-body {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 4px;
|
||
margin-top: 6px;
|
||
padding: 6px;
|
||
background: rgba(76, 175, 80, 0.06);
|
||
border-radius: 8px;
|
||
|
||
label {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 4px;
|
||
font-size: 11px;
|
||
color: #558b2f;
|
||
|
||
input,
|
||
select {
|
||
flex: 1;
|
||
min-width: 0;
|
||
border: 1px solid #a5d6a7;
|
||
border-radius: 6px;
|
||
padding: 2px 6px;
|
||
font-size: 12px;
|
||
background: #fff;
|
||
color: #333;
|
||
&:focus {
|
||
outline: none;
|
||
border-color: #4caf50;
|
||
}
|
||
}
|
||
}
|
||
.adv-check {
|
||
font-size: 11px;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
</style>
|