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