mirror of
https://gitcode.com/JianFeeeee/webui4frpc.git
synced 2026-09-20 00:47:57 +00:00
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:
330
web/src/components/PortEdge.vue
Normal file
330
web/src/components/PortEdge.vue
Normal 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>
|
||||
Reference in New Issue
Block a user