Files
webui4frpc/web/src/components/LocalNode.vue

348 lines
9.7 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.

<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>