Files
opsx-vpn/frontend/src/views/Tunnels.vue
T

303 lines
14 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 }">
<div class="row-actions">
<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)">
<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>
</div>
</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>
<TunnelDetailDrawer ref="detailDrawer" @updated="load" />
</el-card>
<TunnelLogDialog ref="logDialog" />
<!-- 批量任务进度 -->
<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 { 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 TunnelDetailDrawer from './tunnels/components/TunnelDetailDrawer.vue'
import TunnelLogDialog from './tunnels/components/TunnelLogDialog.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 selectedRows = ref([])
const taskProgressRef = ref(null)
const logDialog = ref(null)
const detailDrawer = 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')
}
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') detailDrawer.value?.open(row)
else if (cmd === 'delete') remove(row)
else if (cmd === 'download') download(row)
else if (cmd === 'qrcode') detailDrawer.value?.showQr(row)
else if (cmd === 'ipsec-peer') router.push('/ipsec-peer')
else if (cmd === 'logs') logDialog.value?.open(row)
}
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'))
}
}
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')))
}
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); }
.row-actions { display: flex; align-items: center; gap: 8px; white-space: nowrap; }
.row-actions :deep(.el-button + .el-button) { margin-left: 0; }
.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; }
.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>