mirror of
https://gitcode.com/JianFeeeee/webui4frpc.git
synced 2026-09-20 17:07:57 +00:00
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:
1
.gitignore
vendored
1
.gitignore
vendored
@ -30,3 +30,4 @@ gui-test-screenshots/
|
||||
|
||||
# vendored deps (regenerable; keep out of git)
|
||||
vendor/
|
||||
.pi-glla/
|
||||
|
||||
@ -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) == "" {
|
||||
|
||||
77
internal/httpapi/dist/assets/index-C5WLVFP2.js
vendored
77
internal/httpapi/dist/assets/index-C5WLVFP2.js
vendored
File diff suppressed because one or more lines are too long
77
internal/httpapi/dist/assets/index-D0MfyrKF.js
vendored
Normal file
77
internal/httpapi/dist/assets/index-D0MfyrKF.js
vendored
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
4
internal/httpapi/dist/index.html
vendored
4
internal/httpapi/dist/index.html
vendored
@ -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>
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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) =>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user