feat: implement VPN platform management and real protocol services
This commit is contained in:
commit
58c036c34c
45 files changed
+9244
No files matched your search
@@ -0,0 +1,204 @@
|
||||
<template>
|
||||
<el-card class="page-card" shadow="never">
|
||||
<template #header>
|
||||
<div style="display:flex;justify-content:space-between;align-items:center">
|
||||
<div>
|
||||
<el-input v-model="keyword" placeholder="搜索名称" clearable style="width:200px" @change="load" />
|
||||
<el-select v-model="protocolFilter" placeholder="全部协议" clearable style="width:160px;margin-left:10px" @change="load">
|
||||
<el-option v-for="p in protocolOptions" :key="p" :label="protocolDisplay(p)" :value="p" />
|
||||
</el-select>
|
||||
</div>
|
||||
<el-button type="primary" :icon="Plus" @click="$router.push('/tunnels/create')">创建专线</el-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-table :data="list" v-loading="loading" size="large">
|
||||
<el-table-column prop="name" label="名称" min-width="140">
|
||||
<template #default="{ row }">
|
||||
<b>{{ row.name }}</b>
|
||||
<div class="remark" v-if="row.remark">{{ row.remark }}</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="协议" width="130">
|
||||
<template #default="{ row }">
|
||||
<el-tag :color="protocolColor(row.protocol)" effect="dark" style="border:none">
|
||||
{{ protocolDisplay(row.protocol) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="110">
|
||||
<template #default="{ row }">
|
||||
<el-badge is-dot :type="dotType(row)" style="padding-left:12px">
|
||||
<span :style="{ color: stateColor(row) }">{{ stateText(row) }}</span>
|
||||
</el-badge>
|
||||
<el-tooltip v-if="row.runtime?.detail" :content="row.runtime.detail" placement="top">
|
||||
<el-icon style="margin-left:4px;color:#909399;cursor:help"><InfoFilled /></el-icon>
|
||||
</el-tooltip>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="流量 ↓ / ↑" width="140">
|
||||
<template #default="{ row }">
|
||||
<span style="font-size:12px">{{ fmtBytes(row.rx_bytes) }} / {{ fmtBytes(row.tx_bytes) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="到期时间" width="150">
|
||||
<template #default="{ row }">
|
||||
<span :style="{ color: expiringSoon(row) ? '#e6a23c' : '' }">{{ row.expire_at ? fmtTime(row.expire_at) : '永久' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="节点" width="110">
|
||||
<template #default="{ row }">{{ row.node?.name || '本机节点' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="300" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button v-if="stateOf(row) !== 'running'" type="success" size="small" link @click="start(row)">启动</el-button>
|
||||
<el-button v-else type="warning" size="small" link @click="stop(row)">停止</el-button>
|
||||
<el-button size="small" link @click="showDetail(row)">详情</el-button>
|
||||
<el-button size="small" link @click="$router.push(`/tunnels/${row.id}/edit`)">编辑</el-button>
|
||||
<el-button size="small" link @click="download(row)">下载配置</el-button>
|
||||
<el-button v-if="row.protocol === 'wireguard'" size="small" link @click="showQr(row)">二维码</el-button>
|
||||
<el-button type="danger" size="small" link @click="remove(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<template #empty>
|
||||
<el-empty description="还没有专线,点击右上角创建第一条" />
|
||||
</template>
|
||||
</el-table>
|
||||
|
||||
<!-- 详情抽屉 -->
|
||||
<el-drawer v-model="detailVisible" :title="detail?.tunnel?.name" size="520px">
|
||||
<template v-if="detail">
|
||||
<el-descriptions :column="1" border size="small">
|
||||
<el-descriptions-item label="协议">{{ protocolDisplay(detail.tunnel.protocol) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="状态">{{ stateText(detail.tunnel) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="创建时间">{{ fmtTime(detail.tunnel.created_at) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="自动启动">{{ detail.tunnel.auto_start ? '是' : '否' }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
<h4>运行参数</h4>
|
||||
<pre class="mono conf-box">{{ prettyConfig(detail.tunnel.config) }}</pre>
|
||||
<h4>客户端配置</h4>
|
||||
<pre class="mono conf-box">{{ detail.client_config || '该协议暂无客户端配置' }}</pre>
|
||||
<el-button type="primary" style="width:100%" @click="download(detail.tunnel)">下载客户端配置</el-button>
|
||||
</template>
|
||||
</el-drawer>
|
||||
|
||||
<!-- WireGuard 二维码 -->
|
||||
<el-dialog v-model="qrVisible" title="扫码导入 WireGuard 客户端" width="360px" align-center>
|
||||
<div style="text-align:center">
|
||||
<canvas ref="qrCanvas"></canvas>
|
||||
<p style="color:#909399;font-size:12px">使用 WireGuard App「从二维码添加」</p>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</el-card>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onUnmounted, nextTick } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Plus, InfoFilled } from '@element-plus/icons-vue'
|
||||
import QRCode from 'qrcode'
|
||||
import api from '../api'
|
||||
import { protocolDisplay, protocolColor } from '../protocols'
|
||||
|
||||
const list = ref([])
|
||||
const loading = ref(false)
|
||||
const keyword = ref('')
|
||||
const protocolFilter = ref('')
|
||||
const protocolOptions = ref([])
|
||||
const detailVisible = ref(false)
|
||||
const detail = ref(null)
|
||||
const qrVisible = ref(false)
|
||||
const qrCanvas = ref(null)
|
||||
|
||||
function fmtBytes(n) {
|
||||
if (!n) return '0 B'
|
||||
const u = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||
let i = 0
|
||||
while (n >= 1024 && i < 4) { n /= 1024; i++ }
|
||||
return n.toFixed(1) + ' ' + u[i]
|
||||
}
|
||||
function fmtTime(t) { return t ? new Date(t).toLocaleString('zh-CN') : '-' }
|
||||
function expiringSoon(row) {
|
||||
return row.expire_at && new Date(row.expire_at) - Date.now() < 7 * 864e5
|
||||
}
|
||||
function stateText(row) {
|
||||
return { running: '运行中', stopped: '已停止', error: '异常', undeployed: '未部署' }[stateOf(row)] || stateOf(row)
|
||||
}
|
||||
function stateColor(row) {
|
||||
return { running: '#67c23a', stopped: '#909399', error: '#f56c6c', undeployed: '#e6a23c' }[stateOf(row)]
|
||||
}
|
||||
function dotType(row) {
|
||||
return { running: 'success', stopped: 'info', error: 'danger', undeployed: 'warning' }[stateOf(row)] || 'info'
|
||||
}
|
||||
function stateOf(row) { return row.runtime?.state || row.state }
|
||||
function prettyConfig(c) {
|
||||
try { return JSON.stringify(JSON.parse(c), null, 2) } catch { return c }
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const { data } = await api.get('/tunnels', { params: { keyword: keyword.value, protocol: protocolFilter.value } })
|
||||
list.value = data || []
|
||||
} finally { loading.value = false }
|
||||
}
|
||||
|
||||
async function start(row) {
|
||||
try {
|
||||
await api.post(`/tunnels/${row.id}/start`)
|
||||
ElMessage.success('已启动')
|
||||
} catch (e) {
|
||||
ElMessage.error(e.response?.data?.error || '启动失败')
|
||||
}
|
||||
load()
|
||||
}
|
||||
async function stop(row) {
|
||||
await api.post(`/tunnels/${row.id}/stop`)
|
||||
ElMessage.success('已停止')
|
||||
load()
|
||||
}
|
||||
async function remove(row) {
|
||||
await ElMessageBox.confirm(`确定删除专线「${row.name}」?配置与密钥将一并清除。`, '删除确认', { type: 'warning' })
|
||||
await api.delete(`/tunnels/${row.id}`)
|
||||
ElMessage.success('已删除')
|
||||
load()
|
||||
}
|
||||
async function showDetail(row) {
|
||||
const { data } = await api.get(`/tunnels/${row.id}`)
|
||||
detail.value = data
|
||||
detailVisible.value = true
|
||||
}
|
||||
function download(row) {
|
||||
// 带鉴权下载
|
||||
fetch(`/api/tunnels/${row.id}/config`, { headers: { Authorization: `Bearer ${localStorage.getItem('vpn_token')}` } })
|
||||
.then(r => r.blob()).then(b => {
|
||||
const a = document.createElement('a')
|
||||
const url = URL.createObjectURL(b)
|
||||
a.href = url
|
||||
a.download = `${row.protocol}-${row.name}.conf`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
})
|
||||
}
|
||||
async function showQr(row) {
|
||||
const { data } = await api.get(`/tunnels/${row.id}`)
|
||||
if (!data.client_config) return ElMessage.warning('无客户端配置')
|
||||
qrVisible.value = true
|
||||
await nextTick()
|
||||
QRCode.toCanvas(qrCanvas.value, data.client_config, { width: 280, margin: 1 })
|
||||
}
|
||||
|
||||
let timer
|
||||
onMounted(async () => {
|
||||
load()
|
||||
timer = setInterval(load, 15000)
|
||||
const { data } = await api.get('/protocols')
|
||||
protocolOptions.value = data.map(p => p.name)
|
||||
})
|
||||
onUnmounted(() => clearInterval(timer))
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.remark { font-size: 12px; color: #909399; }
|
||||
.conf-box { background: #f6f8fa; border: 1px solid #e8eaee; border-radius: 6px; padding: 12px; max-height: 260px; overflow: auto; }
|
||||
</style>
|
||||
Reference in new issue
Block a user