661 lines
29 KiB
Vue
661 lines
29 KiB
Vue
<template>
|
||
<el-card class="page-card" shadow="never">
|
||
<template #header>
|
||
<div class="tunnel-toolbar">
|
||
<div class="tunnel-filters">
|
||
<el-input v-model="keyword" :placeholder="$t('tunnels.searchPlaceholder')" clearable class="tunnel-search" @change="load" />
|
||
<el-select v-model="protocolFilter" :placeholder="$t('tunnels.allProtocols')" clearable class="tunnel-filter" @change="load">
|
||
<el-option v-for="p in protocolOptions" :key="p" :label="$t(protocolDisplay(p))" :value="p" />
|
||
</el-select>
|
||
<el-select v-model="stateFilter" :placeholder="$t('tunnels.allStates')" clearable class="tunnel-filter" @change="load">
|
||
<el-option :label="$t('tunnels.stateRunning')" value="running" />
|
||
<el-option :label="$t('tunnels.stateStopped')" value="stopped" />
|
||
<el-option :label="$t('tunnels.stateError')" value="error" />
|
||
</el-select>
|
||
</div>
|
||
<div class="tunnel-actions">
|
||
<el-tooltip :content="$t('common.refresh')" placement="top">
|
||
<el-button :icon="Refresh" circle :loading="loading" :aria-label="$t('common.refresh')" @click="load" />
|
||
</el-tooltip>
|
||
<el-button type="primary" :icon="Plus" @click="$router.push('/tunnels/create')">{{ $t('tunnels.createTunnel') }}</el-button>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<el-alert v-if="loadError" type="error" :closable="false" :title="loadError" class="tunnel-error">
|
||
<el-button size="small" @click="load">{{ $t('common.refresh') }}</el-button>
|
||
</el-alert>
|
||
|
||
<!-- 批量操作栏 -->
|
||
<div v-if="selectedRows.length > 0" class="batch-bar">
|
||
<span class="batch-info">{{ $t('tunnels.selected', { count: selectedRows.length }) }}</span>
|
||
<el-button type="success" size="small" @click="batchAction('start')">{{ $t('tunnels.batchStart') }}</el-button>
|
||
<el-button type="warning" size="small" @click="batchAction('stop')">{{ $t('tunnels.batchStop') }}</el-button>
|
||
<el-button type="danger" size="small" @click="batchAction('delete')">{{ $t('tunnels.batchDelete') }}</el-button>
|
||
</div>
|
||
|
||
<el-table :data="list" v-loading="loading" size="default" @selection-change="handleSelectionChange">
|
||
<el-table-column type="selection" width="45" />
|
||
<el-table-column prop="name" :label="$t('common.name')" 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="$t('common.protocol')" width="150">
|
||
<template #default="{ row }">
|
||
<span class="proto-cell">
|
||
<ProtocolIcon :protocol="row.protocol" :size="16" :style="{ color: protocolColor(row.protocol) }" />
|
||
{{ $t(protocolDisplay(row.protocol)) }}
|
||
</span>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column :label="$t('tunnels.authMethod')" min-width="170">
|
||
<template #default="{ row }">
|
||
<el-tooltip v-if="row.protocol === 'wireguard'" :content="$t('tunnels.wgAuthHint')" placement="top">
|
||
<span class="auth-cell">{{ authMethod(row) }}</span>
|
||
</el-tooltip>
|
||
<span v-else class="auth-cell">{{ authMethod(row) }}</span>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column :label="$t('common.status')" width="110">
|
||
<template #default="{ row }">
|
||
<span :style="{ color: stateColor(row) }">{{ stateText(row) }}</span>
|
||
<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="$t('common.traffic')" width="160" align="right" header-align="right">
|
||
<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="$t('tunnels.expireTime')" width="150">
|
||
<template #default="{ row }">
|
||
<span :style="{ color: expiringSoon(row) ? '#e6a23c' : '' }">{{ row.expire_at ? fmtTime(row.expire_at) : $t('common.permanent') }}</span>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column :label="$t('tunnels.node')" width="110">
|
||
<template #default="{ row }">{{ row.node?.name || $t('tunnels.localNode') }}</template>
|
||
</el-table-column>
|
||
<el-table-column :label="$t('common.operation')" width="205" fixed="right">
|
||
<template #default="{ row }">
|
||
<el-button v-if="stateOf(row) !== 'running'" type="success" size="small" plain :loading="changingIds.has(row.id)" :disabled="changingIds.has(row.id)" @click="changeState(row, 'start')">{{ $t('tunnels.start') }}</el-button>
|
||
<el-button v-else type="warning" size="small" plain :loading="changingIds.has(row.id)" :disabled="changingIds.has(row.id)" @click="changeState(row, 'stop')">{{ $t('tunnels.stop') }}</el-button>
|
||
<el-button size="small" link @click="$router.push(`/tunnels/${row.id}/edit`)">{{ $t('common.edit') }}</el-button>
|
||
<el-dropdown trigger="click" @command="cmd => onMoreCommand(cmd, row)" style="margin-left:8px">
|
||
<el-button size="small" link>{{ $t('tunnels.more') }}<el-icon class="el-icon--right"><ArrowDown /></el-icon></el-button>
|
||
<template #dropdown>
|
||
<el-dropdown-menu>
|
||
<el-dropdown-item command="detail">{{ $t('tunnels.detail') }}</el-dropdown-item>
|
||
<el-dropdown-item command="logs">{{ $t('tunnels.viewLogs') }}</el-dropdown-item>
|
||
<el-dropdown-item command="download">{{ $t('tunnels.downloadConfig') }}</el-dropdown-item>
|
||
<el-dropdown-item v-if="row.protocol === 'wireguard'" command="qrcode">{{ $t('tunnels.showQrCode') }}</el-dropdown-item>
|
||
<el-dropdown-item v-if="row.protocol === 'ipsec'" command="ipsec-peer">{{ $t('tunnels.ipsecTest') }}</el-dropdown-item>
|
||
<el-dropdown-item command="delete" divided>{{ $t('common.delete') }}</el-dropdown-item>
|
||
</el-dropdown-menu>
|
||
</template>
|
||
</el-dropdown>
|
||
</template>
|
||
</el-table-column>
|
||
<template #empty>
|
||
<el-empty :description="$t('tunnels.noTunnels')">
|
||
<el-button type="primary" :icon="Plus" @click="$router.push('/tunnels/create')">{{ $t('tunnels.createFirst') }}</el-button>
|
||
</el-empty>
|
||
</template>
|
||
</el-table>
|
||
|
||
<!-- 详情抽屉 -->
|
||
<el-drawer v-model="detailVisible" :title="detail?.tunnel?.name" size="560px">
|
||
<template v-if="detail">
|
||
<el-descriptions :column="1" border size="small">
|
||
<el-descriptions-item :label="$t('common.protocol')">{{ $t(protocolDisplay(detail.tunnel.protocol)) }}</el-descriptions-item>
|
||
<el-descriptions-item :label="$t('tunnels.authMethod')">{{ authMethod(detail.tunnel) }}</el-descriptions-item>
|
||
<el-descriptions-item :label="$t('common.status')">{{ stateText(detail.tunnel) }}</el-descriptions-item>
|
||
<el-descriptions-item :label="$t('tunnels.createTime')">{{ fmtTime(detail.tunnel.created_at) }}</el-descriptions-item>
|
||
<el-descriptions-item :label="$t('tunnels.autoStart')">{{ detail.tunnel.auto_start ? $t('common.yes') : $t('common.no') }}</el-descriptions-item>
|
||
</el-descriptions>
|
||
<h4>{{ $t('tunnels.runningParams') }}</h4>
|
||
<pre class="mono conf-box">{{ prettyConfig(detail.tunnel.config) }}</pre>
|
||
<h4>{{ $t('tunnels.clientConfig') }}</h4>
|
||
<pre class="mono conf-box">{{ detail.client_config || $t('tunnels.noClientConfig') }}</pre>
|
||
<el-button type="primary" style="width:100%;margin-bottom:8px" @click="download(detail.tunnel)">{{ $t('tunnels.downloadClientConfig') }}</el-button>
|
||
<el-button v-if="detail.tunnel.protocol === 'ipsec'" type="warning" plain style="width:100%;margin-bottom:16px"
|
||
@click="$router.push('/ipsec-peer')">{{ $t('tunnels.ipsecParamCheck') }}</el-button>
|
||
|
||
<!-- 流量策略 -->
|
||
<h4>{{ $t('tunnels.trafficPolicy') }}</h4>
|
||
<el-form label-width="90px" size="small" v-loading="policyLoading">
|
||
<el-form-item :label="$t('tunnels.quotaGB')">
|
||
<el-input-number v-model="policy.quota_gb" :min="0" :precision="1" :step="10" style="width:100%" />
|
||
<span style="font-size:12px;color:var(--text-secondary);margin-left:8px">{{ $t('tunnels.quotaUnlimited', { used: fmtBytes(policy.used_bytes || 0) }) }}</span>
|
||
</el-form-item>
|
||
<el-form-item :label="$t('tunnels.rateLimitMbps')">
|
||
<el-input-number v-model="policy.rate_limit_mbps" :min="0" :max="10000" style="width:100%" />
|
||
<span style="font-size:12px;color:var(--text-secondary);margin-left:8px">{{ $t('tunnels.rateUnlimited') }}</span>
|
||
</el-form-item>
|
||
<el-form-item :label="$t('tunnels.exceedAction')">
|
||
<el-select v-model="policy.action_on_exceed" style="width:100%">
|
||
<el-option value="warn" :label="$t('tunnels.actionWarn')" />
|
||
<el-option value="block" :label="$t('tunnels.actionBlock')" />
|
||
<el-option value="throttle" :label="$t('tunnels.actionThrottle')" />
|
||
</el-select>
|
||
</el-form-item>
|
||
<el-form-item :label="$t('tunnels.resetCycle')">
|
||
<el-select v-model="policy.reset_cycle" style="width:100%">
|
||
<el-option value="none" :label="$t('tunnels.resetNone')" />
|
||
<el-option value="daily" :label="$t('tunnels.resetDaily')" />
|
||
<el-option value="monthly" :label="$t('tunnels.resetMonthly')" />
|
||
</el-select>
|
||
</el-form-item>
|
||
<el-button type="primary" size="small" :loading="policySaving" @click="savePolicy(detail.tunnel.id)">{{ $t('tunnels.savePolicy') }}</el-button>
|
||
</el-form>
|
||
|
||
<!-- WireGuard Peer 管理 -->
|
||
<template v-if="detail.tunnel.protocol === 'wireguard'">
|
||
<div style="display:flex;justify-content:space-between;align-items:center;margin:8px 0">
|
||
<h4 style="margin:0">{{ $t('tunnels.peerList') }}</h4>
|
||
<el-button type="primary" size="small" @click="openAddPeer">{{ $t('tunnels.addPeer') }}</el-button>
|
||
</div>
|
||
<el-table :data="wgPeers" size="small" v-loading="peersLoading">
|
||
<el-table-column prop="id" label="ID" width="100" />
|
||
<el-table-column prop="name" :label="$t('common.name')" width="100" />
|
||
<el-table-column prop="client_address" :label="$t('tunnels.address')" min-width="110" />
|
||
<el-table-column :label="$t('common.status')" width="80">
|
||
<template #default="{ row }">
|
||
<el-tag size="small" :type="peerOnline(row) ? 'success' : 'info'">
|
||
{{ peerOnline(row) ? $t('tunnels.peerOnline') : $t('tunnels.peerOffline') }}
|
||
</el-tag>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column :label="$t('common.operation')" width="150">
|
||
<template #default="{ row }">
|
||
<el-button v-if="row.managed !== false" link size="small" @click="downloadPeer(row)">{{ $t('tunnels.config') }}</el-button>
|
||
<el-button v-if="row.managed !== false" link size="small" @click="qrPeer(row)">{{ $t('tunnels.qrCode') }}</el-button>
|
||
<el-button type="danger" link size="small" @click="removePeer(row)">{{ $t('common.delete') }}</el-button>
|
||
</template>
|
||
</el-table-column>
|
||
</el-table>
|
||
</template>
|
||
|
||
<!-- 流量趋势图 -->
|
||
<TrafficChart v-if="detail.tunnel.id" :tunnel-id="detail.tunnel.id" />
|
||
</template>
|
||
</el-drawer>
|
||
|
||
<!-- WireGuard 二维码 -->
|
||
<el-dialog v-model="qrVisible" :title="$t('tunnels.scanQrTitle')" width="360px" align-center>
|
||
<div style="text-align:center">
|
||
<canvas ref="qrCanvas"></canvas>
|
||
<p style="color:#909399;font-size:12px">{{ $t('tunnels.scanQrHint') }}</p>
|
||
</div>
|
||
</el-dialog>
|
||
|
||
<!-- 添加 Peer -->
|
||
<el-dialog v-model="peerDialog" :title="$t('tunnels.addPeerTitle')" width="440px">
|
||
<el-form label-width="110px">
|
||
<el-form-item :label="$t('tunnels.peerId')">
|
||
<el-input v-model="peerForm.id" :placeholder="$t('tunnels.autoGenId')" />
|
||
</el-form-item>
|
||
<el-form-item :label="$t('common.name')">
|
||
<el-input v-model="peerForm.name" :placeholder="$t('tunnels.peerNamePlaceholder')" />
|
||
</el-form-item>
|
||
<el-form-item :label="$t('tunnels.clientAddress')" required>
|
||
<el-input v-model="peerForm.client_address" placeholder="10.8.0.3/32" />
|
||
</el-form-item>
|
||
<el-form-item label="AllowedIPs">
|
||
<el-input v-model="peerForm.allowed_ips" :placeholder="$t('tunnels.defaultAllowedIPs')" />
|
||
</el-form-item>
|
||
<el-form-item :label="$t('tunnels.externalPublicKey')">
|
||
<el-input v-model="peerForm.public_key" type="textarea" :rows="2"
|
||
:placeholder="$t('tunnels.externalKeyHint')" />
|
||
</el-form-item>
|
||
</el-form>
|
||
<template #footer>
|
||
<el-button @click="peerDialog = false">{{ $t('common.cancel') }}</el-button>
|
||
<el-button type="primary" :loading="peerSaving" @click="addPeer">{{ $t('common.add') }}</el-button>
|
||
</template>
|
||
</el-dialog>
|
||
</el-card>
|
||
|
||
<!-- 日志实时查看弹窗 -->
|
||
<el-dialog v-model="logVisible" :title="$t('tunnels.logTitle', { name: logTunnel?.name || '' })" width="780px" align-center
|
||
@close="closeLogStream">
|
||
<div class="log-toolbar">
|
||
<el-tag size="small" :type="logStreaming ? 'success' : 'info'">
|
||
{{ logStreaming ? $t('tunnels.liveOutput') : $t('tunnels.logStopped') }}
|
||
</el-tag>
|
||
<el-button v-if="logStreaming" size="small" @click="stopLogStream">{{ $t('tunnels.pause') }}</el-button>
|
||
<el-button v-else size="small" type="primary" @click="resumeLogStream">{{ $t('tunnels.resume') }}</el-button>
|
||
<el-button size="small" @click="logContent = ''">{{ $t('tunnels.clearScreen') }}</el-button>
|
||
</div>
|
||
<div class="log-view mono" ref="logView">{{ logContent || $t('tunnels.waitingLog') }}</div>
|
||
</el-dialog>
|
||
|
||
<!-- 批量任务进度 -->
|
||
<TaskProgress ref="taskProgressRef" />
|
||
</template>
|
||
|
||
<script setup>
|
||
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
|
||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||
import { useI18n } from 'vue-i18n'
|
||
import { Plus, InfoFilled, ArrowDown, Refresh } from '@element-plus/icons-vue'
|
||
import QRCode from 'qrcode'
|
||
import { useRouter } from 'vue-router'
|
||
import api from '../api'
|
||
import { fmtBytes } from '../utils/format'
|
||
import ProtocolIcon from '../components/ProtocolIcon.vue'
|
||
import TaskProgress from '../components/TaskProgress.vue'
|
||
import TrafficChart from '../components/TrafficChart.vue'
|
||
import { protocolDisplay, protocolColor } from '../protocols'
|
||
|
||
const { t } = useI18n()
|
||
const router = useRouter()
|
||
const list = ref([])
|
||
const loading = ref(false)
|
||
const loadError = ref('')
|
||
const changingIds = ref(new Set())
|
||
let loadRevision = 0
|
||
const keyword = ref('')
|
||
const protocolFilter = ref('')
|
||
const stateFilter = ref('')
|
||
const protocolOptions = ref([])
|
||
const detailVisible = ref(false)
|
||
const detail = ref(null)
|
||
const qrVisible = ref(false)
|
||
// 流量策略
|
||
const policy = ref({ quota_gb: 0, rate_limit_mbps: 0, action_on_exceed: 'block', reset_cycle: 'none', used_bytes: 0 })
|
||
const policyLoading = ref(false)
|
||
const policySaving = ref(false)
|
||
const qrCanvas = ref(null)
|
||
const wgPeers = ref([])
|
||
const runtimePeers = ref([])
|
||
const peersLoading = ref(false)
|
||
const peerDialog = ref(false)
|
||
const peerSaving = ref(false)
|
||
const peerForm = ref({ id: '', name: '', client_address: '', allowed_ips: '', public_key: '' })
|
||
|
||
// 批量操作
|
||
const selectedRows = ref([])
|
||
const taskProgressRef = ref(null)
|
||
|
||
function handleSelectionChange(rows) {
|
||
selectedRows.value = rows
|
||
}
|
||
const selectedIds = computed(() => selectedRows.value.map(r => r.id))
|
||
|
||
async function batchAction(action) {
|
||
const ids = selectedIds.value
|
||
if (ids.length === 0) return
|
||
const confirmKey = `tunnels.batch${action.charAt(0).toUpperCase() + action.slice(1)}Confirm`
|
||
try {
|
||
await ElMessageBox.confirm(t(confirmKey, { count: ids.length }), t('common.confirm'), { type: 'warning' })
|
||
} catch { return }
|
||
try {
|
||
const { data } = await api.post(`/tunnels/batch/${action}`, { tunnel_ids: ids })
|
||
ElMessage.success(t('tunnels.batchStarted'))
|
||
selectedRows.value = []
|
||
if (data.task_id && taskProgressRef.value) {
|
||
taskProgressRef.value.open(data.task_id)
|
||
}
|
||
load()
|
||
} catch (e) {
|
||
ElMessage.error(e.response?.data?.error || t('common.operationFailed'))
|
||
}
|
||
}
|
||
|
||
function fmtTime(time) { return time ? new Date(time).toLocaleString() : '-' }
|
||
function expiringSoon(row) {
|
||
return row.expire_at && new Date(row.expire_at) - Date.now() < 7 * 864e5
|
||
}
|
||
function stateText(row) {
|
||
return { running: t('tunnels.stateRunning'), stopped: t('tunnels.stateStopped'), error: t('tunnels.stateError'), undeployed: t('tunnels.stateUndeployed') }[stateOf(row)] || stateOf(row)
|
||
}
|
||
function stateColor(row) {
|
||
return { running: '#67c23a', stopped: '#909399', error: '#f56c6c', undeployed: '#e6a23c' }[stateOf(row)]
|
||
}
|
||
function stateOf(row) { return row.runtime?.state || row.state }
|
||
function authMethod(row) {
|
||
let config = {}
|
||
try { config = typeof row.config === 'string' ? JSON.parse(row.config) : (row.config || {}) } catch { /* old malformed config */ }
|
||
if (row.protocol === 'wireguard') return !config.auth_mode || config.auth_mode === 'password' ? t('tunnels.wgAuthPassword') : t('tunnels.authUnknown')
|
||
if (row.protocol === 'openvpn') {
|
||
const mode = config.auth_mode || 'password'
|
||
const key = `protocols.openvpn.fields.auth_mode.options.${mode}`
|
||
return ['certificate', 'password', 'certificate+password', 'oidc'].includes(mode) ? t(key) : t('tunnels.authUnknown')
|
||
}
|
||
return t('tunnels.authUnknown')
|
||
}
|
||
function prettyConfig(c) {
|
||
try { return JSON.stringify(JSON.parse(c), null, 2) } catch { return c }
|
||
}
|
||
function peerOnline(row) {
|
||
const rt = runtimePeers.value.find(p => p.id === row.id || p.public_key === row.public_key)
|
||
return !!rt?.connected
|
||
}
|
||
|
||
async function load() {
|
||
const revision = ++loadRevision
|
||
loading.value = true
|
||
try {
|
||
const { data } = await api.get('/tunnels', { params: { keyword: keyword.value, protocol: protocolFilter.value } })
|
||
let result = data || []
|
||
if (stateFilter.value) {
|
||
result = result.filter(r => stateOf(r) === stateFilter.value)
|
||
}
|
||
if (revision === loadRevision) { list.value = result; loadError.value = '' }
|
||
} catch (e) {
|
||
if (revision === loadRevision) loadError.value = e.response?.data?.error || t('common.loadFailed')
|
||
} finally { if (revision === loadRevision) loading.value = false }
|
||
}
|
||
|
||
// 更多操作下拉命令分发
|
||
function onMoreCommand(cmd, row) {
|
||
if (cmd === 'detail') showDetail(row)
|
||
else if (cmd === 'delete') remove(row)
|
||
else if (cmd === 'download') download(row)
|
||
else if (cmd === 'qrcode') showQr(row)
|
||
else if (cmd === 'ipsec-peer') router.push('/ipsec-peer')
|
||
else if (cmd === 'logs') openLogStream(row)
|
||
}
|
||
|
||
// ---- 实时日志流(SSE via fetch + ReadableStream,避免 token 暴露在 URL)----
|
||
const logVisible = ref(false)
|
||
const logTunnel = ref(null)
|
||
const logContent = ref('')
|
||
const logStreaming = ref(false)
|
||
const logView = ref(null)
|
||
let logAbortController = null
|
||
|
||
function openLogStream(row) {
|
||
logTunnel.value = row
|
||
logContent.value = ''
|
||
logVisible.value = true
|
||
resumeLogStream()
|
||
}
|
||
|
||
async function resumeLogStream() {
|
||
if (!logTunnel.value) return
|
||
stopLogStream()
|
||
const token = localStorage.getItem('vpn_token')
|
||
logAbortController = new AbortController()
|
||
logStreaming.value = true
|
||
try {
|
||
const resp = await fetch(`/api/tunnels/${logTunnel.value.id}/logs`, {
|
||
headers: { 'Authorization': `Bearer ${token}` },
|
||
signal: logAbortController.signal
|
||
})
|
||
if (!resp.ok) { logStreaming.value = false; return }
|
||
const reader = resp.body.getReader()
|
||
const decoder = new TextDecoder()
|
||
let buffer = ''
|
||
while (true) {
|
||
const { done, value } = await reader.read()
|
||
if (done) break
|
||
buffer += decoder.decode(value, { stream: true })
|
||
const lines = buffer.split('\n')
|
||
buffer = lines.pop()
|
||
for (const line of lines) {
|
||
if (line.startsWith('data: ')) {
|
||
logContent.value += line.slice(6) + '\n'
|
||
}
|
||
}
|
||
nextTick(() => {
|
||
if (logView.value) logView.value.scrollTop = logView.value.scrollHeight
|
||
})
|
||
if (logContent.value.length > 100000) {
|
||
logContent.value = logContent.value.slice(-50000)
|
||
}
|
||
}
|
||
} catch (e) {
|
||
if (e.name !== 'AbortError') logContent.value += `\n${t('common.loadFailed')}\n`
|
||
}
|
||
logStreaming.value = false
|
||
}
|
||
|
||
function stopLogStream() {
|
||
logStreaming.value = false
|
||
logAbortController?.abort()
|
||
logAbortController = null
|
||
}
|
||
|
||
function closeLogStream() {
|
||
stopLogStream()
|
||
logContent.value = ''
|
||
}
|
||
|
||
// ---- 流量策略 ----
|
||
async function loadPolicy(tunnelId) {
|
||
policyLoading.value = true
|
||
try {
|
||
const { data } = await api.get(`/tunnels/${tunnelId}/policy`)
|
||
policy.value = {
|
||
quota_gb: data.quota_bytes > 0 ? +(data.quota_bytes / (1024 * 1024 * 1024)).toFixed(2) : 0,
|
||
rate_limit_mbps: data.rate_limit_mbps || 0,
|
||
action_on_exceed: data.action_on_exceed || 'block',
|
||
reset_cycle: data.reset_cycle || 'none',
|
||
used_bytes: data.used_bytes || 0,
|
||
}
|
||
} catch { policy.value = { quota_gb: 0, rate_limit_mbps: 0, action_on_exceed: 'block', reset_cycle: 'none', used_bytes: 0 } }
|
||
finally { policyLoading.value = false }
|
||
}
|
||
|
||
async function savePolicy(tunnelId) {
|
||
policySaving.value = true
|
||
try {
|
||
const quotaBytes = Math.round((policy.value.quota_gb || 0) * 1024 * 1024 * 1024)
|
||
await api.put(`/tunnels/${tunnelId}/policy`, {
|
||
quota_bytes: quotaBytes,
|
||
rate_limit_mbps: policy.value.rate_limit_mbps,
|
||
action_on_exceed: policy.value.action_on_exceed,
|
||
reset_cycle: policy.value.reset_cycle,
|
||
})
|
||
ElMessage.success(t('tunnels.policySaved'))
|
||
} catch (e) {
|
||
ElMessage.error(e.response?.data?.error || t('common.saveFailed'))
|
||
} finally { policySaving.value = false }
|
||
}
|
||
|
||
async function changeState(row, action) {
|
||
if (changingIds.value.has(row.id)) return
|
||
changingIds.value = new Set([...changingIds.value, row.id])
|
||
++loadRevision // discard a poll started before this operation
|
||
try {
|
||
const { data } = await api.post(`/tunnels/${row.id}/${action}`)
|
||
row.state = data.state
|
||
row.runtime = null // discard the monitor's older snapshot immediately
|
||
if (stateFilter.value && stateFilter.value !== data.state) {
|
||
list.value = list.value.filter(item => item.id !== row.id)
|
||
}
|
||
if (data.warning) ElMessage.warning(data.warning)
|
||
else ElMessage.success(t(action === 'start' ? 'tunnels.started' : 'tunnels.stopped'))
|
||
} catch (e) {
|
||
ElMessage.error(e.response?.data?.error || t(action === 'start' ? 'tunnels.startFailed' : 'tunnels.stopFailed'))
|
||
} finally {
|
||
try { await load() } finally {
|
||
const next = new Set(changingIds.value)
|
||
next.delete(row.id)
|
||
changingIds.value = next
|
||
}
|
||
}
|
||
}
|
||
async function remove(row) {
|
||
try {
|
||
await ElMessageBox.confirm(t('tunnels.deleteTunnelConfirm', { name: row.name }), t('common.deleteConfirm'), { type: 'warning' })
|
||
await api.delete(`/tunnels/${row.id}`)
|
||
ElMessage.success(t('common.deleteSuccess'))
|
||
load()
|
||
} catch (e) {
|
||
if (e !== 'cancel') ElMessage.error(e.response?.data?.error || t('common.deleteFailed'))
|
||
}
|
||
}
|
||
async function showDetail(row) {
|
||
try {
|
||
const { data } = await api.get(`/tunnels/${row.id}`)
|
||
detail.value = data
|
||
detailVisible.value = true
|
||
if (row.protocol === 'wireguard') {
|
||
await loadPeers(row.id)
|
||
} else {
|
||
wgPeers.value = []
|
||
runtimePeers.value = []
|
||
}
|
||
loadPolicy(row.id)
|
||
} catch (e) {
|
||
ElMessage.error(e.response?.data?.error || t('common.loadFailed'))
|
||
}
|
||
}
|
||
|
||
async function loadPeers(tunnelId) {
|
||
peersLoading.value = true
|
||
try {
|
||
const { data } = await api.get(`/tunnels/${tunnelId}/wireguard/peers`)
|
||
wgPeers.value = data.peers || []
|
||
runtimePeers.value = data.runtime_peers || []
|
||
} catch {
|
||
wgPeers.value = []
|
||
} finally {
|
||
peersLoading.value = false
|
||
}
|
||
}
|
||
|
||
function openAddPeer() {
|
||
peerForm.value = { id: '', name: '', client_address: '', allowed_ips: '', public_key: '' }
|
||
peerDialog.value = true
|
||
}
|
||
|
||
async function addPeer() {
|
||
if (!peerForm.value.client_address) return ElMessage.warning(t('tunnels.fillClientAddress'))
|
||
peerSaving.value = true
|
||
try {
|
||
const body = { ...peerForm.value }
|
||
if (!body.id) delete body.id
|
||
if (!body.public_key) delete body.public_key
|
||
if (!body.allowed_ips) delete body.allowed_ips
|
||
await api.post(`/tunnels/${detail.value.tunnel.id}/wireguard/peers`, body)
|
||
ElMessage.success(t('tunnels.peerAdded'))
|
||
peerDialog.value = false
|
||
await loadPeers(detail.value.tunnel.id)
|
||
const { data } = await api.get(`/tunnels/${detail.value.tunnel.id}`)
|
||
detail.value = data
|
||
load()
|
||
} catch (e) {
|
||
ElMessage.error(e.response?.data?.error || t('tunnels.addFailed'))
|
||
} finally {
|
||
peerSaving.value = false
|
||
}
|
||
}
|
||
|
||
async function removePeer(row) {
|
||
await ElMessageBox.confirm(t('tunnels.deletePeerConfirm', { id: row.id }), t('common.confirm'), { type: 'warning' })
|
||
try {
|
||
await api.delete(`/tunnels/${detail.value.tunnel.id}/wireguard/peers/${row.id}`)
|
||
ElMessage.success(t('common.deleteSuccess'))
|
||
await loadPeers(detail.value.tunnel.id)
|
||
load()
|
||
} catch (e) {
|
||
ElMessage.error(e.response?.data?.error || t('common.deleteFailed'))
|
||
}
|
||
}
|
||
|
||
function downloadPeer(row) {
|
||
fetch(`/api/tunnels/${detail.value.tunnel.id}/wireguard/peers/${row.id}/config`, {
|
||
headers: { Authorization: `Bearer ${localStorage.getItem('vpn_token')}` }
|
||
}).then(async r => {
|
||
if (!r.ok) {
|
||
const d = await r.json()
|
||
throw new Error(d.error)
|
||
}
|
||
return r.blob()
|
||
}).then(b => {
|
||
const a = document.createElement('a')
|
||
a.href = URL.createObjectURL(b)
|
||
a.download = `wireguard-${row.id}.conf`
|
||
a.click()
|
||
}).catch(e => ElMessage.error(e.message || t('tunnels.downloadFailed')))
|
||
}
|
||
|
||
async function qrPeer(row) {
|
||
try {
|
||
const res = await fetch(`/api/tunnels/${detail.value.tunnel.id}/wireguard/peers/${row.id}/config`, {
|
||
headers: { Authorization: `Bearer ${localStorage.getItem('vpn_token')}` }
|
||
})
|
||
if (!res.ok) {
|
||
const d = await res.json()
|
||
throw new Error(d.error)
|
||
}
|
||
const conf = await res.text()
|
||
qrVisible.value = true
|
||
await nextTick()
|
||
QRCode.toCanvas(qrCanvas.value, conf, { width: 280, margin: 1 })
|
||
} catch (e) {
|
||
ElMessage.error(e.message || t('tunnels.qrFailed'))
|
||
}
|
||
}
|
||
|
||
function download(row) {
|
||
fetch(`/api/tunnels/${row.id}/config`, { headers: { Authorization: `Bearer ${localStorage.getItem('vpn_token')}` } })
|
||
.then(r => { if (!r.ok) throw new Error(r.statusText); return r.blob() })
|
||
.then(b => {
|
||
const a = document.createElement('a')
|
||
const url = URL.createObjectURL(b)
|
||
a.href = url
|
||
const ext = row.protocol === 'openvpn' ? 'ovpn' : 'conf'
|
||
a.download = `${row.protocol}-${row.name}.${ext}`
|
||
a.click()
|
||
URL.revokeObjectURL(url)
|
||
})
|
||
.catch(e => ElMessage.error(e.message || t('tunnels.downloadFailed')))
|
||
}
|
||
async function showQr(row) {
|
||
try {
|
||
const { data } = await api.get(`/tunnels/${row.id}`)
|
||
if (!data.client_config) return ElMessage.warning(t('tunnels.noClientConfigWarn'))
|
||
qrVisible.value = true
|
||
await nextTick()
|
||
QRCode.toCanvas(qrCanvas.value, data.client_config, { width: 280, margin: 1 })
|
||
} catch (e) {
|
||
ElMessage.error(e.response?.data?.error || t('tunnels.qrFailed'))
|
||
}
|
||
}
|
||
|
||
let timer
|
||
onMounted(async () => {
|
||
load()
|
||
timer = setInterval(load, 15000)
|
||
try {
|
||
const { data } = await api.get('/protocols')
|
||
protocolOptions.value = data.map(p => p.name)
|
||
} catch { /* protocols load optional */ }
|
||
})
|
||
onUnmounted(() => clearInterval(timer))
|
||
</script>
|
||
|
||
<style scoped>
|
||
.tunnel-toolbar, .tunnel-filters, .tunnel-actions { display: flex; align-items: center; gap: var(--space-2); }
|
||
.tunnel-toolbar { justify-content: space-between; flex-wrap: wrap; }
|
||
.tunnel-filters { flex: 1; min-width: 0; flex-wrap: wrap; }
|
||
.tunnel-search { width: 200px; }
|
||
.tunnel-filter { width: 150px; }
|
||
.tunnel-error { margin-bottom: var(--space-4); }
|
||
@media (max-width: 640px) { .tunnel-search { flex: 1 1 100%; width: 100%; } .tunnel-filter { flex: 1 1 135px; width: auto; } .tunnel-actions { width: 100%; justify-content: flex-end; } }
|
||
.remark { font-size: 12px; color: var(--text-secondary); }
|
||
.proto-cell { display: inline-flex; align-items: center; gap: 6px; font-weight: 600; }
|
||
.conf-box { background: var(--bg-inset); border: 1px solid var(--border-base); border-radius: var(--radius-sm); padding: 12px; max-height: 260px; overflow: auto; }
|
||
.log-toolbar { display: flex; align-items: center; gap: 8px; margin-bottom: 12px; }
|
||
.log-view {
|
||
background: #1e1e1e; color: #d4d4d4; border-radius: var(--radius-sm); padding: 16px;
|
||
height: 400px; overflow-y: auto; font-size: 13px; line-height: 1.6; white-space: pre-wrap; word-break: break-all;
|
||
}
|
||
.batch-bar {
|
||
display: flex; align-items: center; gap: 10px;
|
||
padding: 10px 16px; margin-bottom: 12px;
|
||
background: var(--el-color-primary-light-9);
|
||
border-radius: var(--radius-sm);
|
||
border: 1px solid var(--el-color-primary-light-5);
|
||
}
|
||
.batch-info { font-size: 13px; font-weight: 600; color: var(--el-color-primary); margin-right: auto; }
|
||
</style>
|