Files
webui4frpc/web/src/components/PortEdge.vue
jianf c1936887a2 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 文档
2026-08-17 11:00:26 +08:00

331 lines
8.7 KiB
Vue

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