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

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