mirror of
https://gitcode.com/JianFeeeee/webui4frpc.git
synced 2026-09-20 17:07:57 +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:
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