mirror of
https://gitcode.com/JianFeeeee/webui4frpc.git
synced 2026-09-20 00:47:57 +00:00
- 零 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 文档
331 lines
8.7 KiB
Vue
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>
|