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

12
web/index.html Normal file
View File

@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>webui-frpc</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

2887
web/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

27
web/package.json Normal file
View File

@ -0,0 +1,27 @@
{
"name": "webui4frpc-web",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc --noEmit && vite build",
"preview": "vite preview"
},
"dependencies": {
"@vue-flow/background": "^1.3.2",
"@vue-flow/core": "^1.48.2",
"element-plus": "^2.14.3",
"pinia": "^3.0.4",
"vue": "^3.5.40",
"vue-router": "^4.5.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^6.0.3",
"@vue/tsconfig": "^0.8.1",
"sass-embedded": "^1.102.0",
"typescript": "^5.9.3",
"vite": "^7.3.0",
"vue-tsc": "^3.3.8"
}
}

95
web/src/App.vue Normal file
View File

@ -0,0 +1,95 @@
<!-- 简单 SPA 外壳顶部切换 状态 / 连接配置 / 设置 -->
<template>
<div class="app-shell">
<header class="topbar">
<span class="brand">webui4frpc</span>
<nav class="nav">
<button class="nav-btn" :class="{ active: view === 'status' }" @click="view = 'status'">
状态
</button>
<button class="nav-btn" :class="{ active: view === 'canvas' }" @click="view = 'canvas'">
连接配置
</button>
<button class="nav-btn" :class="{ active: view === 'settings' }" @click="view = 'settings'">
设置
</button>
</nav>
</header>
<main class="content">
<CanvasView v-if="view === 'canvas'" />
<SettingsView v-else-if="view === 'settings'" />
<StatusView v-else />
</main>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import CanvasView from './views/CanvasView.vue'
import SettingsView from './views/SettingsView.vue'
import StatusView from './views/StatusView.vue'
const view = ref<'canvas' | 'settings' | 'status'>('status')
</script>
<style>
html,
body,
#app {
margin: 0;
height: 100%;
}
.app-shell {
height: 100%;
display: flex;
flex-direction: column;
font-family: ui-sans-serif, system-ui, -apple-system, 'Segoe UI', sans-serif;
background: #f7f8fa;
}
.topbar {
flex-shrink: 0;
display: flex;
align-items: center;
gap: 24px;
padding: 0 20px;
height: 52px;
background: #fff;
border-bottom: 1px solid #e4e7ed;
}
.brand {
font-weight: 600;
font-size: 16px;
}
.nav {
display: flex;
gap: 8px;
}
.nav-btn {
border: none;
background: transparent;
padding: 6px 16px;
border-radius: 8px;
cursor: pointer;
font-size: 14px;
color: #606266;
&:hover {
background: #f2f3f5;
}
&.active {
background: #303133;
color: #fff;
}
}
.content {
flex: 1;
min-height: 0;
}
</style>

84
web/src/api.ts Normal file
View File

@ -0,0 +1,84 @@
// HTTP client and API functions for webui4frpc.
import type {
BinaryStatus,
CanvasData,
InstallResult,
Remote,
Settings,
StatusResp,
} from './types'
class HTTPError extends Error {
status: number
constructor(status: number, message: string) {
super(message)
this.status = status
}
}
async function request<T>(url: string, options: RequestInit = {}): Promise<T> {
const response = await fetch(url, {
credentials: 'same-origin',
...options,
})
if (!response.ok) {
throw new HTTPError(response.status, `HTTP ${response.status}`)
}
const ct = response.headers.get('content-type') || ''
if (ct.includes('application/json')) {
return response.json() as Promise<T>
}
return response.text() as unknown as Promise<T>
}
const json = (body: unknown): RequestInit => ({
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
export const api = {
status: () => request<StatusResp>('/api/manager/status'),
canvas: () => request<CanvasData>('/api/manager/canvas'),
saveCanvas: (data: CanvasData) =>
request<CanvasData>('/api/manager/canvas', json(data)),
settings: () => request<Settings>('/api/manager/settings'),
saveSettings: (s: Settings) =>
request<Settings>('/api/manager/settings', json(s)),
binaryStatus: () => request<BinaryStatus>('/api/manager/binary/status'),
installBinary: (version?: string) =>
request<InstallResult>('/api/manager/binary/install', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: version ? JSON.stringify({ version }) : undefined,
}),
saveRemote: (remote: Remote) =>
request<Remote>('/api/manager/remotes', json(remote)),
deleteRemote: (name: string) =>
request<void>(`/api/manager/remotes/${encodeURIComponent(name)}`, {
method: 'DELETE',
}),
profileStart: (name: string) =>
request<void>(`/api/manager/profiles/${encodeURIComponent(name)}/start`, {
method: 'POST',
}),
profileStop: (name: string) =>
request<void>(`/api/manager/profiles/${encodeURIComponent(name)}/stop`, {
method: 'POST',
}),
profileRestart: (name: string) =>
request<void>(
`/api/manager/profiles/${encodeURIComponent(name)}/restart`,
{ method: 'POST' },
),
profileConfig: (name: string) =>
request<string>(`/api/manager/profiles/${encodeURIComponent(name)}/config`),
profileLogs: (name: string) =>
request<string>(`/api/manager/profiles/${encodeURIComponent(name)}/logs`),
}

View File

@ -0,0 +1,166 @@
<template>
<div class="local-node" :class="{ selected, conflicted }">
<!-- source handle (right) connects to remote -->
<Handle type="source" :position="Position.Right" />
<div class="node-head">
<span class="node-icon"></span>
<input v-model="nameField" class="name-input" />
<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
>协议
<select v-model="protocolField">
<option value="tcp">tcp</option>
<option value="udp">udp</option>
<option value="http">http</option>
<option value="https">https</option>
</select>
</label>
</div>
</div>
</template>
<script setup lang="ts">
import { Handle, Position } from '@vue-flow/core'
import { computed } from 'vue'
import type { CanvasLocal } from '../types'
const props = defineProps<{
data: CanvasLocal
selected?: boolean
conflicted?: boolean
}>()
const emit = defineEmits<{
(e: 'update:data', data: CanvasLocal): void
(e: 'remove', name: string): void
}>()
const field = <K extends keyof CanvasLocal>(key: K) =>
computed({
get: () => props.data[key],
set: (val: CanvasLocal[K]) => {
emit('update:data', { ...props.data, [key]: val })
},
})
const nameField = field('name')
const ipField = field('ip')
const portField = field('port')
const protocolField = field('protocol')
</script>
<style scoped lang="scss">
.local-node {
min-width: 190px;
border-radius: 14px 14px 14px 4px;
background: #e8f5e9;
border: 2px solid #4caf50;
box-shadow: 0 2px 8px rgba(76, 175, 80, 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: #4caf50;
font-size: 16px;
}
}
.name-input {
flex: 1;
min-width: 0;
border: none;
background: transparent;
font-weight: 600;
font-size: 14px;
color: #2e7d32;
&:focus {
outline: 1px solid #4caf50;
}
}
.del-btn {
border: none;
background: transparent;
color: #81c784;
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: #558b2f;
input,
select {
flex: 1;
min-width: 0;
border: 1px solid #a5d6a7;
border-radius: 6px;
padding: 2px 6px;
font-size: 12px;
background: #fff;
color: #333;
&:focus {
outline: none;
border-color: #4caf50;
}
}
input[type='number'] {
width: 64px;
flex: 0 0 64px;
}
}
}
</style>

