From 50e1661c1b16336beb1affe494cbbdeaa8f242ea Mon Sep 17 00:00:00 2001 From: kevin Date: Fri, 10 Jul 2026 14:18:06 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=9E=E7=8E=B0=E6=AF=8F=E7=94=A8?= =?UTF-8?q?=E6=88=B7=E6=B5=81=E9=87=8F=E7=BB=9F=E8=AE=A1=E4=B8=8E=E5=AE=9A?= =?UTF-8?q?=E6=97=B6=E8=90=BD=E5=BA=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 流量统计从全站按日聚合升级为 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 相关国际化 --- frontend/package-lock.json | 30 +++ frontend/package.json | 2 + frontend/src/components/TrafficChart.vue | 87 ++++++++ frontend/src/locales/en.ts | 12 ++ frontend/src/locales/zh.ts | 12 ++ frontend/src/views/AdminView.vue | 87 +++++++- frontend/src/views/ProfileView.vue | 51 +++++ internal/db/db.go | 2 +- internal/handler/traffic.go | 255 +++++++++++++++++++++++ internal/model/vpn.go | 13 ++ internal/router/router.go | 6 + internal/vpn/service.go | 63 +++++- internal/vpn/tunnel.go | 29 ++- 13 files changed, 643 insertions(+), 6 deletions(-) create mode 100644 frontend/src/components/TrafficChart.vue create mode 100644 internal/handler/traffic.go diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 8c68359..888662e 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -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", diff --git a/frontend/package.json b/frontend/package.json index 0fdc870..7a33179 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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" }, diff --git a/frontend/src/components/TrafficChart.vue b/frontend/src/components/TrafficChart.vue new file mode 100644 index 0000000..e32faf9 --- /dev/null +++ b/frontend/src/components/TrafficChart.vue @@ -0,0 +1,87 @@ + + + diff --git a/frontend/src/locales/en.ts b/frontend/src/locales/en.ts index cf923aa..f935dc8 100644 --- a/frontend/src/locales/en.ts +++ b/frontend/src/locales/en.ts @@ -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', diff --git a/frontend/src/locales/zh.ts b/frontend/src/locales/zh.ts index eab44ed..226381f 100644 --- a/frontend/src/locales/zh.ts +++ b/frontend/src/locales/zh.ts @@ -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: '刷新', diff --git a/frontend/src/views/AdminView.vue b/frontend/src/views/AdminView.vue index 66e9929..456ddef 100644 --- a/frontend/src/views/AdminView.vue +++ b/frontend/src/views/AdminView.vue @@ -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([]) const kickError = ref('') +interface UserTraffic { + user_id: number + username: string + rx_bytes: number + tx_bytes: number + total_bytes: number +} +const userTrafficToday = ref([]) + +interface TrafficRecord { + date: string + rx_bytes: number + tx_bytes: number +} +const siteTraffic7d = ref([]) + +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) { {{ t('vpn.ipv4') }} {{ t('vpn.ipv6') }} {{ t('vpn.connectTime') }} + {{ t('traffic.upload') }} + {{ t('traffic.download') }} {{ t('common.actions') }} - {{ t('vpn.noOnlineClients') }} + {{ t('vpn.noOnlineClients') }} {{ c.username }} {{ c.ip }} {{ c.ip6 || '-' }} {{ c.connected_at }} + {{ formatBytes(c.rx_bytes) }} + {{ formatBytes(c.tx_bytes) }}