Files
lmvpn_server/frontend/src/components/TrafficChart.vue
T
kevin 50e1661c1b 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 相关国际化
2026-07-10 14:18:06 +08:00

88 lines
1.9 KiB
Vue

<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>