将 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: 更新常量表为可配置项
488 lines
22 KiB
Vue
488 lines
22 KiB
Vue
<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>
|