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:
@@ -59,6 +59,9 @@ export default {
|
|||||||
enterOldAndNewPassword: 'Please enter current and new password',
|
enterOldAndNewPassword: 'Please enter current and new password',
|
||||||
passwordsDoNotMatch: 'The two passwords do not match',
|
passwordsDoNotMatch: 'The two passwords do not match',
|
||||||
passwordChangeFailed: 'Failed to change password',
|
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: {
|
about: {
|
||||||
title: 'About LmVPN',
|
title: 'About LmVPN',
|
||||||
|
|||||||
@@ -59,6 +59,9 @@ export default {
|
|||||||
enterOldAndNewPassword: '请填写原密码和新密码',
|
enterOldAndNewPassword: '请填写原密码和新密码',
|
||||||
passwordsDoNotMatch: '两次输入的新密码不一致',
|
passwordsDoNotMatch: '两次输入的新密码不一致',
|
||||||
passwordChangeFailed: '密码修改失败',
|
passwordChangeFailed: '密码修改失败',
|
||||||
|
myVpnConnections: '我的 VPN 连接',
|
||||||
|
noConnections: '暂无在线连接',
|
||||||
|
abnormalConnectionHint: '如发现异常连接,请修改密码以断开所有设备',
|
||||||
},
|
},
|
||||||
about: {
|
about: {
|
||||||
title: '关于 LmVPN',
|
title: '关于 LmVPN',
|
||||||
|
|||||||
@@ -8,8 +8,29 @@ const authStore = useAuthStore()
|
|||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const { t } = useI18n()
|
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 () => {
|
onMounted(async () => {
|
||||||
await authStore.fetchUser()
|
await authStore.fetchUser()
|
||||||
|
fetchVpnConnections()
|
||||||
})
|
})
|
||||||
|
|
||||||
const showPasswordModal = ref(false)
|
const showPasswordModal = ref(false)
|
||||||
@@ -73,6 +94,35 @@ async function handleChangePassword() {
|
|||||||
</div>
|
</div>
|
||||||
</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
|
<button
|
||||||
class="px-6 py-2.5 rounded-lg font-medium text-white bg-sky-600 hover:bg-sky-700 transition-colors"
|
class="px-6 py-2.5 rounded-lg font-medium text-white bg-sky-600 hover:bg-sky-700 transition-colors"
|
||||||
@click="openPasswordModal"
|
@click="openPasswordModal"
|
||||||
|
|||||||
@@ -208,6 +208,42 @@ func UpdateVpnSettings(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, gin.H{"message": "设置已更新"})
|
c.JSON(http.StatusOK, gin.H{"message": "设置已更新"})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type myVpnConnection struct {
|
||||||
|
IP string `json:"ip"`
|
||||||
|
IP6 string `json:"ip6,omitempty"`
|
||||||
|
ConnectedAt string `json:"connected_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetMyVpnConnections(c *gin.Context) {
|
||||||
|
userID, _ := c.Get("user_id")
|
||||||
|
|
||||||
|
maxConns := 30
|
||||||
|
if vpn.VPN != nil {
|
||||||
|
s := vpn.VPN.Settings()
|
||||||
|
if s.MaxConnsPerUser > 0 {
|
||||||
|
maxConns = s.MaxConnsPerUser
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
connections := make([]myVpnConnection, 0)
|
||||||
|
if vpn.VPN != nil && vpn.VPN.Running() {
|
||||||
|
for _, ci := range vpn.VPN.ClientList() {
|
||||||
|
if ci.UserID == userID.(uint) {
|
||||||
|
connections = append(connections, myVpnConnection{
|
||||||
|
IP: ci.IP,
|
||||||
|
IP6: ci.IP6,
|
||||||
|
ConnectedAt: ci.ConnectedAt,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"max_conns_per_user": maxConns,
|
||||||
|
"connections": connections,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
type vpnStatusResponse struct {
|
type vpnStatusResponse struct {
|
||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
Online int `json:"online"`
|
Online int `json:"online"`
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ func Setup(r *gin.Engine) {
|
|||||||
auth.PUT("/me/password", handler.ChangePassword)
|
auth.PUT("/me/password", handler.ChangePassword)
|
||||||
auth.GET("/me/sessions", handler.ListMySessions)
|
auth.GET("/me/sessions", handler.ListMySessions)
|
||||||
auth.DELETE("/me/sessions/:sessionId", handler.RevokeMySession)
|
auth.DELETE("/me/sessions/:sessionId", handler.RevokeMySession)
|
||||||
|
auth.GET("/me/vpn/connections", handler.GetMyVpnConnections)
|
||||||
}
|
}
|
||||||
|
|
||||||
admin := r.Group("/api/admin")
|
admin := r.Group("/api/admin")
|
||||||
|
|||||||
Reference in New Issue
Block a user