feat: 实现每用户流量统计与定时落库
流量统计从全站按日聚合升级为 per-user 维度,并引入 60 秒定时 落库机制,服务崩溃最多丢失 60 秒数据。 后端: - model/vpn.go: 新增 UserTrafficStat 模型(user_id + date 联合唯一索引) - db/db.go: AutoMigrate 注册新模型 - vpn/tunnel.go: tunnelConn 增加 flushedRx/flushedTx 快照字段 + flushDelta() 增量计算;recordTraffic 改为 per-user 双写 (user_traffic_stats + traffic_stats);连接断开 defer 改增量落库 - vpn/service.go: 新增 flushDone 字段 + trafficFlusher(60s 定时 落库)+ flushAllTraffic;Stop() 先停 flusher 再最终 flush 全部 在线连接增量;TotalLiveTraffic 改返回未落库增量避免与 DB 重复; 新增 UserLiveTraffic(userID);ClientInfo 增加 RxBytes/TxBytes - handler/traffic.go: 新建 5 个 handler(admin 3 个 + user 2 个) - router.go: 注册 5 条新路由 新增 API: - GET /api/admin/traffic/today 所有用户今日流量排行 - GET /api/admin/traffic/history?days=N 全站近 N 天流量历史 - GET /api/admin/traffic/users/:id?days=N 指定用户近 N 天流量 - GET /api/me/traffic/today 自己的今日流量 - GET /api/me/traffic?days=N 自己的近 N 天流量 前端: - 安装 chart.js + vue-chartjs - 新建 TrafficChart.vue 可复用柱状图组件(上行/下行双柱) - AdminView.vue: 在线客户端表格增加 RX/TX 列 + 全站 7 天流量 图表 + 用户今日流量排行表 - ProfileView.vue: 新增流量统计卡片(今日上行/下行/合计)+ 7 天 流量柱状图 - zh.ts/en.ts: 新增 traffic 相关国际化
This commit is contained in:
Generated
+30
@@ -8,8 +8,10 @@
|
||||
"name": "frontend",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"chart.js": "^4.5.1",
|
||||
"pinia": "^3.0.4",
|
||||
"vue": "^3.5.38",
|
||||
"vue-chartjs": "^5.3.4",
|
||||
"vue-i18n": "^11.4.6",
|
||||
"vue-router": "^5.1.0"
|
||||
},
|
||||
@@ -625,6 +627,12 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@kurkle/color": {
|
||||
"version": "0.3.4",
|
||||
"resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz",
|
||||
"integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@napi-rs/wasm-runtime": {
|
||||
"version": "1.1.6",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz",
|
||||
@@ -1734,6 +1742,18 @@
|
||||
],
|
||||
"license": "CC-BY-4.0"
|
||||
},
|
||||
"node_modules/chart.js": {
|
||||
"version": "4.5.1",
|
||||
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz",
|
||||
"integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@kurkle/color": "^0.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"pnpm": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/chokidar": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz",
|
||||
@@ -3405,6 +3425,16 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/vue-chartjs": {
|
||||
"version": "5.3.4",
|
||||
"resolved": "https://registry.npmjs.org/vue-chartjs/-/vue-chartjs-5.3.4.tgz",
|
||||
"integrity": "sha512-x3Fqob8RQvrTdssfi9ecsCzEkFOd8JPmNwSkSQzdfKj/uBsRJs/Y88cZcZIEcPsTVfMGwMo4MOoihoDG2DoE/g==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"chart.js": "^4.1.1",
|
||||
"vue": "^3.0.0-0 || ^2.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vue-i18n": {
|
||||
"version": "11.4.6",
|
||||
"resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-11.4.6.tgz",
|
||||
|
||||
@@ -11,8 +11,10 @@
|
||||
"type-check": "vue-tsc --build"
|
||||
},
|
||||
"dependencies": {
|
||||
"chart.js": "^4.5.1",
|
||||
"pinia": "^3.0.4",
|
||||
"vue": "^3.5.38",
|
||||
"vue-chartjs": "^5.3.4",
|
||||
"vue-i18n": "^11.4.6",
|
||||
"vue-router": "^5.1.0"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { Bar } from 'vue-chartjs'
|
||||
import {
|
||||
Chart as ChartJS,
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
BarElement,
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend,
|
||||
} from 'chart.js'
|
||||
|
||||
ChartJS.register(CategoryScale, LinearScale, BarElement, Title, Tooltip, Legend)
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
interface TrafficRecord {
|
||||
date: string
|
||||
rx_bytes: number
|
||||
tx_bytes: number
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
records: TrafficRecord[]
|
||||
}>()
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes === 0) return '0 B'
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(1024))
|
||||
return (bytes / Math.pow(1024, i)).toFixed(i > 0 ? 1 : 0) + ' ' + units[i]
|
||||
}
|
||||
|
||||
const chartData = computed(() => ({
|
||||
labels: props.records.map(r => r.date.slice(5)),
|
||||
datasets: [
|
||||
{
|
||||
label: t('traffic.upload'),
|
||||
data: props.records.map(r => r.rx_bytes),
|
||||
backgroundColor: 'rgba(14, 165, 233, 0.6)',
|
||||
borderColor: 'rgba(14, 165, 233, 1)',
|
||||
borderWidth: 1,
|
||||
},
|
||||
{
|
||||
label: t('traffic.download'),
|
||||
data: props.records.map(r => r.tx_bytes),
|
||||
backgroundColor: 'rgba(34, 197, 94, 0.6)',
|
||||
borderColor: 'rgba(34, 197, 94, 1)',
|
||||
borderWidth: 1,
|
||||
},
|
||||
],
|
||||
}))
|
||||
|
||||
const chartOptions = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'top' as const,
|
||||
},
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: (context: any) => {
|
||||
const label = context.dataset.label || ''
|
||||
return `${label}: ${formatBytes(context.parsed.y)}`
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
ticks: {
|
||||
callback: (value: any) => formatBytes(Number(value)),
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="h-64">
|
||||
<Bar :data="chartData" :options="chartOptions" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -95,6 +95,18 @@ export default {
|
||||
'Are you sure you want to delete user {username}? This action cannot be undone.',
|
||||
confirmDeleteButton: 'Confirm Delete',
|
||||
},
|
||||
traffic: {
|
||||
myTraffic: 'My Traffic',
|
||||
userTrafficToday: "User Traffic Today",
|
||||
todayTraffic: "Today's Traffic",
|
||||
upload: 'Upload',
|
||||
download: 'Download',
|
||||
total: 'Total',
|
||||
history: 'Traffic History',
|
||||
date: 'Date',
|
||||
noTrafficData: 'No traffic data',
|
||||
trafficHistory7d: 'Traffic - Last 7 Days',
|
||||
},
|
||||
vpn: {
|
||||
title: 'VPN Management',
|
||||
refresh: 'Refresh',
|
||||
|
||||
@@ -94,6 +94,18 @@ export default {
|
||||
confirmDeleteMessage: '确定要删除用户 {username} 吗?此操作不可撤销。',
|
||||
confirmDeleteButton: '确认删除',
|
||||
},
|
||||
traffic: {
|
||||
myTraffic: '我的流量统计',
|
||||
userTrafficToday: '用户今日流量',
|
||||
todayTraffic: '今日流量',
|
||||
upload: '上行',
|
||||
download: '下行',
|
||||
total: '合计',
|
||||
history: '流量历史',
|
||||
date: '日期',
|
||||
noTrafficData: '暂无流量数据',
|
||||
trafficHistory7d: '近 7 天流量',
|
||||
},
|
||||
vpn: {
|
||||
title: 'VPN 管理',
|
||||
refresh: '刷新',
|
||||
|
||||
@@ -3,6 +3,7 @@ import { onMounted, onUnmounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import TrafficChart from '@/components/TrafficChart.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
@@ -25,10 +26,35 @@ interface ClientInfo {
|
||||
ip: string
|
||||
ip6?: string
|
||||
connected_at: string
|
||||
rx_bytes: number
|
||||
tx_bytes: number
|
||||
}
|
||||
const vpnClients = ref<ClientInfo[]>([])
|
||||
const kickError = ref('')
|
||||
|
||||
interface UserTraffic {
|
||||
user_id: number
|
||||
username: string
|
||||
rx_bytes: number
|
||||
tx_bytes: number
|
||||
total_bytes: number
|
||||
}
|
||||
const userTrafficToday = ref<UserTraffic[]>([])
|
||||
|
||||
interface TrafficRecord {
|
||||
date: string
|
||||
rx_bytes: number
|
||||
tx_bytes: number
|
||||
}
|
||||
const siteTraffic7d = ref<TrafficRecord[]>([])
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes === 0) return '0 B'
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(1024))
|
||||
return (bytes / Math.pow(1024, i)).toFixed(i > 0 ? 1 : 0) + ' ' + units[i]
|
||||
}
|
||||
|
||||
function formatUptime(seconds: number): string {
|
||||
if (seconds <= 0) return '0m'
|
||||
const d = Math.floor(seconds / 86400)
|
||||
@@ -82,6 +108,28 @@ async function fetchVpnStatus() {
|
||||
} catch {}
|
||||
}
|
||||
|
||||
async function fetchTrafficToday() {
|
||||
try {
|
||||
const res = await fetch('/api/admin/traffic/today', {
|
||||
headers: { Authorization: `Bearer ${authStore.token}` },
|
||||
})
|
||||
if (!res.ok) return
|
||||
const data = await res.json()
|
||||
userTrafficToday.value = (data.users || []).sort((a: UserTraffic, b: UserTraffic) => b.total_bytes - a.total_bytes)
|
||||
} catch {}
|
||||
}
|
||||
|
||||
async function fetchSiteTraffic7d() {
|
||||
try {
|
||||
const res = await fetch('/api/admin/traffic/history?days=7', {
|
||||
headers: { Authorization: `Bearer ${authStore.token}` },
|
||||
})
|
||||
if (!res.ok) return
|
||||
const data = await res.json()
|
||||
siteTraffic7d.value = data.records || []
|
||||
} catch {}
|
||||
}
|
||||
|
||||
async function handleKick(userId: number, username: string) {
|
||||
kickError.value = ''
|
||||
if (userId === authStore.user?.id) {
|
||||
@@ -107,9 +155,12 @@ onMounted(async () => {
|
||||
fetchUserCount()
|
||||
fetchStats()
|
||||
fetchVpnStatus()
|
||||
fetchTrafficToday()
|
||||
fetchSiteTraffic7d()
|
||||
statsTimer = setInterval(() => {
|
||||
fetchStats()
|
||||
fetchVpnStatus()
|
||||
fetchTrafficToday()
|
||||
}, 30000)
|
||||
})
|
||||
|
||||
@@ -167,18 +218,22 @@ function handleStatClick(route: string) {
|
||||
<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-right font-medium text-gray-500 dark:text-gray-400">{{ t('traffic.upload') }}</th>
|
||||
<th class="px-6 py-3 text-right font-medium text-gray-500 dark:text-gray-400">{{ t('traffic.download') }}</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>
|
||||
<td colspan="7" 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 text-right text-gray-700 dark:text-gray-300 tabular-nums">{{ formatBytes(c.rx_bytes) }}</td>
|
||||
<td class="px-6 py-3 text-right text-gray-700 dark:text-gray-300 tabular-nums">{{ formatBytes(c.tx_bytes) }}</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"
|
||||
@@ -192,5 +247,35 @@ function handleStatClick(route: string) {
|
||||
</table>
|
||||
<p v-if="kickError" class="text-sm text-red-500 px-6 pb-4">{{ kickError }}</p>
|
||||
</div>
|
||||
|
||||
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 p-6 mb-6">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">{{ t('traffic.trafficHistory7d') }}</h3>
|
||||
<TrafficChart :records="siteTraffic7d" />
|
||||
</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('traffic.userTrafficToday') }}</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('common.username') }}</th>
|
||||
<th class="px-6 py-3 text-right font-medium text-gray-500 dark:text-gray-400">{{ t('traffic.upload') }}</th>
|
||||
<th class="px-6 py-3 text-right font-medium text-gray-500 dark:text-gray-400">{{ t('traffic.download') }}</th>
|
||||
<th class="px-6 py-3 text-right font-medium text-gray-500 dark:text-gray-400">{{ t('traffic.total') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-if="!userTrafficToday.length">
|
||||
<td colspan="4" class="px-6 py-6 text-center text-gray-400">{{ t('traffic.noTrafficData') }}</td>
|
||||
</tr>
|
||||
<tr v-for="(u, i) in userTrafficToday" :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">{{ u.username }}</td>
|
||||
<td class="px-6 py-3 text-right text-gray-700 dark:text-gray-300 tabular-nums">{{ formatBytes(u.rx_bytes) }}</td>
|
||||
<td class="px-6 py-3 text-right text-gray-700 dark:text-gray-300 tabular-nums">{{ formatBytes(u.tx_bytes) }}</td>
|
||||
<td class="px-6 py-3 text-right text-gray-900 dark:text-white font-medium tabular-nums">{{ formatBytes(u.total_bytes) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import TrafficChart from '@/components/TrafficChart.vue'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const router = useRouter()
|
||||
@@ -16,6 +17,35 @@ interface VpnConnection {
|
||||
const vpnConnections = ref<VpnConnection[]>([])
|
||||
const maxConns = ref(30)
|
||||
|
||||
interface TrafficRecord {
|
||||
date: string
|
||||
rx_bytes: number
|
||||
tx_bytes: number
|
||||
}
|
||||
const myTraffic7d = ref<TrafficRecord[]>([])
|
||||
const todayRx = ref(0)
|
||||
const todayTx = ref(0)
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes === 0) return '0 B'
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(1024))
|
||||
return (bytes / Math.pow(1024, i)).toFixed(i > 0 ? 1 : 0) + ' ' + units[i]
|
||||
}
|
||||
|
||||
async function fetchMyTraffic() {
|
||||
try {
|
||||
const res = await fetch('/api/me/traffic?days=7', {
|
||||
headers: { Authorization: `Bearer ${authStore.token}` },
|
||||
})
|
||||
if (!res.ok) return
|
||||
const data = await res.json()
|
||||
myTraffic7d.value = data.records || []
|
||||
todayRx.value = data.today_rx_bytes || 0
|
||||
todayTx.value = data.today_tx_bytes || 0
|
||||
} catch {}
|
||||
}
|
||||
|
||||
async function fetchVpnConnections() {
|
||||
try {
|
||||
const res = await fetch('/api/me/vpn/connections', {
|
||||
@@ -31,6 +61,7 @@ async function fetchVpnConnections() {
|
||||
onMounted(async () => {
|
||||
await authStore.fetchUser()
|
||||
fetchVpnConnections()
|
||||
fetchMyTraffic()
|
||||
})
|
||||
|
||||
const showPasswordModal = ref(false)
|
||||
@@ -94,6 +125,26 @@ 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 p-6 mb-6">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">{{ t('traffic.myTraffic') }}</h3>
|
||||
<div class="grid grid-cols-3 gap-4 mb-6">
|
||||
<div class="text-center">
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mb-1">{{ t('traffic.upload') }}</p>
|
||||
<p class="text-lg font-bold text-sky-600 dark:text-sky-400 tabular-nums">{{ formatBytes(todayRx) }}</p>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mb-1">{{ t('traffic.download') }}</p>
|
||||
<p class="text-lg font-bold text-green-600 dark:text-green-400 tabular-nums">{{ formatBytes(todayTx) }}</p>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mb-1">{{ t('traffic.total') }}</p>
|
||||
<p class="text-lg font-bold text-gray-900 dark:text-white tabular-nums">{{ formatBytes(todayRx + todayTx) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<h4 class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">{{ t('traffic.trafficHistory7d') }}</h4>
|
||||
<TrafficChart :records="myTraffic7d" />
|
||||
</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>
|
||||
|
||||
Reference in New Issue
Block a user