View File

@ -0,0 +1,330 @@
<template>
<g class="port-edge" :class="{ selected }">
<path
:d="path"
class="port-edge-path"
:class="{ selected, conflicted }"
fill="none"
:stroke="strokeColor"
:stroke-width="selected ? 3.5 : 2.5"
/>
<!-- Draggable port label. Dragging it moves the curve (and its midpoint)
so users can place the port anywhere along/around the connection. -->
<EdgeLabelRenderer>
<div
ref="labelEl"
class="port-label"
:class="{ dragging }"
:style="labelStyle"
@pointerdown.stop.prevent="onPointerDown"
@pointermove="onPointerMove"
@pointerup="onPointerUp"
@pointercancel="onPointerUp"
>
{{ displayPort }}
</div>
</EdgeLabelRenderer>
</g>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { EdgeLabelRenderer } from '@vue-flow/core'
import type { EdgeProps } from '@vue-flow/core'
const props = withDefaults(
defineProps<EdgeProps & { conflicted?: boolean }>(),
{
selected: false,
conflicted: false,
},
)
const emit = defineEmits<{
(e: 'label-click', payload: { edgeId: string }): void
(
e: 'label-drag',
payload: { edgeId: string; offset: { x: number; y: number } },
): void
}>()
// User-dragged offset relative to the curve midpoint, initialized from the
// edge data so it survives saves.
const dragOffset = ref<{ x: number; y: number }>({
x: Number(props.data?.offsetX ?? 0),
y: Number(props.data?.offsetY ?? 0),
})
// Press-to-drag using Pointer Events + pointer capture. Once the pointer is
// captured on the label element, all subsequent pointer events (even outside
// the element) are delivered to it, and pointerup reliably ends the drag.
const labelEl = ref<HTMLElement | null>(null)
const clickSlop = 2 // px of total travel that still counts as a click
let moved = false
let startX = 0
let startY = 0
let startOff = { x: 0, y: 0 }
let dragging = false
const onPointerDown = (e: PointerEvent) => {
// Never let this press reach the Vue Flow pane (which would pan the canvas).
e.stopPropagation()
dragging = true
moved = false
startX = e.clientX
startY = e.clientY
startOff = { ...dragOffset.value }
if (labelEl.value) {
try {
labelEl.value.setPointerCapture(e.pointerId)
} catch {
// Capture can fail in rare cases; the pointermove/up listeners below
// still work while the pointer stays over the label.
}
}
e.preventDefault()
}
const onPointerMove = (e: PointerEvent) => {
if (!dragging) return
const dx = e.clientX - startX
const dy = e.clientY - startY
if (Math.abs(dx) > clickSlop || Math.abs(dy) > clickSlop) {
moved = true
}
// The label lives in canvas (world) coordinates; pointer deltas are in
// screen pixels, so divide by the current viewport zoom.
const z = readZoom()
dragOffset.value = {
x: startOff.x + dx / z,
y: startOff.y + dy / z,
}
emit('label-drag', {
edgeId: props.id,
offset: { ...dragOffset.value },
})
}
const onPointerUp = (e: PointerEvent) => {
e.stopPropagation()
const wasDrag = dragging
dragging = false
if (labelEl.value && e.pointerId != null) {
try {
labelEl.value.releasePointerCapture?.(e.pointerId)
} catch {
// releasePointerCapture may throw if capture was never established.
}
}
// Press without real movement = tap: open the port editor.
if (!wasDrag) return
if (!moved) {
emit('label-click', { edgeId: props.id })
}
}
// readZoom parses the current zoom level from the Vue Flow viewport's
// transform (e.g. "translate(10px, 20px) scale(0.85)"). Falls back to 1.
function readZoom(): number {
const vp = document.querySelector('.vue-flow__viewport') as HTMLElement | null
if (!vp) return 1
const m = /scale\(([0-9.]+)\)/.exec(vp.style.transform || '')
if (!m) return 1
const z = Number(m[1])
return z > 0 ? z : 1
}
// {{ layer }} is a per-pair index assigned by the canvas editor. It bends this
// edge away from its siblings so multiple links between the same two nodes do
// not overlap. Rendering order follows the layer too, so later links are drawn
// above earlier ones.
const layer = computed(() => Number(props.data?.layer ?? 0))
const displayPort = computed(() => {
const t = String(props.label ?? '')
return t || '?'
})
// Direction of the base bend alternates so pairs of links spread symmetrically
// above and below the straight line.
const baseBend = computed(() => {
const l = layer.value
const dir = l % 2 === 0 ? 1 : -1
const mag = (Math.floor(l / 2) + 1) * 16
return dir * mag
})
// totalBend adds the user's vertical drag onto the base layer bend so the
// curve follows the dragged label.
const totalBend = computed(() => baseBend.value + dragOffset.value.y)
const path = computed(() => {
const { sourceX, sourceY, sourcePosition, targetX, targetY, targetPosition } =
props
return buildBentPath(
sourceX,
sourceY,
sourcePosition,
targetX,
targetY,
targetPosition,
totalBend.value,
)
})
// The label sits at the curve midpoint, shifted horizontally by the user's
// drag offset (vertical drag is already absorbed into totalBend).
const mid = computed(() => midpointOf(path.value))
const labelStyle = computed(() => ({
position: 'absolute' as const,
transform: `translate(${mid.value.x + dragOffset.value.x}px, ${mid.value.y}px) translate(-50%, -50%)`,
pointerEvents: 'all' as const,
cursor: 'grab',
zIndex: 10,
}))
const strokeColor = computed(() => {
if (props.conflicted) {
return '#f5222d'
}
const l = layer.value
// Muted, UI-friendly palette that matches the existing theme (Element Plus
// primary / success / warning / danger / info), cycled per layer.
const hues = [
'#409eff',
'#67c23a',
'#e6a23c',
'#f56c6c',
'#909399',
'#409eff',
]
return hues[l % hues.length]
})
// buildBentPath draws a cubic bezier from the source handle to the target
// handle, bending the control points vertically by (bend) pixels so parallel
// links between the same nodes separate visually.
function buildBentPath(
sourceX: number,
sourceY: number,
sourcePosition: unknown,
targetX: number,
targetY: number,
targetPosition: unknown,
bend: number,
): string {
const horizontal = Math.abs(targetX - sourceX)
const handleLen = Math.max(horizontal * 0.45, 40)
let sx = sourceX
let sy = sourceY
let tx = targetX
let ty = targetY
if (sourcePosition === 'Right') {
sx = sourceX
} else if (sourcePosition === 'Left') {
sx = sourceX - handleLen
} else if (sourcePosition === 'Top') {
sx = sourceX
sy = sourceY
} else {
sx = sourceX
}
if (targetPosition === 'Left') {
tx = targetX
} else if (targetPosition === 'Right') {
tx = targetX + handleLen
} else if (targetPosition === 'Top') {
ty = targetY
}
const c1x = sx + (tx - sx) * 0.5
const c1y = sy + bend
const c2x = tx - (tx - sx) * 0.5
const c2y = ty + bend
return `M ${sx} ${sy} C ${c1x} ${c1y}, ${c2x} ${c2y}, ${tx} ${ty}`
}
// midpointOf approximates the curve center using the quadratic mean of the two
// evaluated bezier points at t=0.5 (single evaluation is enough for labels).
function midpointOf(path: string): { x: number; y: number } {
// Parse the cubic bezier control points out of the path string.
const nums = path
.replace('M', '')
.replace('C', '')
.split(/[ ,]+/)
.filter((s) => s.length > 0)
.map(Number)
if (nums.length < 8) {
return { x: 0, y: 0 }
}
const x0 = nums[0] ?? 0
const y0 = nums[1] ?? 0
const cx1 = nums[2] ?? 0
const cy1 = nums[3] ?? 0
const cx2 = nums[4] ?? 0
const cy2 = nums[5] ?? 0
const x1 = nums[6] ?? 0
const y1 = nums[7] ?? 0
const t = 0.5
const mt = 1 - t
const x =
mt * mt * mt * x0 +
3 * mt * mt * t * cx1 +
3 * mt * t * t * cx2 +
t * t * t * x1
const y =
mt * mt * mt * y0 +
3 * mt * mt * t * cy1 +
3 * mt * t * t * cy2 +
t * t * t * y1
return { x, y }
}
</script>
<style scoped lang="scss">
.port-edge-path {
&.selected {
filter: drop-shadow(0 0 4px rgba(244, 67, 54, 0.6));
}
&.conflicted {
filter: drop-shadow(0 0 6px rgba(245, 34, 45, 0.8));
animation: conflict-pulse 1.2s ease-in-out infinite;
}
}
@keyframes conflict-pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.55;
}
}
.port-label {
background: rgba(255, 255, 255, 0.92);
color: $color-text-primary;
border: 1px solid $color-border-light;
border-radius: 10px;
padding: 2px 10px;
font-size: 12px;
font-weight: 600;
font-family: inherit;
white-space: nowrap;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.12);
user-select: none;
transition:
box-shadow $transition-fast,
transform $transition-fast;
&.dragging {
cursor: grabbing;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.22);
}
}
</style>

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>

