mirror of
https://gitcode.com/JianFeeeee/webui4frpc.git
synced 2026-09-19 16:38:31 +00:00
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:
12
web/index.html
Normal file
12
web/index.html
Normal 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
2887
web/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
27
web/package.json
Normal file
27
web/package.json
Normal 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
95
web/src/App.vue
Normal 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
84
web/src/api.ts
Normal 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`),
|
||||
}
|
||||
166
web/src/components/LocalNode.vue
Normal file
166
web/src/components/LocalNode.vue
Normal 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>
|
||||
330
web/src/components/PortEdge.vue
Normal file
330
web/src/components/PortEdge.vue
Normal 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>
|
||||
172
web/src/components/RemoteNode.vue
Normal file
172
web/src/components/RemoteNode.vue
Normal 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
7
web/src/env.d.ts
vendored
Normal 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
10
web/src/main.ts
Normal 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')
|
||||
40
web/src/styles/variables.scss
Normal file
40
web/src/styles/variables.scss
Normal 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
96
web/src/types.ts
Normal 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
|
||||
}
|
||||
785
web/src/views/CanvasView.vue
Normal file
785
web/src/views/CanvasView.vue
Normal 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>
|
||||
319
web/src/views/SettingsView.vue
Normal file
319
web/src/views/SettingsView.vue
Normal 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>
|
||||
497
web/src/views/StatusView.vue
Normal file
497
web/src/views/StatusView.vue
Normal 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
20
web/tsconfig.json
Normal 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
28
web/vite.config.ts
Normal 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',
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user