refactor: 在线客户端卡片移至管理后台首页,移除 body 退出按钮
- AdminView.vue: 新增 fetchVpnStatus/handleKick,用户信息卡片下方 插入在线客户端表格(含踢下线按钮),30s 轮询同步刷新; 删除 body 退出登录按钮及 handleLogout(header 已有退出入口) - VpnView.vue: 移除在线客户端卡片及 handleKick(已迁移至 AdminView)
This commit is contained in:
@@ -19,6 +19,16 @@ const stats = ref([
|
||||
const userCount = ref<number | null>(null)
|
||||
let statsTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
interface ClientInfo {
|
||||
user_id: number
|
||||
username: string
|
||||
ip: string
|
||||
ip6?: string
|
||||
connected_at: string
|
||||
}
|
||||
const vpnClients = ref<ClientInfo[]>([])
|
||||
const kickError = ref('')
|
||||
|
||||
function formatUptime(seconds: number): string {
|
||||
if (seconds <= 0) return '0m'
|
||||
const d = Math.floor(seconds / 86400)
|
||||
@@ -61,11 +71,42 @@ async function fetchStats() {
|
||||
} catch {}
|
||||
}
|
||||
|
||||
async function fetchVpnStatus() {
|
||||
try {
|
||||
const res = await fetch('/api/admin/vpn/status', {
|
||||
headers: { Authorization: `Bearer ${authStore.token}` },
|
||||
})
|
||||
if (!res.ok) return
|
||||
const data = await res.json()
|
||||
vpnClients.value = data.clients || []
|
||||
} catch {}
|
||||
}
|
||||
|
||||
async function handleKick(userId: number, username: string) {
|
||||
if (!confirm(t('vpn.confirmKick', { username }))) return
|
||||
kickError.value = ''
|
||||
try {
|
||||
const res = await fetch(`/api/admin/vpn/clients/${userId}`, {
|
||||
method: 'DELETE',
|
||||
headers: { Authorization: `Bearer ${authStore.token}` },
|
||||
})
|
||||
const data = await res.json()
|
||||
if (!res.ok) throw new Error(data.error || t('vpn.kickFailed'))
|
||||
await fetchVpnStatus()
|
||||
} catch (e: any) {
|
||||
kickError.value = e.message
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await authStore.fetchUser()
|
||||
fetchUserCount()
|
||||
fetchStats()
|
||||
statsTimer = setInterval(fetchStats, 30000)
|
||||
fetchVpnStatus()
|
||||
statsTimer = setInterval(() => {
|
||||
fetchStats()
|
||||
fetchVpnStatus()
|
||||
}, 30000)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
@@ -75,11 +116,6 @@ onUnmounted(() => {
|
||||
function handleStatClick(route: string) {
|
||||
if (route) router.push(route)
|
||||
}
|
||||
|
||||
function handleLogout() {
|
||||
authStore.logout()
|
||||
router.push('/')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -118,11 +154,39 @@ function handleLogout() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
class="px-6 py-2.5 rounded-lg font-medium text-white bg-red-500 hover:bg-red-600 transition-colors"
|
||||
@click="handleLogout"
|
||||
>
|
||||
{{ t('admin.logoutButton') }}
|
||||
</button>
|
||||
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden mb-6">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white p-6 pb-4">{{ t('vpn.onlineClients') }}</h3>
|
||||
<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('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="!vpnClients.length">
|
||||
<td colspan="5" class="px-6 py-6 text-center text-gray-400">{{ t('vpn.noOnlineClients') }}</td>
|
||||
</tr>
|
||||
<tr v-for="(c, i) in vpnClients" :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>
|
||||
<p v-if="kickError" class="text-sm text-red-500 px-6 pb-4">{{ kickError }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -202,21 +202,6 @@ 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')
|
||||
@@ -431,41 +416,6 @@ onMounted(() => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 在线客户端 -->
|
||||
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white p-6 pb-4">{{ t('vpn.onlineClients') }}</h3>
|
||||
<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('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="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>
|
||||
</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">
|
||||
|
||||
Reference in New Issue
Block a user