7
web/src/env.d.ts vendored Normal file
View File

@ -0,0 +1,7 @@
/// <reference types="vite/client" />
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<object, object, unknown>
export default component
}

10
web/src/main.ts Normal file
View File

@ -0,0 +1,10 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import App from './App.vue'
const app = createApp(App)
app.use(createPinia())
app.use(ElementPlus)
app.mount('#app')

View File

@ -0,0 +1,40 @@
// Global SCSS variables for webui4frpc (light theme).
$color-text-primary: #303133;
$color-text-secondary: #606266;
$color-text-muted: #909399;
$color-text-light: #c0c4cc;
$color-bg-primary: #ffffff;
$color-bg-secondary: #f9f9f9;
$color-bg-tertiary: #fafafa;
$color-bg-muted: #f4f4f5;
$color-bg-hover: #efefef;
$color-bg-active: #eaeaea;
$color-border: #dcdfe6;
$color-border-light: #e4e7ed;
$color-border-lighter: #ebeef5;
$color-border-extra-light: #f2f6fc;
$color-primary: #409eff;
$color-success: #67c23a;
$color-warning: #e6a23c;
$color-danger: #f56c6c;
$color-info: #909399;
$color-btn-primary: #303133;
$color-btn-primary-hover: #4a4d5c;
$font-size-xs: 12px;
$font-size-sm: 13px;
$font-size-md: 14px;
$font-size-lg: 16px;
$font-size-xl: 18px;
$font-weight-medium: 500;
$font-weight-semibold: 600;
$spacing-xs: 4px;
$spacing-sm: 8px;
$spacing-md: 12px;
$spacing-lg: 24px;
$spacing-xl: 32px;
$radius-sm: 6px;
$radius-md: 10px;
$radius-lg: 14px;
$transition-fast: 0.2s ease;

