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,497 @@
<template>
<div class="status-page">
<div class="status-top">
<h2 class="page-title">状态</h2>
<span class="refresh-hint"> 5 秒自动刷新</span>
<button class="refresh-btn" @click="loadStatus">刷新</button>
<button class="add-btn" @click="openAdd"> 添加远程节点</button>
</div>
<div class="status-body" v-if="status">
<!-- 远程节点状态 -->
<section class="status-section">
<h3 class="status-title">远程节点状态</h3>
<div class="status-cards">
<div
v-for="p in status.profiles"
:key="p.name"
class="node-status-card"
:class="{ healthy: workerHealthy(p.process.state) }"
>
<div class="ns-head">
<span class="ns-name">{{ p.name }}</span>
<span class="ns-badge" :class="{ ok: workerHealthy(p.process.state) }">
{{ workerStateLabel(p.process.state) }}
</span>
<span class="ns-actions">
<button v-if="p.process.state === 'running'" class="mini-btn" @click="stopRemote(p.name)">停止</button>
<button v-else class="mini-btn" @click="startRemote(p.name)">启动</button>
<button class="mini-btn" @click="openEdit(p)">编辑</button>
<button class="mini-btn danger" @click="removeRemote(p.name)">删除</button>
</span>
</div>
<div class="ns-meta">
<span v-if="p.process.pid">pid {{ p.process.pid }}</span>
<span class="ns-err" v-else-if="p.process.err">{{ p.process.err }}</span>
</div>
<div class="ns-forwards" v-if="p.forwards.length">
<span v-for="f in p.forwards" :key="f.service" class="ns-fwd">
{{ f.service }} :{{ f.remotePort }}
</span>
</div>
</div>
<div v-if="!status.profiles.length" class="ns-empty">尚未配置远程节点</div>
</div>
</section>
<!-- 本地服务转发状态 -->
<section class="status-section">
<h3 class="status-title">本地服务转发</h3>
<div class="local-status-list">
<div v-for="ls in status.localStatus" :key="ls.local.name" class="local-status-card">
<div class="ls-head">
<span class="ls-name">{{ ls.local.name }}</span>
<span class="ls-addr">{{ ls.local.ip }}:{{ ls.local.port }}</span>
<span class="ls-proto">{{ ls.local.protocol }}</span>
</div>
<div class="ls-targets">
<div v-for="t in ls.targets" :key="t.remote + ':' + t.remotePort" class="ls-target">
<span class="ls-arrow"></span>
<span class="ls-remote">{{ t.remote }}</span>
<span class="ls-port">:{{ t.remotePort }}</span>
<span class="ls-state" :class="{ ok: workerHealthy(t.workerState) }">
{{ workerStateLabel(t.workerState) }}
</span>
</div>
<div v-if="!ls.targets.length" class="ls-none">未转发</div>
</div>
</div>
<div v-if="!status.localStatus.length" class="ls-empty">尚未配置本地服务</div>
</div>
</section>
</div>
<el-dialog v-model="dialog.visible" :title="dialog.isNew ? '添加远程节点' : '编辑远程节点'" width="440px">
<div class="form-row">
<label>名称</label>
<el-input v-model="dialog.remote.name" :disabled="!dialog.isNew" placeholder="如 aliyun-hz" />
</div>
<div class="form-row">
<label>服务器 IP</label>
<el-input v-model="dialog.remote.ip" placeholder="frps 地址" />
</div>
<div class="form-row">
<label>端口</label>
<el-input-number v-model="dialog.remote.port" :min="1" :max="65535" />
</div>
<div class="form-row">
<label>Token</label>
<el-input v-model="dialog.remote.token" placeholder="可选" />
</div>
<div class="form-row">
<label>URL</label>
<el-input v-model="dialog.remote.url" placeholder="可选" />
</div>
<div class="form-row">
<label>启用</label>
<el-switch v-model="dialog.remote.enabled" />
</div>
<template #footer>
<button class="refresh-btn" @click="dialog.visible = false">取消</button>
<button class="add-btn" @click="saveRemote">保存</button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted, onBeforeUnmount } from 'vue'
import { ElMessage } from 'element-plus'
import { api } from '../api'
import type { Remote, StatusResp } from '../types'
const status = ref<StatusResp | null>(null)
let timer: number | null = null
interface RemoteDialog {
visible: boolean
isNew: boolean
remote: Remote
}
const newRemote = (): Remote => ({
name: '',
ip: '',
port: 7000,
token: '',
url: '',
enabled: true,
})
const dialog = reactive<RemoteDialog>({
visible: false,
isNew: true,
remote: newRemote(),
})
const openAdd = () => {
dialog.isNew = true
dialog.remote = newRemote()
dialog.visible = true
}
const openEdit = (p: { name: string }) => {
const found = status.value?.remotes.find((r) => r.name === p.name)
dialog.isNew = false
dialog.remote = found
? { ...found }
: { name: p.name, ip: '', port: 7000, token: '', url: '', enabled: true }
dialog.visible = true
}
const saveRemote = async () => {
const d = dialog.remote
if (!d.name || !d.ip || d.port <= 0) {
ElMessage.warning('请填写名称、IP、端口')
return
}
try {
await api.saveRemote(d)
dialog.visible = false
ElMessage.success(dialog.isNew ? '节点已添加' : '节点已更新')
await loadStatus()
} catch (e: any) {
ElMessage.error('保存失败: ' + (e.message || e))
}
}
const startRemote = async (name: string) => {
try {
await api.profileStart(name)
await loadStatus()
} catch (e: any) {
ElMessage.error('启动失败: ' + (e.message || e))
}
}
const stopRemote = async (name: string) => {
try {
await api.profileStop(name)
await loadStatus()
} catch (e: any) {
ElMessage.error('停止失败: ' + (e.message || e))
}
}
const removeRemote = async (name: string) => {
if (!window.confirm(`删除远程节点 ${name}`)) return
try {
await api.deleteRemote(name)
await loadStatus()
} catch (e: any) {
ElMessage.error('删除失败: ' + (e.message || e))
}
}
const loadStatus = async () => {
try {
status.value = await api.status()
} catch {
// keep last known status on transient errors
}
}
const workerStateLabel = (s?: string) => {
switch (s) {
case 'running':
return '通畅'
case 'starting':
return '启动中'
case 'restarting':
return '重启中'
case 'crashed':
return '异常'
default:
return '未启动'
}
}
const workerHealthy = (s?: string) => s === 'running'
onMounted(() => {
loadStatus()
timer = window.setInterval(loadStatus, 5000)
})
onBeforeUnmount(() => {
if (timer !== null) {
window.clearInterval(timer)
timer = null
}
})
</script>
<style scoped lang="scss">
.add-btn {
margin-left: auto;
border: none;
border-radius: 6px;
padding: 5px 14px;
background: $color-btn-primary;
color: #fff;
cursor: pointer;
font-size: 13px;
&:hover {
background: $color-btn-primary-hover;
}
}
.form-row {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 12px;
label {
width: 72px;
font-size: 13px;
color: $color-text-secondary;
flex-shrink: 0;
}
}
.mini-btn {
border: 1px solid $color-border;
border-radius: 5px;
padding: 1px 8px;
background: #fff;
font-size: 12px;
cursor: pointer;
color: $color-text-secondary;
&:hover {
background: $color-bg-hover;
}
&.danger:hover {
color: $color-danger;
}
}
.ns-actions {
display: flex;
gap: 4px;
}
.status-page {
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
}
.status-top {
flex-shrink: 0;
display: flex;
align-items: center;
gap: 12px;
padding: 14px 20px;
border-bottom: 1px solid $color-border-light;
background: #fff;
}
.page-title {
margin: 0;
font-size: 18px;
font-weight: 600;
}
.refresh-hint {
font-size: 12px;
color: $color-text-muted;
}
.refresh-btn {
border: 1px solid $color-border;
border-radius: 6px;
padding: 3px 12px;
background: #fff;
cursor: pointer;
font-size: 13px;
&:hover {
background: $color-bg-hover;
}
}
.status-body {
flex: 1;
display: flex;
gap: 20px;
padding: 20px;
overflow: hidden;
}
.status-section {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
}
.status-title {
margin: 0 0 12px;
font-size: 15px;
font-weight: 600;
}
.status-cards,
.local-status-list {
flex: 1;
display: flex;
flex-direction: column;
gap: 10px;
overflow-y: auto;
padding-right: 6px;
}
.node-status-card {
border: 1px solid $color-border-light;
border-radius: 10px;
padding: 10px 14px;
background: #fafafa;
border-left: 4px solid $color-danger;
&.healthy {
border-left-color: $color-success;
}
}
.ns-head {
display: flex;
align-items: center;
justify-content: space-between;
}
.ns-name {
font-weight: 600;
margin-right: 8px;
}
.ns-badge {
font-size: 12px;
padding: 1px 8px;
border-radius: 8px;
background: #fef0f0;
color: $color-danger;
&.ok {
background: #f0f9eb;
color: $color-success;
}
}
.ns-meta {
font-size: 12px;
color: $color-text-muted;
margin-top: 2px;
}
.ns-err {
color: $color-danger;
}
.ns-forwards {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-top: 6px;
}
.ns-fwd {
font-size: 11px;
background: $color-bg-muted;
border-radius: 6px;
padding: 1px 6px;
color: $color-text-secondary;
}
.ns-empty,
.ls-empty {
color: $color-text-muted;
font-size: 13px;
padding: 8px;
}
.local-status-card {
border: 1px solid $color-border-light;
border-radius: 10px;
padding: 10px 14px;
background: #fafafa;
}
.ls-head {
display: flex;
align-items: center;
gap: 8px;
}
.ls-name {
font-weight: 600;
}
.ls-addr {
font-size: 12px;
color: $color-text-secondary;
}
.ls-proto {
font-size: 11px;
background: #ecf5ff;
color: #409eff;
border-radius: 6px;
padding: 1px 6px;
}
.ls-targets {
margin-top: 6px;
display: flex;
flex-direction: column;
gap: 4px;
}
.ls-target {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
}
.ls-arrow {
color: $color-text-muted;
}
.ls-remote {
font-weight: 500;
}
.ls-port {
color: $color-text-secondary;
}
.ls-state {
margin-left: auto;
font-size: 12px;
color: $color-danger;
&.ok {
color: $color-success;
}
}
.ls-none {
color: $color-text-light;
font-size: 12px;
}
</style>