webui4frpc: 独立可用的可视化 frpc 控制器 (M0)

- 零 frp 源码依赖,单二进制 (Go + Vue3 + VueFlow + Element Plus)
- 画布多对多连线,渲染 tcp/udp/http/https frpc 配置
- worker 进程管理:自愈、日志轮转、崩溃退避重启
- frpc 一键安装 (GitHub Releases) + 手动指定路径
- 三页 UI:状态(默认)/连接配置/设置
- 状态页实时节点/转发状态,节点可增删改启停
- 画布冲突检查:端口/域名冲突标红 + 弹窗拦截保存
- backend 单测覆盖 store/render/process/httpapi/install
- plan.md + FRPC_FEATURES_AUDIT.md 文档
This commit is contained in:
2026-08-17 11:00:26 +08:00
commit c1936887a2
40 changed files with 7868 additions and 0 deletions

View File

@ -0,0 +1,166 @@
<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>
</div>
</div>
</template>
<script setup lang="ts">
import { Handle, Position } from '@vue-flow/core'
import { computed } 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')
</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;
}
}
}
</style>

View File

@ -0,0 +1,330 @@
<template>
<g class="port-edge" :class="{ selected }">
<path
:d="path"
class="port-edge-path"
:class="{ selected, conflicted }"
fill="none"
:stroke="strokeColor"
:stroke-width="selected ? 3.5 : 2.5"
/>
<!-- Draggable port label. Dragging it moves the curve (and its midpoint)
so users can place the port anywhere along/around the connection. -->
<EdgeLabelRenderer>
<div
ref="labelEl"
class="port-label"
:class="{ dragging }"
:style="labelStyle"
@pointerdown.stop.prevent="onPointerDown"
@pointermove="onPointerMove"
@pointerup="onPointerUp"
@pointercancel="onPointerUp"
>
{{ displayPort }}
</div>
</EdgeLabelRenderer>
</g>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { EdgeLabelRenderer } from '@vue-flow/core'
import type { EdgeProps } from '@vue-flow/core'
const props = withDefaults(
defineProps<EdgeProps & { conflicted?: boolean }>(),
{
selected: false,
conflicted: false,
},
)
const emit = defineEmits<{
(e: 'label-click', payload: { edgeId: string }): void
(
e: 'label-drag',
payload: { edgeId: string; offset: { x: number; y: number } },
): void
}>()
// User-dragged offset relative to the curve midpoint, initialized from the
// edge data so it survives saves.
const dragOffset = ref<{ x: number; y: number }>({
x: Number(props.data?.offsetX ?? 0),
y: Number(props.data?.offsetY ?? 0),
})
// Press-to-drag using Pointer Events + pointer capture. Once the pointer is
// captured on the label element, all subsequent pointer events (even outside
// the element) are delivered to it, and pointerup reliably ends the drag.
const labelEl = ref<HTMLElement | null>(null)
const clickSlop = 2 // px of total travel that still counts as a click
let moved = false
let startX = 0
let startY = 0
let startOff = { x: 0, y: 0 }
let dragging = false
const onPointerDown = (e: PointerEvent) => {
// Never let this press reach the Vue Flow pane (which would pan the canvas).
e.stopPropagation()
dragging = true
moved = false
startX = e.clientX
startY = e.clientY
startOff = { ...dragOffset.value }
if (labelEl.value) {
try {
labelEl.value.setPointerCapture(e.pointerId)
} catch {
// Capture can fail in rare cases; the pointermove/up listeners below
// still work while the pointer stays over the label.
}
}
e.preventDefault()
}
const onPointerMove = (e: PointerEvent) => {
if (!dragging) return
const dx = e.clientX - startX
const dy = e.clientY - startY
if (Math.abs(dx) > clickSlop || Math.abs(dy) > clickSlop) {
moved = true
}
// The label lives in canvas (world) coordinates; pointer deltas are in
// screen pixels, so divide by the current viewport zoom.
const z = readZoom()
dragOffset.value = {
x: startOff.x + dx / z,
y: startOff.y + dy / z,
}
emit('label-drag', {
edgeId: props.id,
offset: { ...dragOffset.value },
})
}
const onPointerUp = (e: PointerEvent) => {
e.stopPropagation()
const wasDrag = dragging
dragging = false
if (labelEl.value && e.pointerId != null) {
try {
labelEl.value.releasePointerCapture?.(e.pointerId)
} catch {
// releasePointerCapture may throw if capture was never established.
}
}
// Press without real movement = tap: open the port editor.
if (!wasDrag) return
if (!moved) {
emit('label-click', { edgeId: props.id })
}
}
// readZoom parses the current zoom level from the Vue Flow viewport's
// transform (e.g. "translate(10px, 20px) scale(0.85)"). Falls back to 1.
function readZoom(): number {
const vp = document.querySelector('.vue-flow__viewport') as HTMLElement | null
if (!vp) return 1
const m = /scale\(([0-9.]+)\)/.exec(vp.style.transform || '')
if (!m) return 1
const z = Number(m[1])
return z > 0 ? z : 1
}
// {{ layer }} is a per-pair index assigned by the canvas editor. It bends this
// edge away from its siblings so multiple links between the same two nodes do
// not overlap. Rendering order follows the layer too, so later links are drawn
// above earlier ones.
const layer = computed(() => Number(props.data?.layer ?? 0))
const displayPort = computed(() => {
const t = String(props.label ?? '')
return t || '?'
})
// Direction of the base bend alternates so pairs of links spread symmetrically
// above and below the straight line.
const baseBend = computed(() => {
const l = layer.value
const dir = l % 2 === 0 ? 1 : -1
const mag = (Math.floor(l / 2) + 1) * 16
return dir * mag
})
// totalBend adds the user's vertical drag onto the base layer bend so the
// curve follows the dragged label.
const totalBend = computed(() => baseBend.value + dragOffset.value.y)
const path = computed(() => {
const { sourceX, sourceY, sourcePosition, targetX, targetY, targetPosition } =
props
return buildBentPath(
sourceX,
sourceY,
sourcePosition,
targetX,
targetY,
targetPosition,
totalBend.value,
)
})
// The label sits at the curve midpoint, shifted horizontally by the user's
// drag offset (vertical drag is already absorbed into totalBend).
const mid = computed(() => midpointOf(path.value))
const labelStyle = computed(() => ({
position: 'absolute' as const,
transform: `translate(${mid.value.x + dragOffset.value.x}px, ${mid.value.y}px) translate(-50%, -50%)`,
pointerEvents: 'all' as const,
cursor: 'grab',
zIndex: 10,
}))
const strokeColor = computed(() => {
if (props.conflicted) {
return '#f5222d'
}
const l = layer.value
// Muted, UI-friendly palette that matches the existing theme (Element Plus
// primary / success / warning / danger / info), cycled per layer.
const hues = [
'#409eff',
'#67c23a',
'#e6a23c',
'#f56c6c',
'#909399',
'#409eff',
]
return hues[l % hues.length]
})
// buildBentPath draws a cubic bezier from the source handle to the target
// handle, bending the control points vertically by (bend) pixels so parallel
// links between the same nodes separate visually.
function buildBentPath(
sourceX: number,
sourceY: number,
sourcePosition: unknown,
targetX: number,
targetY: number,
targetPosition: unknown,
bend: number,
): string {
const horizontal = Math.abs(targetX - sourceX)
const handleLen = Math.max(horizontal * 0.45, 40)
let sx = sourceX
let sy = sourceY
let tx = targetX
let ty = targetY
if (sourcePosition === 'Right') {
sx = sourceX
} else if (sourcePosition === 'Left') {
sx = sourceX - handleLen
} else if (sourcePosition === 'Top') {
sx = sourceX
sy = sourceY
} else {
sx = sourceX
}
if (targetPosition === 'Left') {
tx = targetX
} else if (targetPosition === 'Right') {
tx = targetX + handleLen
} else if (targetPosition === 'Top') {
ty = targetY
}
const c1x = sx + (tx - sx) * 0.5
const c1y = sy + bend
const c2x = tx - (tx - sx) * 0.5
const c2y = ty + bend
return `M ${sx} ${sy} C ${c1x} ${c1y}, ${c2x} ${c2y}, ${tx} ${ty}`
}
// midpointOf approximates the curve center using the quadratic mean of the two
// evaluated bezier points at t=0.5 (single evaluation is enough for labels).
function midpointOf(path: string): { x: number; y: number } {
// Parse the cubic bezier control points out of the path string.
const nums = path
.replace('M', '')
.replace('C', '')
.split(/[ ,]+/)
.filter((s) => s.length > 0)
.map(Number)
if (nums.length < 8) {
return { x: 0, y: 0 }
}
const x0 = nums[0] ?? 0
const y0 = nums[1] ?? 0
const cx1 = nums[2] ?? 0
const cy1 = nums[3] ?? 0
const cx2 = nums[4] ?? 0
const cy2 = nums[5] ?? 0
const x1 = nums[6] ?? 0
const y1 = nums[7] ?? 0
const t = 0.5
const mt = 1 - t
const x =
mt * mt * mt * x0 +
3 * mt * mt * t * cx1 +
3 * mt * t * t * cx2 +
t * t * t * x1
const y =
mt * mt * mt * y0 +
3 * mt * mt * t * cy1 +
3 * mt * t * t * cy2 +
t * t * t * y1
return { x, y }
}
</script>
<style scoped lang="scss">
.port-edge-path {
&.selected {
filter: drop-shadow(0 0 4px rgba(244, 67, 54, 0.6));
}
&.conflicted {
filter: drop-shadow(0 0 6px rgba(245, 34, 45, 0.8));
animation: conflict-pulse 1.2s ease-in-out infinite;
}
}
@keyframes conflict-pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.55;
}
}
.port-label {
background: rgba(255, 255, 255, 0.92);
color: $color-text-primary;
border: 1px solid $color-border-light;
border-radius: 10px;
padding: 2px 10px;
font-size: 12px;
font-weight: 600;
font-family: inherit;
white-space: nowrap;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.12);
user-select: none;
transition:
box-shadow $transition-fast,
transform $transition-fast;
&.dragging {
cursor: grabbing;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.22);
}
}
</style>

