feat(app): 添加流量统计功能和服务器配置管理
All checks were successful
Build Multi-Platform Binaries / build-frontend (push) Successful in 38s
Build Multi-Platform Binaries / build-binaries (amd64, linux, client, true) (push) Successful in 1m47s
Build Multi-Platform Binaries / build-binaries (amd64, darwin, server, false) (push) Successful in 1m48s
Build Multi-Platform Binaries / build-binaries (amd64, windows, client, true) (push) Successful in 1m29s
Build Multi-Platform Binaries / build-binaries (amd64, linux, server, true) (push) Successful in 1m53s
Build Multi-Platform Binaries / build-binaries (arm, 7, linux, client, true) (push) Successful in 1m14s
Build Multi-Platform Binaries / build-binaries (amd64, windows, server, true) (push) Successful in 1m40s
Build Multi-Platform Binaries / build-binaries (arm64, darwin, server, false) (push) Successful in 1m36s
Build Multi-Platform Binaries / build-binaries (arm, 7, linux, server, true) (push) Successful in 1m51s
Build Multi-Platform Binaries / build-binaries (arm64, linux, client, true) (push) Successful in 1m17s
Build Multi-Platform Binaries / build-binaries (arm64, linux, server, true) (push) Successful in 1m50s
Build Multi-Platform Binaries / build-binaries (arm64, windows, server, false) (push) Successful in 1m18s
All checks were successful
Build Multi-Platform Binaries / build-frontend (push) Successful in 38s
Build Multi-Platform Binaries / build-binaries (amd64, linux, client, true) (push) Successful in 1m47s
Build Multi-Platform Binaries / build-binaries (amd64, darwin, server, false) (push) Successful in 1m48s
Build Multi-Platform Binaries / build-binaries (amd64, windows, client, true) (push) Successful in 1m29s
Build Multi-Platform Binaries / build-binaries (amd64, linux, server, true) (push) Successful in 1m53s
Build Multi-Platform Binaries / build-binaries (arm, 7, linux, client, true) (push) Successful in 1m14s
Build Multi-Platform Binaries / build-binaries (amd64, windows, server, true) (push) Successful in 1m40s
Build Multi-Platform Binaries / build-binaries (arm64, darwin, server, false) (push) Successful in 1m36s
Build Multi-Platform Binaries / build-binaries (arm, 7, linux, server, true) (push) Successful in 1m51s
Build Multi-Platform Binaries / build-binaries (arm64, linux, client, true) (push) Successful in 1m17s
Build Multi-Platform Binaries / build-binaries (arm64, linux, server, true) (push) Successful in 1m50s
Build Multi-Platform Binaries / build-binaries (arm64, windows, server, false) (push) Successful in 1m18s
- 在WebServer中添加TrafficStore存储接口 - 将Web配置从根级别移动到Server.Web子结构下 - 移除Web配置中的BindAddr字段并调整默认值逻辑 - 在前端HomeView中替换模拟流量数据显示真实统计数据 - 添加流量统计API接口(/traffic/stats和/traffic/hourly) - 实现SQLite数据库流量统计表创建和CRUD操作 - 在Relay包中添加带流量统计的数据转发功能 - 在设置页面添加服务器配置编辑和保存功能 - 创建流量统计处理器和相关数据模型定义
This commit is contained in:
@@ -180,3 +180,47 @@ export const createLogStream = (
|
||||
|
||||
return eventSource
|
||||
}
|
||||
|
||||
// 流量统计
|
||||
export interface TrafficStats {
|
||||
traffic_24h: { inbound: number; outbound: number }
|
||||
traffic_total: { inbound: number; outbound: number }
|
||||
}
|
||||
|
||||
export interface TrafficRecord {
|
||||
timestamp: number
|
||||
inbound: number
|
||||
outbound: number
|
||||
}
|
||||
|
||||
export const getTrafficStats = () => get<TrafficStats>('/traffic/stats')
|
||||
export const getTrafficHourly = () => get<{ records: TrafficRecord[] }>('/traffic/hourly')
|
||||
|
||||
// 服务器配置
|
||||
export interface ServerConfigInfo {
|
||||
bind_addr: string
|
||||
bind_port: number
|
||||
token: string
|
||||
heartbeat_sec: number
|
||||
heartbeat_timeout: number
|
||||
}
|
||||
|
||||
export interface WebConfigInfo {
|
||||
enabled: boolean
|
||||
bind_port: number
|
||||
username: string
|
||||
password: string
|
||||
}
|
||||
|
||||
export interface ServerConfigResponse {
|
||||
server: ServerConfigInfo
|
||||
web: WebConfigInfo
|
||||
}
|
||||
|
||||
export interface UpdateServerConfigRequest {
|
||||
server?: Partial<ServerConfigInfo>
|
||||
web?: Partial<WebConfigInfo>
|
||||
}
|
||||
|
||||
export const getServerConfig = () => get<ServerConfigResponse>('/config')
|
||||
export const updateServerConfig = (config: UpdateServerConfigRequest) => put('/config', config)
|
||||
|
||||
@@ -1,33 +1,46 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { getClients } from '../api'
|
||||
import { getClients, getTrafficStats, getTrafficHourly, type TrafficRecord } from '../api'
|
||||
import type { ClientStatus } from '../types'
|
||||
|
||||
const clients = ref<ClientStatus[]>([])
|
||||
|
||||
// Mock data for traffic (API not implemented yet)
|
||||
const trafficStats = ref({
|
||||
inbound: 0,
|
||||
outbound: 0,
|
||||
inboundUnit: 'GB',
|
||||
outboundUnit: 'GB'
|
||||
})
|
||||
// 流量统计数据
|
||||
const traffic24h = ref({ inbound: 0, outbound: 0 })
|
||||
const trafficTotal = ref({ inbound: 0, outbound: 0 })
|
||||
const trafficHistory = ref<TrafficRecord[]>([])
|
||||
|
||||
// Mock 24h traffic data for chart
|
||||
const trafficHistory = ref<Array<{ hour: string; inbound: number; outbound: number }>>([])
|
||||
|
||||
const generateMockTrafficData = () => {
|
||||
const data = []
|
||||
const now = new Date()
|
||||
for (let i = 23; i >= 0; i--) {
|
||||
const hour = new Date(now.getTime() - i * 60 * 60 * 1000)
|
||||
data.push({
|
||||
hour: hour.getHours().toString().padStart(2, '0') + ':00',
|
||||
inbound: Math.random() * 100,
|
||||
outbound: Math.random() * 80
|
||||
})
|
||||
// 格式化字节数
|
||||
const formatBytes = (bytes: number): { value: string; unit: string } => {
|
||||
if (bytes === 0) return { value: '0', unit: 'B' }
|
||||
const k = 1024
|
||||
const sizes: string[] = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||
const i = Math.min(Math.floor(Math.log(bytes) / Math.log(k)), sizes.length - 1)
|
||||
return {
|
||||
value: parseFloat((bytes / Math.pow(k, i)).toFixed(2)).toString(),
|
||||
unit: sizes[i] as string
|
||||
}
|
||||
}
|
||||
|
||||
// 加载流量统计
|
||||
const loadTrafficStats = async () => {
|
||||
try {
|
||||
const { data } = await getTrafficStats()
|
||||
traffic24h.value = data.traffic_24h
|
||||
trafficTotal.value = data.traffic_total
|
||||
} catch (e) {
|
||||
console.error('Failed to load traffic stats', e)
|
||||
}
|
||||
}
|
||||
|
||||
// 加载每小时流量
|
||||
const loadTrafficHourly = async () => {
|
||||
try {
|
||||
const { data } = await getTrafficHourly()
|
||||
trafficHistory.value = data.records || []
|
||||
} catch (e) {
|
||||
console.error('Failed to load hourly traffic', e)
|
||||
}
|
||||
trafficHistory.value = data
|
||||
}
|
||||
|
||||
const loadClients = async () => {
|
||||
@@ -47,6 +60,12 @@ const totalRules = computed(() => {
|
||||
return clients.value.reduce((sum, c) => sum + (c.rule_count || 0), 0)
|
||||
})
|
||||
|
||||
// 格式化后的流量统计
|
||||
const formatted24hInbound = computed(() => formatBytes(traffic24h.value.inbound))
|
||||
const formatted24hOutbound = computed(() => formatBytes(traffic24h.value.outbound))
|
||||
const formattedTotalInbound = computed(() => formatBytes(trafficTotal.value.inbound))
|
||||
const formattedTotalOutbound = computed(() => formatBytes(trafficTotal.value.outbound))
|
||||
|
||||
// Chart helpers
|
||||
const maxTraffic = computed(() => {
|
||||
const max = Math.max(
|
||||
@@ -59,9 +78,16 @@ const getBarHeight = (value: number) => {
|
||||
return (value / maxTraffic.value) * 100
|
||||
}
|
||||
|
||||
// 格式化时间戳为小时
|
||||
const formatHour = (timestamp: number) => {
|
||||
const date = new Date(timestamp * 1000)
|
||||
return date.getHours().toString().padStart(2, '0') + ':00'
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadClients()
|
||||
generateMockTrafficData()
|
||||
loadTrafficStats()
|
||||
loadTrafficHourly()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -94,9 +120,9 @@ onMounted(() => {
|
||||
</svg>
|
||||
</div>
|
||||
<div class="stat-content">
|
||||
<span class="stat-label">出站流量</span>
|
||||
<span class="stat-value">{{ trafficStats.outbound.toFixed(2) }}</span>
|
||||
<span class="stat-unit">{{ trafficStats.outboundUnit }}</span>
|
||||
<span class="stat-label">24h出站</span>
|
||||
<span class="stat-value">{{ formatted24hOutbound.value }}</span>
|
||||
<span class="stat-unit">{{ formatted24hOutbound.unit }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -108,9 +134,37 @@ onMounted(() => {
|
||||
</svg>
|
||||
</div>
|
||||
<div class="stat-content">
|
||||
<span class="stat-label">入站流量</span>
|
||||
<span class="stat-value">{{ trafficStats.inbound.toFixed(2) }}</span>
|
||||
<span class="stat-unit">{{ trafficStats.inboundUnit }}</span>
|
||||
<span class="stat-label">24h入站</span>
|
||||
<span class="stat-value">{{ formatted24hInbound.value }}</span>
|
||||
<span class="stat-unit">{{ formatted24hInbound.unit }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Total Outbound Traffic -->
|
||||
<div class="stat-card glass-stat">
|
||||
<div class="stat-icon total-out">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 11l5-5m0 0l5 5m-5-5v12" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="stat-content">
|
||||
<span class="stat-label">总出站</span>
|
||||
<span class="stat-value">{{ formattedTotalOutbound.value }}</span>
|
||||
<span class="stat-unit">{{ formattedTotalOutbound.unit }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Total Inbound Traffic -->
|
||||
<div class="stat-card glass-stat">
|
||||
<div class="stat-icon total-in">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 13l-5 5m0 0l-5-5m5 5V6" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="stat-content">
|
||||
<span class="stat-label">总入站</span>
|
||||
<span class="stat-value">{{ formattedTotalInbound.value }}</span>
|
||||
<span class="stat-unit">{{ formattedTotalInbound.unit }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -168,12 +222,12 @@ onMounted(() => {
|
||||
<div class="bar inbound" :style="{ height: getBarHeight(data.inbound) + '%' }"></div>
|
||||
<div class="bar outbound" :style="{ height: getBarHeight(data.outbound) + '%' }"></div>
|
||||
</div>
|
||||
<span class="bar-label">{{ data.hour }}</span>
|
||||
<span class="bar-label">{{ formatHour(data.timestamp) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chart-hint">
|
||||
<span>流量统计功能开发中,当前显示模拟数据</span>
|
||||
<div class="chart-hint" v-if="trafficHistory.length === 0">
|
||||
<span>暂无流量数据</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -268,7 +322,7 @@ onMounted(() => {
|
||||
/* Stats Grid */
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 20px;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
@@ -338,6 +392,16 @@ onMounted(() => {
|
||||
box-shadow: 0 4px 16px rgba(245, 158, 11, 0.4);
|
||||
}
|
||||
|
||||
.stat-icon.total-out {
|
||||
background: linear-gradient(135deg, #38bdf8 0%, #0284c7 100%);
|
||||
box-shadow: 0 4px 16px rgba(2, 132, 199, 0.4);
|
||||
}
|
||||
|
||||
.stat-icon.total-in {
|
||||
background: linear-gradient(135deg, #c084fc 0%, #9333ea 100%);
|
||||
box-shadow: 0 4px 16px rgba(147, 51, 234, 0.4);
|
||||
}
|
||||
|
||||
.stat-icon svg {
|
||||
color: white;
|
||||
}
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { CloudDownloadOutline, RefreshOutline, ServerOutline } from '@vicons/ionicons5'
|
||||
import { CloudDownloadOutline, RefreshOutline, ServerOutline, SettingsOutline, SaveOutline } from '@vicons/ionicons5'
|
||||
import GlassTag from '../components/GlassTag.vue'
|
||||
import { useToast } from '../composables/useToast'
|
||||
import { useConfirm } from '../composables/useConfirm'
|
||||
import {
|
||||
getVersionInfo, checkServerUpdate, applyServerUpdate,
|
||||
type UpdateInfo, type VersionInfo
|
||||
getServerConfig, updateServerConfig,
|
||||
type UpdateInfo, type VersionInfo, type ServerConfigResponse
|
||||
} from '../api'
|
||||
|
||||
const message = useToast()
|
||||
@@ -18,6 +19,20 @@ const loading = ref(true)
|
||||
const checkingServer = ref(false)
|
||||
const updatingServer = ref(false)
|
||||
|
||||
// 服务器配置
|
||||
const serverConfig = ref<ServerConfigResponse | null>(null)
|
||||
const configLoading = ref(false)
|
||||
const savingConfig = ref(false)
|
||||
|
||||
// 配置表单
|
||||
const configForm = ref({
|
||||
bind_addr: '',
|
||||
heartbeat_sec: 30,
|
||||
heartbeat_timeout: 90,
|
||||
web_username: '',
|
||||
web_password: ''
|
||||
})
|
||||
|
||||
const loadVersionInfo = async () => {
|
||||
try {
|
||||
const { data } = await getVersionInfo()
|
||||
@@ -29,6 +44,53 @@ const loadVersionInfo = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
const loadServerConfig = async () => {
|
||||
configLoading.value = true
|
||||
try {
|
||||
const { data } = await getServerConfig()
|
||||
serverConfig.value = data
|
||||
// 填充表单
|
||||
configForm.value = {
|
||||
bind_addr: data.server.bind_addr,
|
||||
heartbeat_sec: data.server.heartbeat_sec,
|
||||
heartbeat_timeout: data.server.heartbeat_timeout,
|
||||
web_username: data.web.username,
|
||||
web_password: ''
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load server config', e)
|
||||
} finally {
|
||||
configLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleSaveConfig = async () => {
|
||||
savingConfig.value = true
|
||||
try {
|
||||
const updateReq: any = {
|
||||
server: {
|
||||
bind_addr: configForm.value.bind_addr,
|
||||
heartbeat_sec: configForm.value.heartbeat_sec,
|
||||
heartbeat_timeout: configForm.value.heartbeat_timeout
|
||||
},
|
||||
web: {
|
||||
username: configForm.value.web_username
|
||||
}
|
||||
}
|
||||
// 只有填写了密码才更新
|
||||
if (configForm.value.web_password) {
|
||||
updateReq.web.password = configForm.value.web_password
|
||||
}
|
||||
await updateServerConfig(updateReq)
|
||||
message.success('配置已保存,部分配置需要重启服务后生效')
|
||||
configForm.value.web_password = ''
|
||||
} catch (e: any) {
|
||||
message.error(e.response?.data || '保存配置失败')
|
||||
} finally {
|
||||
savingConfig.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleCheckServerUpdate = async () => {
|
||||
checkingServer.value = true
|
||||
try {
|
||||
@@ -83,6 +145,7 @@ const formatBytes = (bytes: number): string => {
|
||||
|
||||
onMounted(() => {
|
||||
loadVersionInfo()
|
||||
loadServerConfig()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -140,6 +203,87 @@ onMounted(() => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Server Config Card -->
|
||||
<div class="glass-card">
|
||||
<div class="card-header">
|
||||
<h3>服务器配置</h3>
|
||||
<SettingsOutline class="header-icon" />
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div v-if="configLoading" class="loading-state">加载中...</div>
|
||||
<div v-else-if="serverConfig" class="config-form">
|
||||
<div class="form-group">
|
||||
<label class="form-label">服务器地址</label>
|
||||
<input
|
||||
v-model="configForm.bind_addr"
|
||||
type="text"
|
||||
class="glass-input"
|
||||
placeholder="0.0.0.0"
|
||||
/>
|
||||
<span class="form-hint">服务器监听地址,修改后需重启生效</span>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label class="form-label">心跳间隔 (秒)</label>
|
||||
<input
|
||||
v-model.number="configForm.heartbeat_sec"
|
||||
type="number"
|
||||
class="glass-input"
|
||||
min="1"
|
||||
max="300"
|
||||
/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">心跳超时 (秒)</label>
|
||||
<input
|
||||
v-model.number="configForm.heartbeat_timeout"
|
||||
type="number"
|
||||
class="glass-input"
|
||||
min="1"
|
||||
max="600"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-divider"></div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label class="form-label">Web 用户名</label>
|
||||
<input
|
||||
v-model="configForm.web_username"
|
||||
type="text"
|
||||
class="glass-input"
|
||||
placeholder="admin"
|
||||
/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Web 密码</label>
|
||||
<input
|
||||
v-model="configForm.web_password"
|
||||
type="password"
|
||||
class="glass-input"
|
||||
placeholder="留空则不修改"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button
|
||||
class="glass-btn primary"
|
||||
:disabled="savingConfig"
|
||||
@click="handleSaveConfig"
|
||||
>
|
||||
<SaveOutline class="btn-icon" />
|
||||
保存配置
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="empty-state">无法加载配置信息</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Server Update Card -->
|
||||
<div class="glass-card">
|
||||
<div class="card-header">
|
||||
@@ -406,4 +550,71 @@ onMounted(() => {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
/* Config Form */
|
||||
.config-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
font-size: 13px;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.form-hint {
|
||||
font-size: 11px;
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
@media (max-width: 500px) {
|
||||
.form-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.form-divider {
|
||||
height: 1px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
/* Glass Input */
|
||||
.glass-input {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
border-radius: 8px;
|
||||
padding: 10px 14px;
|
||||
color: white;
|
||||
font-size: 14px;
|
||||
outline: none;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.glass-input:focus {
|
||||
border-color: rgba(96, 165, 250, 0.5);
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
.glass-input::placeholder {
|
||||
color: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user