feat: 新增管理员踢下线 VPN 客户端功能

- vpn/service.go: ClientInfo 增加 user_id 字段,新增 KickUser 方法
  用读锁收集目标连接后逐个关闭 WebSocket,返回断开数量
- vpn/tunnel.go: info() 填充 UserID
- handler/vpn.go: 新增 KickUserClient handler,校验用户存在后调用 KickUser
- router.go: 注册 DELETE /api/admin/vpn/clients/:id
- VpnView.vue: 在线客户端表格新增操作列与踢下线按钮,handleKick 确认后调用 API 并刷新
- zh.ts/en.ts: 新增 kick/confirmKick/kickFailed 国际化 key
This commit is contained in:
2026-07-10 13:01:26 +08:00
parent 129c6c7a96
commit fea3fc62f7
7 changed files with 73 additions and 1 deletions
+3
View File
@@ -148,6 +148,9 @@ export default {
ipv6Address: 'IPv6 Address (optional)',
selectUserAndIp: 'Please select a user and fill in at least one IP address',
confirmDeleteReservation: 'Delete this reservation?',
kick: 'Kick',
confirmKick: 'Disconnect all VPN connections for user {username}?',
kickFailed: 'Failed to kick user',
},
footer: {
lastCommit: 'Last commit',
+3
View File
@@ -147,6 +147,9 @@ export default {
ipv6Address: 'IPv6 地址 (可选)',
selectUserAndIp: '请选择用户并至少填写一个 IP 地址',
confirmDeleteReservation: '确认删除该预留?',
kick: '踢下线',
confirmKick: '确定要断开用户 {username} 的所有 VPN 连接吗?',
kickFailed: '踢下线失败',
},
footer: {
lastCommit: '最后提交',
+26 -1
View File
@@ -18,6 +18,7 @@ interface Settings {
do_remote_ip_config: boolean
}
interface ClientInfo {
user_id: number
username: string
ip: string
ip6?: string
@@ -201,6 +202,21 @@ async function handleDeleteResv(id: number) {
}
}
async function handleKick(userId: number, username: string) {
if (!confirm(t('vpn.confirmKick', { username }))) return
try {
const res = await fetch(`/api/admin/vpn/clients/${userId}`, {
method: 'DELETE',
headers: authHeader(),
})
const data = await res.json()
if (!res.ok) throw new Error(data.error || t('vpn.kickFailed'))
await fetchStatus()
} catch (e: any) {
error.value = e.message
}
}
function checkTunCreate(): boolean | null {
if (!diag.value) return null
return diag.value.tun_create.startsWith('ok')
@@ -425,17 +441,26 @@ onMounted(() => {
<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('vpn.connectTime') }}</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="!status?.clients?.length">
<td colspan="4" class="px-6 py-6 text-center text-gray-400">{{ t('vpn.noOnlineClients') }}</td>
<td colspan="5" class="px-6 py-6 text-center text-gray-400">{{ t('vpn.noOnlineClients') }}</td>
</tr>
<tr v-for="(c, i) in status?.clients" :key="i" 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">{{ c.username }}</td>
<td class="px-6 py-3 text-gray-700 dark:text-gray-300">{{ c.ip }}</td>
<td class="px-6 py-3 text-gray-700 dark:text-gray-300">{{ c.ip6 || '—' }}</td>
<td class="px-6 py-3 text-gray-500 dark:text-gray-400">{{ c.connected_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="handleKick(c.user_id, c.username)"
>
{{ t('vpn.kick') }}
</button>
</td>
</tr>
</tbody>
</table>