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:
2026-08-17 11:00:26 +08:00
commit c1936887a2
40 changed files with 7868 additions and 0 deletions

View File

@ -0,0 +1,172 @@
<template>
<div class="remote-node" :class="{ selected, conflicted }">
<!-- target handle (left) receives connections from locals -->
<Handle type="target" :position="Position.Left" />
<div class="node-head">
<span class="node-icon"></span>
<input v-model="nameField" class="name-input" />
<label class="enabled">
<input v-model="enabledField" type="checkbox" />
启用
</label>
<button
class="del-btn"
title="删除"
@click.stop="emit('remove', props.data.name)"
>
×
</button>
</div>
<div class="fields">
<label>IP <input v-model="ipField" /></label>
<label>端口 <input v-model.number="portField" type="number" /></label>
<label>令牌 <input v-model="tokenField" /></label>
<label>URL <input v-model="urlField" /></label>
</div>
</div>
</template>
<script setup lang="ts">
import { Handle, Position } from '@vue-flow/core'
import { computed } from 'vue'
import type { CanvasRemote } from '../types'
const props = defineProps<{
data: CanvasRemote
selected?: boolean
conflicted?: boolean
}>()
const emit = defineEmits<{
(e: 'update:data', data: CanvasRemote): void
(e: 'remove', name: string): void
}>()
const field = <K extends keyof CanvasRemote>(key: K) =>
computed({
get: () => props.data[key],
set: (val: CanvasRemote[K]) => {
emit('update:data', { ...props.data, [key]: val })
},
})
const nameField = field('name')
const ipField = field('ip')
const portField = field('port')
const tokenField = field('token')
const urlField = field('url')
const enabledField = field('enabled')
</script>
<style scoped lang="scss">
.remote-node {
min-width: 200px;
border-radius: 14px 14px 4px 14px;
background: #fff3e0;
border: 2px solid #ff9800;
box-shadow: 0 2px 8px rgba(255, 152, 0, 0.18);
padding: 8px 10px;
&.selected {
border-color: #ffd54f;
box-shadow: 0 0 0 3px rgba(255, 213, 79, 0.45);
}
&.conflicted {
border-color: #f5222d;
box-shadow: 0 0 0 3px rgba(245, 34, 45, 0.4);
animation: conflict-pulse 1.2s ease-in-out infinite;
}
}
@keyframes conflict-pulse {
0%,
100% {
box-shadow: 0 0 0 3px rgba(245, 34, 45, 0.4);
}
50% {
box-shadow: 0 0 0 5px rgba(245, 34, 45, 0.7);
}
}
.node-head {
display: flex;
align-items: center;
gap: 6px;
.node-icon {
color: #ff9800;
font-size: 16px;
}
}
.name-input {
flex: 1;
min-width: 0;
border: none;
background: transparent;
font-weight: 600;
font-size: 14px;
color: #e65100;
&:focus {
outline: 1px solid #ff9800;
}
}
.enabled {
display: inline-flex;
align-items: center;
gap: 2px;
font-size: 10px;
color: #ef6c00;
}
.del-btn {
border: none;
background: transparent;
color: #ffb74d;
font-size: 16px;
cursor: pointer;
line-height: 1;
&:hover {
color: #c62828;
}
}
.fields {
display: flex;
flex-direction: column;
gap: 4px;
margin-top: 6px;
label {
display: flex;
align-items: center;
gap: 4px;
font-size: 11px;
color: #ef6c00;
input {
flex: 1;
min-width: 0;
border: 1px solid #ffcc80;
border-radius: 6px;
padding: 2px 6px;
font-size: 12px;
background: #fff;
color: #333;
&:focus {
outline: none;
border-color: #ff9800;
}
}
input[type='number'] {
width: 64px;
flex: 0 0 64px;
}
}
}
</style>