View File

@ -0,0 +1,172 @@
<template>
<div class="remote-node" :class="{ selected, conflicted }">
<!-- target handle (left) receives connections from locals -->
<Handle type="target" :position="Position.Left" />
<div class="node-head">
<span class="node-icon"></span>
<input v-model="nameField" class="name-input" />
<label class="enabled">
<input v-model="enabledField" type="checkbox" />
启用
</label>
<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>令牌 <input v-model="tokenField" /></label>
<label>URL <input v-model="urlField" /></label>
</div>
</div>
</template>
<script setup lang="ts">
import { Handle, Position } from '@vue-flow/core'
import { computed } from 'vue'
import type { CanvasRemote } from '../types'
const props = defineProps<{
data: CanvasRemote
selected?: boolean
conflicted?: boolean
}>()
const emit = defineEmits<{
(e: 'update:data', data: CanvasRemote): void
(e: 'remove', name: string): void
}>()
const field = <K extends keyof CanvasRemote>(key: K) =>
computed({
get: () => props.data[key],
set: (val: CanvasRemote[K]) => {
emit('update:data', { ...props.data, [key]: val })
},
})
const nameField = field('name')
const ipField = field('ip')
const portField = field('port')
const tokenField = field('token')
const urlField = field('url')
const enabledField = field('enabled')
</script>
<style scoped lang="scss">
.remote-node {
min-width: 200px;
border-radius: 14px 14px 4px 14px;
background: #fff3e0;
border: 2px solid #ff9800;
box-shadow: 0 2px 8px rgba(255, 152, 0, 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: #ff9800;
font-size: 16px;
}
}
.name-input {
flex: 1;
min-width: 0;
border: none;
background: transparent;
font-weight: 600;
font-size: 14px;
color: #e65100;
&:focus {
outline: 1px solid #ff9800;
}
}
.enabled {
display: inline-flex;
align-items: center;
gap: 2px;
font-size: 10px;
color: #ef6c00;
}
.del-btn {
border: none;
background: transparent;
color: #ffb74d;
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: #ef6c00;
input {
flex: 1;
min-width: 0;
border: 1px solid #ffcc80;
border-radius: 6px;
padding: 2px 6px;
font-size: 12px;
background: #fff;
color: #333;
&:focus {
outline: none;
border-color: #ff9800;
}
}
input[type='number'] {
width: 64px;
flex: 0 0 64px;
}
}
}
</style>