mirror of
https://gitcode.com/JianFeeeee/webui4frpc.git
synced 2026-09-23 10:27:55 +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:
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>
|
||||
Reference in New Issue
Block a user