forked from kevin/lmvpn_server
流量统计从全站按日聚合升级为 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 相关国际化
88 lines
1.9 KiB
Vue
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>
|