96
web/src/types.ts Normal file
View File

@ -0,0 +1,96 @@
// Data models shared with the Go backend.
// CanvasLocal / CanvasRemote are aliases kept for node components migrated
// from the original prototype; they equal Local / Remote.
export type CanvasLocal = Local
export type CanvasRemote = Remote
// CanvasLink mirrors the store.Link JSON shape used by the canvas editor.
export type CanvasLink = Link
export interface Local {
name: string
ip: string
port: number
protocol: string // tcp | udp | http | https
}
export interface Remote {
name: string
ip: string
port: number // frps connect port
token?: string
url?: string
enabled: boolean
}
export interface Link {
id?: number
local: string
remote: string
remotePort: number
offsetX?: number
offsetY?: number
}
export interface CanvasData {
locals: Local[]
remotes: Remote[]
links: Link[]
}
export interface Settings {
autoStartProfiles: boolean
restartOnExit: boolean
restartIntervalSeconds: number
binaryPath?: string
}
export interface ProcessStatus {
state: string
pid?: number
startTime?: number
restartCount: number
exitCode?: number
err?: string
}
export interface StatusProfile {
name: string
enabled: boolean
process: ProcessStatus
hasProcess: boolean
forwards: { service: string; remotePort: number; localPort?: number }[]
}
export interface StatusResp {
version: string
workDir: string
settings: Settings
services: Local[]
remotes: Remote[]
binaryPath: string
profiles: StatusProfile[]
localStatus: LocalStatus[]
}
export interface LocalTargetStatus {
remote: string
remotePort: number
workerState: string
}
export interface LocalStatus {
local: Local
targets: LocalTargetStatus[]
}
export interface BinaryStatus {
binaryPath: string
version?: string
}
export interface InstallResult {
path: string
version: string
}

View File

