Files
lmvpn_server/frontend/src/views/VpnView.vue
T
kevin c63440435e feat: 每用户最大连接数改为可配置,默认 30
将 maxConnsPerUser 从硬编码常量(3)改为数据库动态配置项,管理员可在
/admin/vpn 隧道设置中调整,保存后对新连接立即生效。

- model/vpn.go: VpnSetting 新增 MaxConnsPerUser 字段,gorm default:30
- db/db.go: 种子数据设默认 30;旧库回填 0 值为 30
- vpn/tunnel.go: 删除 maxConnsPerUser 常量,改读 VPN.Settings(),兜底 30
- handler/vpn.go: API 响应/请求结构体新增字段,校验范围 1-1000
- VpnView.vue: 隧道设置表单新增"每用户最大连接数"输入框
- zh.ts/en.ts: 新增 maxConnsPerUser 文案,更新首页多设备描述
- docs/client-development.md: 更新常量表为可配置项
2026-07-10 13:44:46 +08:00

488 lines
22 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAuthStore } from '@/stores/auth'
const authStore = useAuthStore()
const { t } = useI18n()
const authHeader = () => ({ Authorization: `Bearer ${authStore.token}` })
interface Settings {
enabled: boolean
subnet: string
subnet6: string
mtu: number
interface_name: string
allow_client_to_client: boolean
do_local_ip_config: boolean
do_remote_ip_config: boolean
max_conns_per_user: number
}
interface ClientInfo {
user_id: number
username: string
ip: string
ip6?: string
connected_at: string
}
interface Status {
enabled: boolean
online: number
used_ips: number
capacity: number
used_ips6?: number
capacity6?: number
clients: ClientInfo[]
}
interface Reservation {
id: number
user_id: number
username: string
ip_address: string
ip_address6?: string
created_at: string
}
interface User {
id: number
username: string
}
interface Diag {
platform: string
is_root: boolean
has_cap_net_admin: boolean | null
cap_net_admin_note?: string
ip_forward: boolean | null
ip_forward_note?: string
masquerade: boolean | null
masquerade_note?: string
ip6_forward: boolean | null
ip6_forward_note?: string
masquerade6: boolean | null
masquerade6_note?: string
tun_create: string
tun_running: boolean
tun_name?: string
}
const settings = ref<Settings | null>(null)
const status = ref<Status | null>(null)
const reservations = ref<Reservation[]>([])
const users = ref<User[]>([])
const diag = ref<Diag | null>(null)
const loading = ref(false)
const error = ref('')
const saving = ref(false)
const saveMsg = ref('')
const saveOk = ref(false)
const form = ref<Settings>({
enabled: false,
subnet: '192.168.77.0/24',
subnet6: '',
mtu: 1420,
interface_name: '',
allow_client_to_client: false,
do_local_ip_config: true,
do_remote_ip_config: true,
max_conns_per_user: 30,
})
async function fetchSettings() {
try {
const res = await fetch('/api/admin/vpn/settings', { headers: authHeader() })
if (!res.ok) throw new Error(t('common.loadFailed'))
const data = await res.json()
form.value = { ...data }
settings.value = { ...data }
} catch (e: any) {
error.value = e.message
}
}
async function fetchStatus() {
try {
const res = await fetch('/api/admin/vpn/status', { headers: authHeader() })
if (!res.ok) throw new Error(t('common.loadFailed'))
status.value = await res.json()
} catch (e: any) {
error.value = e.message
}
}
async function fetchReservations() {
try {
const res = await fetch('/api/admin/vpn/reservations', { headers: authHeader() })
if (!res.ok) throw new Error(t('common.loadFailed'))
const data = await res.json()
reservations.value = data.reservations
} catch (e: any) {
error.value = e.message
}
}
async function fetchUsers() {
try {
const res = await fetch('/api/admin/users', { headers: authHeader() })
if (!res.ok) throw new Error(t('common.loadFailed'))
const data = await res.json()
users.value = data.users
} catch (e: any) {
error.value = e.message
}
}
async function fetchDiag() {
try {
const res = await fetch('/api/admin/vpn/diag', { headers: authHeader() })
if (!res.ok) throw new Error(t('common.loadFailed'))
diag.value = await res.json()
} catch (e: any) {
error.value = e.message
}
}
async function handleSave() {
saving.value = true
saveMsg.value = ''
try {
const res = await fetch('/api/admin/vpn/settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json', ...authHeader() },
body: JSON.stringify(form.value),
})
const data = await res.json()
if (!res.ok) throw new Error(data.error || t('common.saveFailed'))
saveMsg.value = t('vpn.saveSuccess')
saveOk.value = true
await Promise.all([fetchSettings(), fetchStatus(), fetchDiag()])
} catch (e: any) {
saveMsg.value = e.message
saveOk.value = false
} finally {
saving.value = false
}
}
const showAddResv = ref(false)
const resvForm = ref({ user_id: 0, ip_address: '', ip_address6: '' })
const resvError = ref('')
async function handleAddResv() {
resvError.value = ''
if (!resvForm.value.user_id || (!resvForm.value.ip_address && !resvForm.value.ip_address6)) {
resvError.value = t('vpn.selectUserAndIp')
return
}
try {
const res = await fetch('/api/admin/vpn/reservations', {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...authHeader() },
body: JSON.stringify(resvForm.value),
})
const data = await res.json()
if (!res.ok) throw new Error(data.error || t('common.createFailed'))
showAddResv.value = false
resvForm.value = { user_id: 0, ip_address: '', ip_address6: '' }
await fetchReservations()
} catch (e: any) {
resvError.value = e.message
}
}
async function handleDeleteResv(id: number) {
if (!confirm(t('vpn.confirmDeleteReservation'))) return
try {
const res = await fetch(`/api/admin/vpn/reservations/${id}`, {
method: 'DELETE',
headers: authHeader(),
})
const data = await res.json()
if (!res.ok) throw new Error(data.error || t('common.deleteFailed'))
await fetchReservations()
} catch (e: any) {
error.value = e.message
}
}
function checkTunCreate(): boolean | null {
if (!diag.value) return null
return diag.value.tun_create.startsWith('ok')
}
async function refreshAll() {
loading.value = true
error.value = ''
await Promise.all([fetchSettings(), fetchStatus(), fetchReservations(), fetchUsers(), fetchDiag()])
loading.value = false
}
onMounted(() => {
refreshAll()
})
</script>
<template>
<div class="max-w-6xl mx-auto px-4 py-8 space-y-8">
<div class="flex items-center justify-between">
<h2 class="text-2xl font-bold text-gray-900 dark:text-white">{{ t('vpn.title') }}</h2>
<button
class="px-4 py-2 rounded-lg font-medium text-sm text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors"
@click="refreshAll"
>
{{ t('vpn.refresh') }}
</button>
</div>
<p v-if="error" class="text-red-500">{{ error }}</p>
<!-- 状态 -->
<div class="grid gap-4 sm:grid-cols-4">
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 p-5">
<p class="text-xs text-gray-500 dark:text-gray-400">{{ t('vpn.serviceStatus') }}</p>
<p class="text-xl font-bold mt-1" :class="status?.enabled ? 'text-green-600' : 'text-gray-400'">
{{ status?.enabled ? t('vpn.running') : t('vpn.stopped') }}
</p>
</div>
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 p-5">
<p class="text-xs text-gray-500 dark:text-gray-400">{{ t('vpn.onlineClients') }}</p>
<p class="text-xl font-bold text-gray-900 dark:text-white mt-1">{{ status?.online ?? '--' }}</p>
</div>
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 p-5">
<p class="text-xs text-gray-500 dark:text-gray-400">{{ t('vpn.ipUsage') }}</p>
<p class="text-xl font-bold text-gray-900 dark:text-white mt-1">{{ status?.used_ips ?? '--' }} / {{ status?.capacity ?? '--' }}</p>
</div>
</div>
<!-- 系统环境检测 -->
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 p-6">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">{{ t('vpn.systemCheck') }}</h3>
<div class="grid gap-3 sm:grid-cols-2">
<!-- TUN 创建状态 -->
<div class="flex items-start gap-3 p-3 rounded-lg bg-gray-50 dark:bg-gray-700/30">
<span v-if="diag?.tun_running" class="text-green-500 mt-0.5"></span>
<span v-else-if="checkTunCreate()" class="text-green-500 mt-0.5"></span>
<span v-else class="text-red-500 mt-0.5"></span>
<div>
<p class="text-sm font-medium text-gray-900 dark:text-white">{{ t('vpn.tunDevice') }}</p>
<p class="text-xs text-gray-500 dark:text-gray-400">
<template v-if="diag?.tun_running">{{ t('vpn.tunRunning') }} ({{ diag.tun_name }})</template>
<template v-else>{{ diag?.tun_create }}</template>
</p>
</div>
</div>
<!-- Root -->
<div class="flex items-start gap-3 p-3 rounded-lg bg-gray-50 dark:bg-gray-700/30">
<span :class="diag?.is_root ? 'text-green-500' : 'text-red-500'" class="mt-0.5">{{ diag?.is_root ? '✓' : '✗' }}</span>
<div>
<p class="text-sm font-medium text-gray-900 dark:text-white">{{ t('vpn.rootPermission') }}</p>
<p class="text-xs text-gray-500 dark:text-gray-400">{{ diag?.is_root ? t('vpn.runningAsRoot') : t('vpn.notRunningAsRoot') }}</p>
</div>
</div>
<!-- CAP_NET_ADMIN -->
<div class="flex items-start gap-3 p-3 rounded-lg bg-gray-50 dark:bg-gray-700/30">
<span v-if="diag?.has_cap_net_admin === null" class="text-gray-400 mt-0.5"></span>
<span v-else-if="diag?.has_cap_net_admin" class="text-green-500 mt-0.5"></span>
<span v-else class="text-red-500 mt-0.5"></span>
<div>
<p class="text-sm font-medium text-gray-900 dark:text-white">{{ t('vpn.capNetAdmin') }}</p>
<p class="text-xs text-gray-500 dark:text-gray-400">
<template v-if="diag?.has_cap_net_admin === null">{{ diag?.cap_net_admin_note }}</template>
<template v-else>{{ diag?.has_cap_net_admin ? t('vpn.authorized') : diag?.cap_net_admin_note || t('vpn.unauthorized') }}</template>
</p>
</div>
</div>
<!-- IP Forward -->
<div class="flex items-start gap-3 p-3 rounded-lg bg-gray-50 dark:bg-gray-700/30">
<span v-if="diag?.ip_forward === null" class="text-gray-400 mt-0.5"></span>
<span v-else-if="diag?.ip_forward" class="text-green-500 mt-0.5"></span>
<span v-else class="text-red-500 mt-0.5"></span>
<div>
<p class="text-sm font-medium text-gray-900 dark:text-white">{{ t('vpn.ipForward') }}</p>
<p class="text-xs text-gray-500 dark:text-gray-400">
<template v-if="diag?.ip_forward === null">{{ diag?.ip_forward_note }}</template>
<template v-else>{{ diag?.ip_forward ? t('common.enabled') : diag?.ip_forward_note || t('vpn.notEnabled') }}</template>
</p>
</div>
</div>
<!-- MASQUERADE -->
<div class="flex items-start gap-3 p-3 rounded-lg bg-gray-50 dark:bg-gray-700/30">
<span v-if="diag?.masquerade === null" class="text-gray-400 mt-0.5"></span>
<span v-else-if="diag?.masquerade" class="text-green-500 mt-0.5"></span>
<span v-else class="text-red-500 mt-0.5"></span>
<div>
<p class="text-sm font-medium text-gray-900 dark:text-white">{{ t('vpn.natMasqueradeV4') }}</p>
<p class="text-xs text-gray-500 dark:text-gray-400">
<template v-if="diag?.masquerade === null">{{ diag?.masquerade_note }}</template>
<template v-else>{{ diag?.masquerade ? t('vpn.configured') : diag?.masquerade_note || t('vpn.notConfigured') }}</template>
</p>
</div>
</div>
<!-- IPv6 Forward -->
<div class="flex items-start gap-3 p-3 rounded-lg bg-gray-50 dark:bg-gray-700/30">
<span v-if="diag?.ip6_forward === null" class="text-gray-400 mt-0.5"></span>
<span v-else-if="diag?.ip6_forward" class="text-green-500 mt-0.5"></span>
<span v-else class="text-red-500 mt-0.5"></span>
<div>
<p class="text-sm font-medium text-gray-900 dark:text-white">{{ t('vpn.ipv6Forward') }}</p>
<p class="text-xs text-gray-500 dark:text-gray-400">
<template v-if="diag?.ip6_forward === null">{{ diag?.ip6_forward_note }}</template>
<template v-else>{{ diag?.ip6_forward ? t('common.enabled') : diag?.ip6_forward_note || t('vpn.notEnabled') }}</template>
</p>
</div>
</div>
<!-- IPv6 MASQUERADE -->
<div class="flex items-start gap-3 p-3 rounded-lg bg-gray-50 dark:bg-gray-700/30">
<span v-if="diag?.masquerade6 === null" class="text-gray-400 mt-0.5"></span>
<span v-else-if="diag?.masquerade6" class="text-green-500 mt-0.5"></span>
<span v-else class="text-red-500 mt-0.5"></span>
<div>
<p class="text-sm font-medium text-gray-900 dark:text-white">{{ t('vpn.natMasqueradeV6') }}</p>
<p class="text-xs text-gray-500 dark:text-gray-400">
<template v-if="diag?.masquerade6 === null">{{ diag?.masquerade6_note }}</template>
<template v-else>{{ diag?.masquerade6 ? t('vpn.configured') : diag?.masquerade6_note || t('vpn.notConfigured') }}</template>
</p>
</div>
</div>
<!-- 平台 -->
<div class="flex items-start gap-3 p-3 rounded-lg bg-gray-50 dark:bg-gray-700/30">
<span class="text-gray-400 mt-0.5"></span>
<div>
<p class="text-sm font-medium text-gray-900 dark:text-white">{{ t('vpn.platform') }}</p>
<p class="text-xs text-gray-500 dark:text-gray-400">{{ diag?.platform }}</p>
</div>
</div>
</div>
<p v-if="diag && diag.platform !== 'linux'" class="text-xs text-gray-400 mt-3">
{{ t('vpn.nonLinuxNote') }}
</p>
</div>
<!-- 设置 -->
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 p-6">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">{{ t('vpn.tunnelSettings') }}</h3>
<div class="grid gap-4 sm:grid-cols-2">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ t('vpn.enableVpn') }}</label>
<select v-model="form.enabled" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white">
<option :value="true">{{ t('vpn.enable') }}</option>
<option :value="false">{{ t('vpn.stop') }}</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ t('vpn.subnet') }}</label>
<input v-model="form.subnet" type="text" placeholder="192.168.77.0/24" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ t('vpn.ipv6Subnet') }}</label>
<input v-model="form.subnet6" type="text" placeholder="fd00:dead:beef::/112" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ t('vpn.mtu') }}</label>
<input v-model.number="form.mtu" type="number" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ t('vpn.interfaceName') }}</label>
<input v-model="form.interface_name" type="text" placeholder="tun0" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ t('vpn.allowClientToClient') }}</label>
<select v-model="form.allow_client_to_client" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white">
<option :value="false">{{ t('vpn.deny') }}</option>
<option :value="true">{{ t('vpn.allow') }}</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ t('vpn.serverConfigTunIp') }}</label>
<select v-model="form.do_local_ip_config" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white">
<option :value="true">{{ t('vpn.autoConfig') }}</option>
<option :value="false">{{ t('vpn.manual') }}</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ t('vpn.maxConnsPerUser') }}</label>
<input v-model.number="form.max_conns_per_user" type="number" min="1" max="1000" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white" />
</div>
</div>
<div class="flex items-center gap-4 mt-6">
<button
class="px-4 py-2 rounded-lg font-medium text-sm text-white bg-sky-600 hover:bg-sky-700 disabled:opacity-50 transition-colors"
:disabled="saving"
@click="handleSave"
>
{{ saving ? t('common.saving') : t('vpn.saveSettings') }}
</button>
<span v-if="saveMsg" :class="saveOk ? 'text-green-500' : 'text-red-500'" class="text-sm">{{ saveMsg }}</span>
</div>
</div>
<!-- 静态预留 -->
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden">
<div class="flex items-center justify-between p-6 pb-4">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">{{ t('vpn.staticIpReservation') }}</h3>
<button class="px-3 py-1.5 text-xs rounded-md font-medium text-white bg-sky-600 hover:bg-sky-700 transition-colors" @click="showAddResv = true">{{ t('vpn.addReservation') }}</button>
</div>
<table class="w-full text-sm">
<thead>
<tr class="border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/50">
<th class="px-6 py-3 text-left font-medium text-gray-500 dark:text-gray-400">{{ t('vpn.user') }}</th>
<th class="px-6 py-3 text-left font-medium text-gray-500 dark:text-gray-400">{{ t('vpn.ipv4') }}</th>
<th class="px-6 py-3 text-left font-medium text-gray-500 dark:text-gray-400">{{ t('vpn.ipv6') }}</th>
<th class="px-6 py-3 text-left font-medium text-gray-500 dark:text-gray-400">{{ t('common.createdAt') }}</th>
<th class="px-6 py-3 text-left font-medium text-gray-500 dark:text-gray-400">{{ t('common.actions') }}</th>
</tr>
</thead>
<tbody>
<tr v-if="!reservations.length">
<td colspan="5" class="px-6 py-6 text-center text-gray-400">{{ t('vpn.noReservations') }}</td>
</tr>
<tr v-for="r in reservations" :key="r.id" class="border-b border-gray-100 dark:border-gray-700/50">
<td class="px-6 py-3 text-gray-900 dark:text-white font-medium">{{ r.username }}</td>
<td class="px-6 py-3 text-gray-700 dark:text-gray-300">{{ r.ip_address || '—' }}</td>
<td class="px-6 py-3 text-gray-700 dark:text-gray-300">{{ r.ip_address6 || '—' }}</td>
<td class="px-6 py-3 text-gray-500 dark:text-gray-400">{{ r.created_at }}</td>
<td class="px-6 py-3">
<button class="px-3 py-1 text-xs rounded-md font-medium text-red-700 bg-red-50 hover:bg-red-100 dark:text-red-400 dark:bg-red-900/20 transition-colors" @click="handleDeleteResv(r.id)">{{ t('common.delete') }}</button>
</td>
</tr>
</tbody>
</table>
</div>
<!-- 新增预留弹窗 -->
<div v-if="showAddResv" class="fixed inset-0 z-50 flex items-center justify-center bg-black/50" @click.self="showAddResv = false">
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-xl w-full max-w-md mx-4 p-6">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">{{ t('vpn.addIpReservation') }}</h3>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ t('vpn.user') }}</label>
<select v-model.number="resvForm.user_id" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white">
<option :value="0" disabled>{{ t('vpn.selectUser') }}</option>
<option v-for="u in users" :key="u.id" :value="u.id">{{ u.username }}</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ t('vpn.ipv4Address') }}</label>
<input v-model="resvForm.ip_address" type="text" placeholder="192.168.77.10" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ t('vpn.ipv6Address') }}</label>
<input v-model="resvForm.ip_address6" type="text" placeholder="fd00:dead:beef::10" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white" />
</div>
<p v-if="resvError" class="text-sm text-red-500">{{ resvError }}</p>
</div>
<div class="flex justify-end gap-3 mt-6">
<button class="px-4 py-2 rounded-lg text-sm font-medium text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors" @click="showAddResv = false">{{ t('common.cancel') }}</button>
<button class="px-4 py-2 rounded-lg text-sm font-medium text-white bg-sky-600 hover:bg-sky-700 transition-colors" @click="handleAddResv">{{ t('common.confirm') }}</button>
</div>
</div>
</div>
</div>
</template>