webui4frpc: 独立可用的可视化 frpc 控制器 (M0)

- 零 frp 源码依赖,单二进制 (Go + Vue3 + VueFlow + Element Plus)
- 画布多对多连线,渲染 tcp/udp/http/https frpc 配置
- worker 进程管理:自愈、日志轮转、崩溃退避重启
- frpc 一键安装 (GitHub Releases) + 手动指定路径
- 三页 UI:状态(默认)/连接配置/设置
- 状态页实时节点/转发状态,节点可增删改启停
- 画布冲突检查:端口/域名冲突标红 + 弹窗拦截保存
- backend 单测覆盖 store/render/process/httpapi/install
- plan.md + FRPC_FEATURES_AUDIT.md 文档
This commit is contained in:
2026-08-17 11:00:26 +08:00
commit c1936887a2
40 changed files with 7868 additions and 0 deletions

View File

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

View File

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

View File

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