@ -0,0 +1,785 @@
<template>
<div class="canvas-editor">
<!-- Toolbar -->
<div class="toolbar">
<div class="toolbar-left">
<span class="toolbar-title">连接配置</span>
<button class="btn local-add" @click="addLocal"> 本地转发项</button>
<button class="btn remote-add" @click="addRemote"> 远程节点</button>
<button class="btn warn" @click="autoLayout">自动排列</button>
</div>
<div class="toolbar-right">
<span class="save-hint">{{ dirty ? '● 未保存' : '已保存' }}</span>
<button class="btn save" :disabled="saving" @click="save">
保存配置
</button>
</div>
</div>
<div class="flow-wrap">
<VueFlow
v-model:nodes="nodes"
v-model:edges="edges"
:default-viewport="{ zoom: 0.85 }"
:min-zoom="0.3"
:max-zoom="2"
fit-view-on-init
:delete-key-code="null"
:edges-updatable="false"
:edges-reconnectable="false"
class="flow-canvas"
@connect="onConnect"
@pane-click="deselectAll"
@edge-click="onEdgeClick"
>
<Background pattern-color="#cfd8dc" :gap="20" />
<template #node-local="slotProps">
<LocalNode
:data="(slotProps as any).data"
:selected="Boolean((slotProps as any).selected)"
:conflicted="isLocalConflicted((slotProps as any).data?.name)"
@remove="removeLocal"
@update:data="onLocalData"
/>
</template>
<template #node-remote="slotProps">
<RemoteNode
:data="(slotProps as any).data"
:selected="Boolean((slotProps as any).selected)"
:conflicted="isRemoteConflicted((slotProps as any).data?.name)"
@remove="removeRemote"
@update:data="onRemoteData"
/>
</template>
<template #edge-portedge="edgeProps">
<PortEdge
v-bind="edgeProps as any"
:conflicted="isEdgeConflicted((edgeProps as any).id)"
@label-click="onEdgeLabelClick"
@label-drag="onLabelDrag"
/>
</template>
</VueFlow>
</div>
<!-- Connect dialog (port editor) -->
<el-dialog
v-model="portDlg.visible"
:title="portDlg.isNew ? '新建连线' : '编辑远程端口'"
width="400px"
>
<div class="dlg-row">
<label>连接</label>
<span class="dlg-value"
>{{ portDlg.local }} {{ portDlg.remote }}</span
>
</div>
<div class="dlg-row">
<label>远程端口</label>
<el-input
v-model="portInput"
type="number"
min="1"
max="65535"
placeholder="默认 = 本地端口"
class="port-input"
@keyup.enter="confirmPort"
/>
</div>
<template #footer>
<button class="btn" @click="portDlg.visible = false">取消</button>
<button class="btn save" @click="confirmPort">确定</button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import '@vue-flow/core/dist/style.css'
import '@vue-flow/core/dist/theme-default.css'
import { ref, computed, onMounted } from 'vue'
import { VueFlow } from '@vue-flow/core'
import { Background } from '@vue-flow/background'
import { ElMessage, ElMessageBox } from 'element-plus'
import type { Edge, Connection } from '@vue-flow/core'
import type { CanvasLocal, CanvasRemote, CanvasLink } from '../types'
import LocalNode from '../components/LocalNode.vue'
import RemoteNode from '../components/RemoteNode.vue'
import PortEdge from '../components/PortEdge.vue'
import { api } from '../api'
const nodes = ref<any[]>([])
const edges = ref<Edge[]>([])
const loading = ref(false)
// ---- canvas conflict validation ----
interface CanvasConflict {
type: 'port' | 'domain'
remote: string
remotePort?: number
domain?: string
locals: string[] // the conflicting local service names
edgeIds: string[]
}
const conflicts = ref<CanvasConflict[]>([])
const conflictedEdgeIds = computed(() => {
const s = new Set<string>()
for (const c of conflicts.value) {
for (const id of c.edgeIds) s.add(id)
}
return s
})
const saving = ref(false)
const dirty = ref(false)
const portDlg = ref({
visible: false,
isNew: false,
local: '',
remote: '',
remotePort: 0,
pendingEdge: null as Edge | null,
})
// portInput is the plain text control for the remote port in the dialog.
const portInput = ref('')
// openPortDlg opens the port editor dialog, seeding the input.
const openPortDlg = (
local: string,
remote: string,
port: number,
isNew: boolean,
pending?: Edge | null,
) => {
portDlg.value = {
visible: true,
isNew,
local,
remote,
remotePort: port,
pendingEdge: pending ?? null,
}
portInput.value = String(port)
}
const localOf = (name: string) =>
nodes.value.find((n) => n.type === 'local' && n.data?.name === name)?.data as
CanvasLocal | undefined
// validateCanvas checks every remote node for conflicts among the links that
// arrive at it: duplicate remote ports (tcp/udp) or duplicate domains
// (http/https). Returns the list of conflicts and stores it in conflicts.
const validateCanvas = (): CanvasConflict[] => {
const found: CanvasConflict[] = []
const byRemote = new Map<string, { edgeId: string; local: string; remotePort: number; type: string }[]>()
for (const e of edges.value) {
const localName = e.source.startsWith('local::') ? e.source.slice(7) : ''
const remoteName = e.target.startsWith('remote::') ? e.target.slice(8) : ''
if (!localName || !remoteName) continue
const loc = localOf(localName)
const rp = Number(e.label)
if (!loc || isNaN(rp) || rp <= 0) continue
if (!byRemote.has(remoteName)) byRemote.set(remoteName, [])
byRemote.get(remoteName)!.push({
edgeId: e.id,
local: localName,
remotePort: rp,
type: loc.protocol,
})
}
for (const [remote, items] of byRemote) {
// 1) duplicate remote ports among tcp/udp links
const byPort = new Map<number, { local: string; edgeId: string }[]>()
for (const it of items) {
if (it.type !== 'tcp' && it.type !== 'udp') continue
if (!byPort.has(it.remotePort)) byPort.set(it.remotePort, [])
byPort.get(it.remotePort)!.push({ local: it.local, edgeId: it.edgeId })
}
for (const [port, arr] of byPort) {
if (arr.length < 2) continue
found.push({
type: 'port',
remote,
remotePort: port,
locals: arr.map((a) => a.local),
edgeIds: arr.map((a) => a.edgeId),
})
}
// 2) duplicate domains among http/https links (same local name currently
// maps to <name>.local).
const byDomain = new Map<string, { local: string; edgeId: string }[]>()
for (const it of items) {
if (it.type !== 'http' && it.type !== 'https') continue
const domain = it.local + '.local'
if (!byDomain.has(domain)) byDomain.set(domain, [])
byDomain.get(domain)!.push({ local: it.local, edgeId: it.edgeId })
}
for (const [domain, arr] of byDomain) {
if (arr.length < 2) continue
found.push({
type: 'domain',
remote,
domain,
locals: arr.map((a) => a.local),
edgeIds: arr.map((a) => a.edgeId),
})
}
}
conflicts.value = found
return found
}
const isRemoteConflicted = (name: string): boolean =>
conflicts.value.some((c) => c.remote === name)
const isLocalConflicted = (name: string): boolean =>
conflicts.value.some((c) => c.locals.includes(name))
const isEdgeConflicted = (id: string): boolean =>
conflictedEdgeIds.value.has(id)
const nodeId = (kind: 'local' | 'remote', name: string) => `${kind}::${name}`
const addLocal = () => {
const name = `local-${nodes.value.filter((n) => n.type === 'local').length + 1}`
const data: CanvasLocal = {
name,
ip: '127.0.0.1',
port: 8080,
protocol: 'tcp',
}
nodes.value.push({
id: nodeId('local', name),
type: 'local',
position: {
x: 60,
y: 90 + nodes.value.filter((n) => n.type === 'local').length * 140,
},
data,
})
dirty.value = true
}
const addRemote = () => {
const name = `remote-${nodes.value.filter((n) => n.type === 'remote').length + 1}`
const data: CanvasRemote = {
name,
ip: '127.0.0.1',
port: 7000,
token: '',
url: '',
enabled: true,
}
nodes.value.push({
id: nodeId('remote', name),
type: 'remote',
position: {
x: 760,
y: 90 + nodes.value.filter((n) => n.type === 'remote').length * 160,
},
data,
})
dirty.value = true
}
const removeLocal = (name: string) => {
nodes.value = nodes.value.filter(
(n) => !(n.type === 'local' && n.data?.name === name),
)
edges.value = edges.value.filter((e) => e.source !== nodeId('local', name))
dirty.value = true
validateCanvas()
}
const removeRemote = (name: string) => {
nodes.value = nodes.value.filter(
(n) => !(n.type === 'remote' && n.data?.name === name),
)
edges.value = edges.value.filter((e) => e.target !== nodeId('remote', name))
dirty.value = true
validateCanvas()
}
// edgeId builds a unique id for a link. The remote port is part of the id so
// that several links between the same local and remote nodes (different ports)
// are kept separate instead of being merged into one edge.
const edgeId = (source: string, target: string, remotePort: number) =>
`${source}-->${target}@${remotePort}`
const onConnect = (conn: Connection) => {
const localName = conn.source.startsWith('local::')
? conn.source.slice(7)
: ''
const remoteName = conn.target.startsWith('remote::')
? conn.target.slice(8)
: ''
if (!localName || !remoteName) return
const loc = localOf(localName)
if (!loc) return
openPortDlg(localName, remoteName, loc.port, true, {
id: edgeId(conn.source, conn.target, loc.port),
source: conn.source,
target: conn.target,
} as Edge)
}
const confirmPort = () => {
const d = portDlg.value
const parsed = Number(portInput.value)
if (!Number.isInteger(parsed) || parsed < 1 || parsed > 65535) {
ElMessage.warning('请输入 1-65535 的整数端口')
return
}
d.remotePort = parsed
if (d.isNew && d.pendingEdge) {
const newId = edgeId(
d.pendingEdge.source,
d.pendingEdge.target,
d.remotePort,
)
const existing = edges.value.find((e) => e.id === newId)
if (existing) {
existing.label = String(d.remotePort)
} else {
edges.value.push({
id: newId,
source: d.pendingEdge.source,
target: d.pendingEdge.target,
label: String(d.remotePort),
type: 'portedge',
animated: false,
data: {},
})
assignLayers()
}
} else if (d.pendingEdge) {
// Editing an existing link's remote port: update its label and id so the
// edge stays unique when the port changes.
const old = d.pendingEdge
const newId = edgeId(old.source, old.target, d.remotePort)
const conflict = edges.value.find((e) => e.id === newId && e !== old)
if (conflict) {
ElMessage.warning('该端口已被同一条连线占用')
return
}
if (old.id !== newId) {
old.id = newId
}
old.label = String(d.remotePort)
}
portDlg.value.visible = false
dirty.value = true
}
const editEdge = (edge: Edge) => {
const localName = edge.source.startsWith('local::')
? edge.source.slice(7)
: ''
const remoteName = edge.target.startsWith('remote::')
? edge.target.slice(8)
: ''
const loc = localOf(localName)
openPortDlg(
localName,
remoteName,
Number(edge.label) || (loc ? loc.port : 0),
false,
edge,
)
}
// 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)
if (e) editEdge(e)
}
// onLabelDrag stores the user's port label offset on the edge so the custom
// edge re-renders the curve and label at the dragged position, and saves it.
const onLabelDrag = ({
edgeId,
offset,
}: {
edgeId: string
offset: { x: number; y: number }
}) => {
const e = edges.value.find((x) => x.id === edgeId)
if (e) {
e.data = { ...(e.data as object), offsetX: offset.x, offsetY: offset.y }
dirty.value = true
}
}
// assignLayers re-computes the layer (per-pair index) of every edge so custom
// port edges bend away from their siblings. Returns the same array mutated in
// place; also used to keep ordering consistent.
const assignLayers = () => {
const seen = new Map<string, number>()
for (const e of edges.value) {
const key = `${e.source}||${e.target}`
const n = seen.get(key) ?? 0
seen.set(key, n + 1)
e.data = { ...(e.data as object), layer: n }
}
// Re-run availability checks after the canvas topology changes so conflicts
// are flagged (red) immediately.
validateCanvas()
}
const onLocalData = (data: CanvasLocal) => {
const n = nodes.value.find(
(x) => x.type === 'local' && x.data?.name === data.name,
)
if (n) n.data = { ...data }
dirty.value = true
}
const onRemoteData = (data: CanvasRemote) => {
const n = nodes.value.find(
(x) => x.type === 'remote' && x.data?.name === data.name,
)
if (n) n.data = { ...data }
dirty.value = true
}
const onEdgeClick = ({ edge }: { edge: Edge }) => {
editEdge(edge)
}
// checkAndWarn validates the canvas availability (port/domain conflicts)
// and shows a dialog listing the offending local -> remote forwards.
const checkAndWarn = () => {
const found = validateCanvas()
if (found.length === 0) {
return true
}
const lines = found.map((c) => {
const loc = c.locals.join('、')
if (c.type === 'port') {
return `· 本地服务「${loc}」→ 远程节点「${c.remote}」:远程端口 ${c.remotePort} 重复`
}
return `· 本地服务「${loc}」→ 远程节点「${c.remote}」:域名 ${c.domain} 重复(一个域名只能绑定一个 http/https 服务)`
})
void ElMessageBox.alert(
`<div class="conflict-box">${lines.join('<br/>')}</div>`,
'发现连接冲突,请检查标红的节点与连线',
{
confirmButtonText: '知道了',
dangerouslyUseHTMLString: true,
type: 'warning',
},
)
return false
}
const deselectAll = () => {
validateCanvas() // 点击画布:可用性检查(标红)
nodes.value.forEach((n) => {
n.selected = false
})
}
const localNodes = computed(() => nodes.value.filter((n) => n.type === 'local'))
const remoteNodes = computed(() =>
nodes.value.filter((n) => n.type === 'remote'),
)
const autoLayout = () => {
let li = 0
let ri = 0
for (const n of nodes.value) {
if (n.type === 'local') {
n.position = { x: 60, y: 90 + li * 140 }
li++
} else {
n.position = { x: 760, y: 90 + ri * 160 }
ri++
}
}
}
const load = async () => {
loading.value = true
try {
const data = await api.canvas()
nodes.value = []
edges.value = []
let li = 0
let ri = 0
for (const l of data.locals || []) {
nodes.value.push({
id: nodeId('local', l.name),
type: 'local',
position: { x: 60, y: 90 + li * 140 },
data: { ...l },
})
li++
}
for (const r of data.remotes || []) {
nodes.value.push({
id: nodeId('remote', r.name),
type: 'remote',
position: { x: 760, y: 90 + ri * 160 },
data: { ...r },
})
ri++
}
for (const link of data.links || []) {
const s = nodeId('local', link.local)
const t = nodeId('remote', link.remote)
if (
!nodes.value.some((n) => n.id === s) ||
!nodes.value.some((n) => n.id === t)
)
continue
const rp = link.remotePort || localOf(link.local)?.port || 0
edges.value.push({
id: edgeId(s, t, rp),
source: s,
target: t,
label: String(rp),
type: 'portedge',
animated: false,
data: { offsetX: link.offsetX ?? 0, offsetY: link.offsetY ?? 0 },
})
}
assignLayers()
dirty.value = false
} finally {
loading.value = false
}
}
const buildPayload = () => {
const locals: CanvasLocal[] = localNodes.value.map((n) => ({
...(n.data as CanvasLocal),
}))
const remotes: CanvasRemote[] = remoteNodes.value.map((n) => ({
...(n.data as CanvasRemote),
}))
const links: CanvasLink[] = []
for (const e of edges.value) {
const localName = e.source.startsWith('local::') ? e.source.slice(7) : ''
const remoteName = e.target.startsWith('remote::') ? e.target.slice(8) : ''
const rp = Number(e.label)
links.push({
local: localName,
remote: remoteName,
remotePort: isNaN(rp) || rp <= 0 ? 0 : rp,
offsetX: (e.data as any)?.offsetX ?? 0,
offsetY: (e.data as any)?.offsetY ?? 0,
})
}
return { locals, remotes, links }
}
const save = async () => {
// 保存前可用性检查:有冲突则提示并阻止保存,保证配置可直接生效
if (!checkAndWarn()) {
return
}
saving.value = true
try {
const data = await api.saveCanvas(buildPayload())
const posOf = new Map(nodes.value.map((n) => [n.id, n.position]))
nodes.value = []
let li = 0
let ri = 0
for (const l of data.locals || []) {
const id = nodeId('local', l.name)
nodes.value.push({
id,
type: 'local',
position: posOf.get(id) || { x: 60, y: 90 + li * 140 },
data: { ...l },
})
li++
}
for (const r of data.remotes || []) {
const id = nodeId('remote', r.name)
nodes.value.push({
id,
type: 'remote',
position: posOf.get(id) || { x: 760, y: 90 + ri * 160 },
data: { ...r },
})
ri++
}
edges.value = []
for (const link of data.links || []) {
const s = nodeId('local', link.local)
const t = nodeId('remote', link.remote)
const rp = link.remotePort || localOf(link.local)?.port || 0
edges.value.push({
id: edgeId(s, t, rp),
source: s,
target: t,
label: String(rp),
type: 'portedge',
data: { offsetX: link.offsetX ?? 0, offsetY: link.offsetY ?? 0 },
})
}
assignLayers()
dirty.value = false
ElMessage.success('配置已保存')
} catch (e: any) {
ElMessage.error('保存失败: ' + (e.message || e))
} finally {
saving.value = false
}
}
onMounted(load)
</script>
<style scoped lang="scss">
.canvas-editor {
height: 100%;
display: flex;
flex-direction: column;
background: $color-bg-secondary;
}
.toolbar {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 16px;
background: $color-bg-primary;
border-bottom: 1px solid $color-border-light;
z-index: 20;
}
.toolbar-left,
.toolbar-right {
display: flex;
align-items: center;
gap: 8px;
}
.toolbar-title {
font-weight: $font-weight-semibold;
font-size: $font-size-lg;
margin-right: 8px;
}
.btn {
border: none;
border-radius: 8px;
padding: 6px 12px;
font-size: $font-size-sm;
font-weight: $font-weight-medium;
cursor: pointer;
transition: all $transition-fast;
background: $color-bg-muted;
color: $color-text-primary;
&:hover {
background: $color-bg-hover;
}
&:disabled {
opacity: 0.5;
cursor: default;
}
}
.btn.local-add {
background: #4caf50;
color: #fff;
&:hover {
background: #43a047;
}
}
.btn.remote-add {
background: #ff9800;
color: #fff;
&:hover {
background: #f57c00;
}
}
.btn.warn {
background: #78909c;
color: #fff;
&:hover {
background: #607d8b;
}
}
.btn.save {
background: $color-btn-primary;
color: #fff;
&:hover {
background: $color-btn-primary-hover;
}
}
.save-hint {
font-size: $font-size-sm;
color: $color-text-muted;
}
.flow-wrap {
flex: 1;
position: relative;
min-height: 0;
}
.flow-canvas {
width: 100%;
height: 100%;
background: #f5f7fa;
}
.dlg-row {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 12px;
label {
min-width: 70px;
font-size: $font-size-md;
color: $color-text-secondary;
}
}
.dlg-value {
font-weight: $font-weight-medium;
}
.dlg-hint {
font-size: $font-size-sm;
color: $color-text-muted;
}
:deep(.vue-flow__edge-label) {
background: rgba(0, 0, 0, 0.72);
color: #fff;
border-radius: 10px;
padding: 2px 8px;
font-size: 12px;
font-weight: 600;
cursor: pointer;
}
:deep(.vue-flow__handle) {
width: 12px;
height: 12px;
border: 2px solid #fff;
}
:deep(.vue-flow__node) {
cursor: grab;
&.dragging {
cursor: grabbing;
}
}
</style>

