feat: 用户个人页面展示当前 VPN 连接信息

- handler/vpn.go: 新增 GetMyVpnConnections,返回当前用户的在线
  VPN 连接列表(IP/连接时间)及 max_conns_per_user 上限
- router.go: 注册 GET /api/me/vpn/connections(普通用户可访问)
- ProfileView.vue: 新增"我的 VPN 连接"区块,含 n/max 徽章、
  IPv4/IPv6/连接时间表格,底部提示异常连接请修改密码
- zh.ts/en.ts: 新增 myVpnConnections/noConnections/
  abnormalConnectionHint 国际化
This commit is contained in:
2026-07-10 13:53:49 +08:00
parent c63440435e
commit aad8fa9848
5 changed files with 93 additions and 0 deletions
+3
View File
@@ -59,6 +59,9 @@ export default {
enterOldAndNewPassword: 'Please enter current and new password',
passwordsDoNotMatch: 'The two passwords do not match',
passwordChangeFailed: 'Failed to change password',
myVpnConnections: 'My VPN Connections',
noConnections: 'No active connections',
abnormalConnectionHint: 'If you notice any abnormal connections, please change your password to disconnect all devices.',
},
about: {
title: 'About LmVPN',
+3
View File
@@ -59,6 +59,9 @@ export default {
enterOldAndNewPassword: '请填写原密码和新密码',
passwordsDoNotMatch: '两次输入的新密码不一致',
passwordChangeFailed: '密码修改失败',
myVpnConnections: '我的 VPN 连接',
noConnections: '暂无在线连接',
abnormalConnectionHint: '如发现异常连接,请修改密码以断开所有设备',
},
about: {
title: '关于 LmVPN',
+50
View File
@@ -8,8 +8,29 @@ const authStore = useAuthStore()
const router = useRouter()
const { t } = useI18n()
interface VpnConnection {
ip: string
ip6?: string
connected_at: string
}
const vpnConnections = ref<VpnConnection[]>([])
const maxConns = ref(30)
async function fetchVpnConnections() {
try {
const res = await fetch('/api/me/vpn/connections', {
headers: { Authorization: `Bearer ${authStore.token}` },
})
if (!res.ok) return
const data = await res.json()
vpnConnections.value = data.connections || []
maxConns.value = data.max_conns_per_user || 30
} catch {}
}
onMounted(async () => {
await authStore.fetchUser()
fetchVpnConnections()
})
const showPasswordModal = ref(false)
@@ -73,6 +94,35 @@ async function handleChangePassword() {
</div>
</div>
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden mb-6">
<div class="flex items-center justify-between p-6 pb-4">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">{{ t('profile.myVpnConnections') }}</h3>
<span class="px-3 py-1 text-sm font-medium rounded-full" :class="vpnConnections.length > 0 ? 'bg-sky-100 text-sky-700 dark:bg-sky-900/40 dark:text-sky-400' : 'bg-gray-100 text-gray-500 dark:bg-gray-700 dark:text-gray-400'">
{{ vpnConnections.length }} / {{ maxConns }}
</span>
</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.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>
</tr>
</thead>
<tbody>
<tr v-if="!vpnConnections.length">
<td colspan="3" class="px-6 py-6 text-center text-gray-400">{{ t('profile.noConnections') }}</td>
</tr>
<tr v-for="(c, i) in vpnConnections" :key="i" class="border-b border-gray-100 dark:border-gray-700/50">
<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>
</tr>
</tbody>
</table>
<p class="text-xs text-gray-400 px-6 py-4">{{ t('profile.abnormalConnectionHint') }}</p>
</div>
<button
class="px-6 py-2.5 rounded-lg font-medium text-white bg-sky-600 hover:bg-sky-700 transition-colors"
@click="openPasswordModal"