fix: 完善同事未完成的修改 — LAN 地址检测/画布删除撤销/连线删除按钮/Del 键/注释修正

- cmd/webui4frpc/main.go: 新增 primaryLANIPv4() 检测内网 LAN 地址,
  解决三台内网主机间集群发现失败 (reachableAddr 只回退到 hostname)
- internal/httpapi/handlers.go: applyCanvas 新增拓扑差异检测,
  画布删除连线时自动 RevokeTask 避免集群残留
- web/src/api.ts: 新增 addLink / deleteLink API 封装
- web/src/components/PortEdge.vue: × 删除按钮移到端口标签右上角 (绝对定位)
- web/src/views/CanvasView.vue: 启用 Del/Backspace 键删除选中连线
  (确认对话框 + 走 onDeleteEdge 逻辑), 修正注释
- embed dist 同步
This commit is contained in:
JianFeeeee
2026-08-24 01:03:14 +08:00
parent b39bd427fa
commit 2292ee7f3a
10 changed files with 258 additions and 84 deletions

1
.gitignore vendored
View File

@ -30,3 +30,4 @@ gui-test-screenshots/
# vendored deps (regenerable; keep out of git)
vendor/
.pi-glla/

View File

@ -568,8 +568,9 @@ func retryRejoinCached(ctx context.Context, ring *cluster.Engine, peersJSON stri
}
// reachableAddr returns an address peers can dial back: an explicit
// W4F_HOST (compose DNS name) wins; otherwise a non-wildcard listen addr; a
// wildcard falls back to the hostname (routable inside docker networks).
// W4F_HOST (compose DNS name) wins; otherwise a non-wildcard listen addr;
// a wildcard falls back to the primary non-loopback IPv4 detected from the
// interfaces (routable on plain LAN hosts); hostname is the last resort.
func reachableAddr(addr string) string {
if h := os.Getenv("W4F_HOST"); h != "" {
if _, port, err := net.SplitHostPort(addr); err == nil {
@ -582,6 +583,9 @@ func reachableAddr(addr string) string {
return addr
}
if host == "" || host == "0.0.0.0" || host == "::" || host == "[::]" {
if ip := primaryLANIPv4(); ip != "" {
return net.JoinHostPort(ip, port)
}
if h, herr := os.Hostname(); herr == nil && h != "" {
return net.JoinHostPort(h, port)
}
@ -589,6 +593,31 @@ func reachableAddr(addr string) string {
return addr
}
// primaryLANIPv4 returns the first global-scope non-loopback IPv4 address of
// any up interface, preferring RFC1918 ranges. Empty string when none found.
func primaryLANIPv4() string {
addrs, err := net.InterfaceAddrs()
if err != nil {
return ""
}
var fallback string
for _, a := range addrs {
ipnet, ok := a.(*net.IPNet)
if !ok || ipnet.IP.To4() == nil || ipnet.IP.IsLoopback() || !ipnet.IP.IsGlobalUnicast() {
continue
}
ip := ipnet.IP.To4()
if fallback == "" {
fallback = ip.String()
}
// prefer private ranges over link-local / unusual globals
if ip[0] == 10 || (ip[0] == 172 && ip[1]&0xf0 == 16) || (ip[0] == 192 && ip[1] == 168) {
return ip.String()
}
}
return fallback
}
// splitCSV splits a comma-separated list into a slice (empty -> nil).
func splitCSV(s string) []string {
if strings.TrimSpace(s) == "" {

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -5,8 +5,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<title>webui4frpc</title>
<script type="module" crossorigin src="/assets/index-C5WLVFP2.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CL42Ur3_.css">
<script type="module" crossorigin src="/assets/index-D0MfyrKF.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-D67FPIKJ.css">
</head>
<body>
<div id="app"></div>

View File

@ -184,15 +184,45 @@ func (h *Handler) applyCanvas(w http.ResponseWriter, r *http.Request, canvas *ca
// extended so a per-forward stop made on the forwards page (disabled=true)
// is not re-activated by a later canvas save. Local-only forwards are NOT
// submitted — they stay on this node.
localByName := map[string]store.Local{}
localByName := make(map[string]store.Local, len(canvas.Locals))
for _, l := range canvas.Locals {
localByName[l.Name] = l
}
remoteByName := map[string]store.Remote{}
remoteByName := make(map[string]store.Remote, len(canvas.Remotes))
for _, r := range canvas.Remotes {
remoteByName[r.Name] = r
}
// Build a set of (local, remote, remotePort) triples from the incoming
// canvas links so we can revoke any stale topology entries no longer
// present in the canvas (e.g. links that were deleted from the UI).
type triple struct{ local, remote string; port int }
canvasTriples := make(map[triple]bool, len(canvas.Links))
for _, ln := range canvas.Links {
canvasTriples[triple{ln.Local, ln.Remote, ln.RemotePort}] = true
}
if h.Ring != nil {
// Revoke topology entries that the canvas no longer references.
// Only revoke entries whose local name is known to this node (we only
// own tasks for locals we created). The topology is a ring-wide view
// and includes forwards owned by other nodes.
snap := h.Ring.Snapshot()
for _, t := range snap.Topology {
if _, ok := localByName[t.Local.Name]; !ok {
continue // local not in this node's canvas — skip
}
if t.Local.LocalOnly {
continue
}
if canvasTriples[triple{t.Local.Name, t.Remote.Name, t.Link.RemotePort}] {
continue // still in canvas — keep
}
// This forward is in the ring topology but NOT in the new canvas.
// Submit a REVOKE so the owning node cleans it up.
h.Ring.RevokeTask(t.Local, t.Remote, t.Link)
}
for _, ln := range canvas.Links {
loc, ok := localByName[ln.Local]
if !ok || loc.LocalOnly {

View File

@ -72,6 +72,30 @@ export const api = {
method: "DELETE",
}),
// Add a single link (POST /links). The canvas save path (saveCanvas) does a
// wholesale link replace, so this is for incremental single-link adds from
// other surfaces (e.g. a forwards list). For a cluster (non-localOnly)
// forward the backend also submits a ring task so the owning node claims it.
addLink: (link: {
local: string;
remote: string;
remotePort: number;
group?: string;
}) =>
request<import("./types").Link>(`/api/manager/links`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(link),
}),
// Delete a single link by id. When present in the canvas save payload,
// links are replaced wholesale, so the typical path is "remove from
// canvas edges + save". This endpoint is kept for explicit removal
// (e.g. delete from a forwards list) — it also revokes cluster tasks
// for the removed forward.
deleteLink: (id: number) =>
request<void>(`/api/manager/links/${id}`, { method: "DELETE" }),
// Per-forward start/stop (forwards page). local-only forwards toggle the
// local frpc worker; cluster forwards submit/revoke via the ring.
forwardStart: (local: string, remote: string, remotePort: number) =>

View File

@ -34,6 +34,12 @@
@pointerdown.stop
@click.stop="emit('edit-group', { edgeId: props.id })"
>{{ groupName }}</span>
<span
class="port-delete"
@pointerdown.stop
@click.stop="emit('delete-edge', { edgeId: props.id })"
title="删除连线"
>×</span>
</div>
</EdgeLabelRenderer>
</g>
@ -60,6 +66,7 @@ const emit = defineEmits<{
): void
(e: 'toggle-disabled', payload: { edgeId: string }): void
(e: 'edit-group', payload: { edgeId: string }): void
(e: 'delete-edge', payload: { edgeId: string }): void
}>()
// User-dragged offset relative to the curve midpoint, initialized from the
@ -331,6 +338,7 @@ function midpointOf(path: string): { x: number; y: number } {
}
.port-label {
position: relative;
background: color-mix(in srgb, var(--w4f-card-solid) 90%, transparent);
color: $color-text-primary;
border: 1px solid $color-border-light;
@ -383,5 +391,29 @@ function midpointOf(path: string): { x: number; y: number } {
cursor: pointer;
transition: background $transition-fast;
}
.port-delete {
position: absolute;
top: -10px;
right: -10px;
display: inline-flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
border-radius: 50%;
margin-left: 0;
font-size: 13px;
font-weight: 700;
line-height: 1;
background: color-mix(in srgb, var(--el-color-danger) 16%, transparent);
color: var(--el-color-danger);
cursor: pointer;
transition: background $transition-fast, color $transition-fast;
&:hover {
background: var(--el-color-danger);
color: #fff;
}
}
}
</style>

View File

@ -65,6 +65,7 @@
@label-drag="onLabelDrag"
@toggle-disabled="onToggleDisabled"
@edit-group="onEditGroup"
@delete-edge="onDeleteEdge"
/>
</template>
</VueFlow>
@ -466,6 +467,61 @@ const editEdge = (edge: Edge) => {
)
}
// onDeleteEdge removes an edge from the canvas after a confirm dialog.
// The removed link is persisted on the next save: applyCanvas (PUT /canvas)
// replaces links wholesale and revokes any cluster task for links that
// disappeared. The × badge on the edge label is the quick entry; the
// Delete/Backspace key (keydown handler below) removes SELECTED edges.
const onDeleteEdge = async ({ edgeId }: { edgeId: string }) => {
const e = edges.value.find((x) => x.id === edgeId)
if (!e) return
try {
await ElMessageBox.confirm('确认删除该连线?删除后需点击「保存配置」生效。', '删除连线', {
confirmButtonText: '删除',
cancelButtonText: '取消',
type: 'warning',
})
} catch {
return // cancelled
}
removeEdgesSilently([edgeId])
}
// onCanvasKeydown handles Delete/Backspace to drop all SELECTED edges after a
// single shared confirmation (VueFlow's own delete-key handling stays disabled
// via :delete-key-code="null" because we must keep removal behind the same
// confirm dialog; nodes have their own delete buttons).
const onCanvasKeydown = async (ev: KeyboardEvent) => {
if (ev.key !== 'Delete' && ev.key !== 'Backspace') return
const tag = (ev.target as HTMLElement | null)?.tagName
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return
if (!canWrite.value) return
const selectedIds = edges.value
.filter((x): x is Edge & { selected?: boolean } => Boolean((x as Edge & { selected?: boolean }).selected))
.map((x) => x.id)
if (selectedIds.length === 0) return
ev.preventDefault()
try {
await ElMessageBox.confirm(
`确认删除选中的 ${selectedIds.length} 条连线?删除后需点击「保存配置」生效。`,
'删除连线',
{ confirmButtonText: '删除', cancelButtonText: '取消', type: 'warning' },
)
} catch {
return // cancelled
}
removeEdgesSilently(selectedIds)
}
// removeEdgesSilently drops the given edge ids from the canvas without its own
// prompt (the callers already confirmed). Marks the canvas dirty.
const removeEdgesSilently = (ids: string[]) => {
const gone = new Set(ids)
edges.value = edges.value.filter((x) => !gone.has(x.id))
assignLayers()
dirty.value = true
}
// onEdgeLabelClick finds the edge by id and opens its port editor.
const onEdgeLabelClick = ({ edgeId }: { edgeId: string }) => {
const e = edges.value.find((x) => x.id === edgeId)
@ -815,8 +871,10 @@ onMounted(() => {
attributes: true,
attributeFilter: ['data-theme'],
})
window.addEventListener('keydown', onCanvasKeydown)
})
onBeforeUnmount(() => {
window.removeEventListener('keydown', onCanvasKeydown)
themeObserver?.disconnect()
themeObserver = null
})