View File

@ -0,0 +1,319 @@
<template>
<div class="settings-page">
<div class="page-top">
<div class="page-header">
<h2 class="page-title">运行时设置</h2>
<p class="page-subtitle">worker 二进制与运行策略</p>
</div>
</div>
<div v-loading="loading" class="page-content">
<!-- frpc 二进制 -->
<section class="panel">
<h3 class="panel-title">frpc 可执行文件</h3>
<p class="panel-desc">
用于拉起各 remote profile frpc worker
进程留空则使用管理器自身进程
</p>
<div class="binary-row">
<el-input
v-model="binaryPathInput"
placeholder="如 /usr/local/bin/frpc留空用当前进程"
clearable
size="large"
class="binary-input"
@blur="applyBinaryPath"
@keyup.enter="applyBinaryPath"
/>
<button
class="btn install-btn"
:disabled="installing"
@click="installBinary"
>
<el-icon v-if="installing" class="spin"><Loading /></el-icon>
{{ installing ? '安装中…' : '一键安装 frpc' }}
</button>
</div>
<div v-if="binaryStatus" class="binary-info">
<span class="info-label">当前路径</span>
<code class="info-value">{{ binaryStatus.binaryPath }}</code>
<span v-if="binaryStatus.version" class="info-version">
{{ binaryStatus.version }}
</span>
</div>
</section>
<!-- 运行策略 -->
<section class="panel">
<h3 class="panel-title">运行策略</h3>
<div class="setting-row">
<div class="setting-text">
<span class="setting-label">自动启动 profile</span>
<span class="setting-hint"
>管理器启动时自动拉起所有已启用的 remote</span
>
</div>
<el-switch
v-model="settings.autoStartProfiles"
@change="saveSettings"
/>
</div>
<div class="setting-row">
<div class="setting-text">
<span class="setting-label">异常退出自动重启</span>
<span class="setting-hint">worker 崩溃后带退避重启</span>
</div>
<el-switch v-model="settings.restartOnExit" @change="saveSettings" />
</div>
<div class="setting-row">
<div class="setting-text">
<span class="setting-label">重启间隔</span>
<span class="setting-hint">崩溃重启的指数退避基数</span>
</div>
<el-input-number
v-model="settings.restartIntervalSeconds"
:min="1"
:max="3600"
@change="saveSettings"
/>
</div>
</section>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import { Loading } from '@element-plus/icons-vue'
import { api } from '../api'
import type {
BinaryStatus,
Settings as ManagerSettings,
} from '../types'
const loading = ref(false)
const installing = ref(false)
const binaryPathInput = ref('')
const binaryStatus = ref<BinaryStatus | null>(null)
const settings = ref<ManagerSettings>({
autoStartProfiles: true,
restartOnExit: true,
restartIntervalSeconds: 5,
})
const load = async () => {
loading.value = true
try {
settings.value = await api.settings()
binaryPathInput.value = settings.value.binaryPath || ''
try {
binaryStatus.value = await api.binaryStatus()
} catch {
binaryStatus.value = null
}
} finally {
loading.value = false
}
}
const applyBinaryPath = async () => {
try {
const s = await api.settings()
s.binaryPath = binaryPathInput.value.trim()
await api.saveSettings(s)
settings.value = s
const saved = binaryPathInput.value.trim()
ElMessage.success(
saved ? `worker 将使用 ${saved}` : 'worker 将使用管理器自身(默认)',
)
await load()
} catch (e: any) {
ElMessage.error('保存 frpc 路径失败: ' + (e.message || e))
}
}
const installBinary = async () => {
installing.value = true
try {
const result = await api.installBinary()
binaryPathInput.value = result.path
ElMessage.success(`已安装 frpc ${result.version}`)
await load()
} catch (e: any) {
ElMessage.error('安装失败: ' + (e.message || e))
} finally {
installing.value = false
}
}
const saveSettings = async () => {
try {
await api.saveSettings(settings.value)
ElMessage.success('运行策略已保存')
} catch (e: any) {
ElMessage.error('保存失败: ' + (e.message || e))
}
}
onMounted(load)
</script>
<style scoped lang="scss">
.settings-page {
height: 100%;
display: flex;
flex-direction: column;
overflow-y: auto;
}
.page-top {
flex-shrink: 0;
padding: $spacing-lg $spacing-xl;
border-bottom: 1px solid $color-border-light;
}
.page-header {
.page-title {
margin: 0;
}
.page-subtitle {
margin: 4px 0 0;
color: $color-text-muted;
font-size: $font-size-md;
}
}
.page-content {
padding: $spacing-lg $spacing-xl;
display: flex;
flex-direction: column;
gap: $spacing-lg;
max-width: 760px;
}
.panel {
border: 1px solid $color-border-light;
border-radius: $radius-md;
padding: $spacing-lg;
}
.panel-title {
margin: 0 0 4px;
font-size: $font-size-lg;
font-weight: $font-weight-semibold;
}
.panel-desc {
margin: 0 0 $spacing-md;
color: $color-text-muted;
font-size: $font-size-sm;
}
.binary-row {
display: flex;
gap: $spacing-sm;
}
.binary-input {
flex: 1;
}
.btn {
border: none;
border-radius: 8px;
padding: 8px 16px;
font-size: $font-size-md;
font-weight: $font-weight-medium;
cursor: pointer;
transition: all $transition-fast;
white-space: nowrap;
display: inline-flex;
align-items: center;
gap: 6px;
}
.install-btn {
background: $color-btn-primary;
color: #fff;
&:hover {
background: $color-btn-primary-hover;
}
&:disabled {
opacity: 0.6;
cursor: default;
}
}
.spin {
animation: rotate 1s linear infinite;
}
@keyframes rotate {
to {
transform: rotate(360deg);
}
}
.binary-info {
margin-top: $spacing-md;
display: flex;
align-items: center;
gap: $spacing-sm;
flex-wrap: wrap;
.info-label {
color: $color-text-muted;
font-size: $font-size-sm;
}
.info-value {
background: $color-bg-muted;
border-radius: 6px;
padding: 2px 8px;
font-size: $font-size-sm;
word-break: break-all;
}
.info-version {
background: rgba(64, 158, 255, 0.12);
color: $color-primary;
border-radius: 10px;
padding: 2px 8px;
font-size: $font-size-sm;
}
}
.setting-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: $spacing-md;
padding: $spacing-sm 0;
border-bottom: 1px solid $color-border-lighter;
&:last-child {
border-bottom: none;
}
}
.setting-text {
display: flex;
flex-direction: column;
}
.setting-label {
font-size: $font-size-md;
font-weight: $font-weight-medium;
}
.setting-hint {
font-size: $font-size-sm;
color: $color-text-muted;
}
</style>

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>

20
web/tsconfig.json Normal file
View File

@ -0,0 +1,20 @@
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "preserve",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src/**/*.ts", "src/**/*.vue"]
}

28
web/vite.config.ts Normal file
View File

@ -0,0 +1,28 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { fileURLToPath } from 'node:url'
// The frontend is served by the Go binary at / (embedded dist), and talks to
// the same origin via /api/manager/*.
export default defineConfig({
plugins: [vue()],
base: '/',
resolve: {
alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) },
},
css: {
preprocessorOptions: {
scss: {
additionalData: `@use "@/styles/variables.scss" as *;`,
},
},
},
build: {
outDir: 'dist',
},
server: {
proxy: {
'/api': 'http://127.0.0.1:17650',
},
},
})