feat: implement VPN platform management and real protocol services

This commit is contained in:
Logan.Li committed 2026-07-22 21:49:13 +08:00
commit 58c036c34c
45 files changed
+9244

No files matched your search

+204
View File
@@ -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>