feat: 优化页面

This commit is contained in:
yarnom 2025-09-08 19:16:07 +08:00
parent ed8048c111
commit 480c0f7404
7 changed files with 981 additions and 1146 deletions

View File

@ -1,147 +0,0 @@
package main
import (
"fmt"
"net"
"sync"
"time"
)
// 客户端信息结构
type ClientInfo struct {
IP string // IP地址
Port string // 端口
LastSeen time.Time // 最后活跃时间
IsConnected bool // 是否当前连接
}
// 客户端列表(使用互斥锁保护的映射)
var (
clientsMutex sync.Mutex
clients = make(map[string]*ClientInfo)
)
// addClient 添加客户端
func addClient(addr string) {
clientsMutex.Lock()
defer clientsMutex.Unlock()
host, port, err := net.SplitHostPort(addr)
if err != nil {
Logger.Printf("解析客户端地址失败 %s: %v", addr, err)
host = addr
port = "unknown"
}
clients[addr] = &ClientInfo{
IP: host,
Port: port,
LastSeen: time.Now(),
IsConnected: true,
}
Logger.Printf("添加新客户端: %s", addr)
}
// updateClientLastSeen 更新客户端最后活跃时间
func updateClientLastSeen(addr string) {
clientsMutex.Lock()
defer clientsMutex.Unlock()
if client, exists := clients[addr]; exists {
client.LastSeen = time.Now()
}
}
// removeClient 移除客户端(标记断开)
func removeClient(addr string) {
clientsMutex.Lock()
defer clientsMutex.Unlock()
if client, exists := clients[addr]; exists {
client.IsConnected = false
Logger.Printf("客户端断开连接: %s", addr)
}
}
// getAllClients 获取所有客户端信息
func getAllClients() []map[string]interface{} {
clientsMutex.Lock()
defer clientsMutex.Unlock()
now := time.Now()
result := make([]map[string]interface{}, 0, len(clients))
for addr, client := range clients {
lastSeenDuration := now.Sub(client.LastSeen)
// 清理24小时前的记录
if lastSeenDuration > 24*time.Hour {
delete(clients, addr)
continue
}
// 连接状态判断当前连接且2小时内活跃为在线
isOnline := client.IsConnected && lastSeenDuration < 2*time.Hour
var connectionStatus string
if isOnline {
connectionStatus = "保持连接"
} else if client.IsConnected {
connectionStatus = "连接超时"
} else {
connectionStatus = "已断开"
}
result = append(result, map[string]interface{}{
"address": addr,
"ip": client.IP,
"port": client.Port,
"lastSeen": client.LastSeen,
"isOnline": isOnline,
"connectionStatus": connectionStatus,
"lastSeenFormatted": formatDuration(lastSeenDuration),
})
}
return result
}
// formatDuration 格式化持续时间为友好的字符串
func formatDuration(d time.Duration) string {
if d < time.Minute {
return "刚刚"
} else if d < time.Hour {
return fmt.Sprintf("%d分钟前", int(d.Minutes()))
} else if d < 24*time.Hour {
hours := int(d.Hours())
minutes := int(d.Minutes()) % 60
if minutes == 0 {
return fmt.Sprintf("%d小时前", hours)
} else {
return fmt.Sprintf("%d小时%d分钟前", hours, minutes)
}
} else {
return fmt.Sprintf("%d天前", int(d.Hours()/24))
}
}
// startClientCleanup 启动清理过期客户端的goroutine
func startClientCleanup() {
go func() {
for {
time.Sleep(1 * time.Hour) // 每小时检查一次
clientsMutex.Lock()
now := time.Now()
for addr, client := range clients {
if now.Sub(client.LastSeen) > 24*time.Hour {
delete(clients, addr)
Logger.Printf("移除过期客户端: %s", addr)
}
}
clientsMutex.Unlock()
}
}()
}

187
db.go
View File

@ -22,7 +22,7 @@ func InitDB() error {
dbName := "probe_db"
// 连接字符串
dsn := fmt.Sprintf("%s:%s@tcp(%s:%s)/%s?parseTime=true", username, password, host, port, dbName)
dsn := fmt.Sprintf("%s:%s@tcp(%s:%s)/%s?parseTime=true&loc=Asia%%2FShanghai", username, password, host, port, dbName)
var err error
db, err = sql.Open("mysql", dsn)
@ -81,26 +81,22 @@ func SaveSensorData(sensorID int, x, y, z, temperature float64, deviceID string)
// 获取传感器数据 - 添加时间范围,包含温度字段
func GetSensorData(sensorID int, limit int, startDate time.Time, endDate time.Time) ([]SensorData, error) {
query := `SELECT id, sensor_id, x_value, y_value, z_value,
COALESCE(temperature, 0) as temperature,
timestamp as timestamp
FROM sensor_data
WHERE sensor_id = ?`
query := "SELECT id, sensor_id, x_value, y_value, z_value, COALESCE(temperature, 0) as temperature, `timestamp` as timestamp FROM sensor_data WHERE sensor_id = ?"
var args []interface{}
args = append(args, sensorID)
if !startDate.IsZero() {
query += " AND timestamp >= ?"
query += " AND `timestamp` >= ?"
args = append(args, startDate)
}
if !endDate.IsZero() {
query += " AND timestamp <= ?"
query += " AND `timestamp` <= ?"
args = append(args, endDate)
}
query += " ORDER BY timestamp DESC"
query += " ORDER BY `timestamp` DESC"
// 只有当limit > 0时才添加LIMIT子句
if limit > 0 {
@ -138,25 +134,21 @@ func GetSensorData(sensorID int, limit int, startDate time.Time, endDate time.Ti
// 获取所有传感器数据,包含温度字段
func GetAllSensorData(limit int, startDate time.Time, endDate time.Time) ([]SensorData, error) {
query := `SELECT id, sensor_id, x_value, y_value, z_value,
COALESCE(temperature, 0) as temperature,
timestamp as timestamp
FROM sensor_data
WHERE 1=1`
query := "SELECT id, sensor_id, x_value, y_value, z_value, COALESCE(temperature, 0) as temperature, `timestamp` as timestamp FROM sensor_data WHERE 1=1"
var args []interface{}
if !startDate.IsZero() {
query += " AND timestamp >= ?"
query += " AND `timestamp` >= ?"
args = append(args, startDate)
}
if !endDate.IsZero() {
query += " AND timestamp <= ?"
query += " AND `timestamp` <= ?"
args = append(args, endDate)
}
query += " ORDER BY timestamp DESC"
query += " ORDER BY `timestamp` DESC"
// 只有当limit > 0时才添加LIMIT子句
if limit > 0 {
@ -235,6 +227,18 @@ type Device struct {
RegCodeHex sql.NullString
}
// DeviceWithStats 包含设备统计信息
type DeviceWithStats struct {
ID int
DeviceID string
ForwardEnable bool
Host sql.NullString
Port sql.NullInt64
RegCodeHex sql.NullString
SensorCount int
LastSeen sql.NullTime
}
// GetDevice 获取设备配置按设备字符串ID
func GetDevice(deviceID string) (*Device, error) {
row := db.QueryRow(`SELECT id, device_id, COALESCE(forward_enable, 0) as forward_enable, host, port, reg_code_hex FROM devices WHERE device_id = ?`, deviceID)
@ -249,3 +253,152 @@ func GetDevice(deviceID string) (*Device, error) {
d.ForwardEnable = fe != 0
return &d, nil
}
// GetDevicesWithStats 获取设备列表及统计
func GetDevicesWithStats() ([]DeviceWithStats, error) {
query := `SELECT d.id,
d.device_id,
COALESCE(d.forward_enable, 0) AS forward_enable,
d.host,
d.port,
d.reg_code_hex,
COALESCE(COUNT(DISTINCT sd.sensor_id), 0) AS sensor_count,
MAX(sd.timestamp) AS last_seen
FROM devices d
LEFT JOIN sensor_data sd ON sd.device_id = d.device_id
GROUP BY d.id, d.device_id, d.forward_enable, d.host, d.port, d.reg_code_hex
ORDER BY d.id`
rows, err := db.Query(query)
if err != nil {
return nil, err
}
defer rows.Close()
var list []DeviceWithStats
for rows.Next() {
var item DeviceWithStats
var fe int
if err := rows.Scan(&item.ID, &item.DeviceID, &fe, &item.Host, &item.Port, &item.RegCodeHex, &item.SensorCount, &item.LastSeen); err != nil {
return nil, err
}
item.ForwardEnable = fe != 0
list = append(list, item)
}
return list, nil
}
// GetSensorIDsByDevice 获取某设备下的传感器ID
func GetSensorIDsByDevice(deviceID string) ([]int, error) {
rows, err := db.Query(`SELECT DISTINCT sensor_id FROM sensor_data WHERE device_id = ? ORDER BY sensor_id`, deviceID)
if err != nil {
return nil, err
}
defer rows.Close()
var ids []int
for rows.Next() {
var id int
if err := rows.Scan(&id); err != nil {
return nil, err
}
ids = append(ids, id)
}
return ids, nil
}
// GetSensorDataByDevice 获取某设备下的数据(可选时间范围)
func GetSensorDataByDevice(deviceID string, limit int, startDate, endDate time.Time) ([]SensorData, error) {
query := "SELECT id, sensor_id, x_value, y_value, z_value, " +
"COALESCE(temperature, 0) as temperature, " +
"`timestamp` as timestamp " +
"FROM sensor_data " +
"WHERE device_id = ?"
var args []interface{}
args = append(args, deviceID)
if !startDate.IsZero() {
query += " AND `timestamp` >= ?"
args = append(args, startDate)
}
if !endDate.IsZero() {
query += " AND `timestamp` <= ?"
args = append(args, endDate)
}
query += " ORDER BY `timestamp` DESC"
if limit > 0 {
query += " LIMIT ?"
args = append(args, limit)
}
rows, err := db.Query(query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var result []SensorData
for rows.Next() {
var data SensorData
var xInt, yInt, zInt, tempInt int
if err := rows.Scan(&data.ID, &data.SensorID, &xInt, &yInt, &zInt, &tempInt, &data.Timestamp); err != nil {
return nil, err
}
data.X = float64(xInt) / SCALING_FACTOR
data.Y = float64(yInt) / SCALING_FACTOR
data.Z = float64(zInt) / SCALING_FACTOR
data.Temperature = float64(tempInt) / SCALING_FACTOR
result = append(result, data)
}
return result, nil
}
// GetSensorDataByDeviceAndSensor 获取某设备某传感器的数据
func GetSensorDataByDeviceAndSensor(deviceID string, sensorID int, limit int, startDate, endDate time.Time) ([]SensorData, error) {
query := "SELECT id, sensor_id, x_value, y_value, z_value, " +
"COALESCE(temperature, 0) as temperature, " +
"`timestamp` as timestamp " +
"FROM sensor_data " +
"WHERE device_id = ? AND sensor_id = ?"
var args []interface{}
args = append(args, deviceID, sensorID)
if !startDate.IsZero() {
query += " AND `timestamp` >= ?"
args = append(args, startDate)
}
if !endDate.IsZero() {
query += " AND `timestamp` <= ?"
args = append(args, endDate)
}
query += " ORDER BY `timestamp` DESC"
if limit > 0 {
query += " LIMIT ?"
args = append(args, limit)
}
rows, err := db.Query(query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var result []SensorData
for rows.Next() {
var data SensorData
var xInt, yInt, zInt, tempInt int
if err := rows.Scan(&data.ID, &data.SensorID, &xInt, &yInt, &zInt, &tempInt, &data.Timestamp); err != nil {
return nil, err
}
data.X = float64(xInt) / SCALING_FACTOR
data.Y = float64(yInt) / SCALING_FACTOR
data.Z = float64(zInt) / SCALING_FACTOR
data.Temperature = float64(tempInt) / SCALING_FACTOR
result = append(result, data)
}
return result, nil
}

View File

@ -9,17 +9,20 @@ import (
"os"
"path/filepath"
"strconv"
"strings"
"time"
)
// 启动HTTP服务器
func StartHTTPServer(address string) error {
http.HandleFunc("/", handleIndex)
// 静态资源
http.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("static"))))
http.HandleFunc("/api/data", handleGetData)
http.HandleFunc("/api/latest", handleGetLatest)
http.HandleFunc("/api/sensors", handleGetSensors)
http.HandleFunc("/api/clients", handleGetClients)
http.HandleFunc("/api/devices", handleGetDevices)
fmt.Printf("HTTP服务器已启动正在监听 %s\n", address)
return http.ListenAndServe(address, nil)
}
@ -60,6 +63,7 @@ func handleGetData(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
deviceID := r.URL.Query().Get("device_id")
sensorIDStr := r.URL.Query().Get("sensor_id")
limitStr := r.URL.Query().Get("limit")
startDateStr := r.URL.Query().Get("start_date")
@ -93,9 +97,11 @@ func handleGetData(w http.ResponseWriter, r *http.Request) {
}
}
// 使用北京时间解析前端传来的本地时间
var startDate, endDate time.Time
shLoc, _ := time.LoadLocation("Asia/Shanghai")
if startDateStr != "" {
startDate, err = time.Parse("2006-01-02T15:04", startDateStr)
startDate, err = time.ParseInLocation("2006-01-02T15:04", startDateStr, shLoc)
if err != nil {
log.Printf("错误: 无效的开始日期: %s, %v", startDateStr, err)
http.Error(w, "无效的开始日期", http.StatusBadRequest)
@ -104,7 +110,7 @@ func handleGetData(w http.ResponseWriter, r *http.Request) {
}
if endDateStr != "" {
endDate, err = time.Parse("2006-01-02T15:04", endDateStr)
endDate, err = time.ParseInLocation("2006-01-02T15:04", endDateStr, shLoc)
if err != nil {
log.Printf("错误: 无效的结束日期: %s, %v", endDateStr, err)
http.Error(w, "无效的结束日期", http.StatusBadRequest)
@ -112,18 +118,19 @@ func handleGetData(w http.ResponseWriter, r *http.Request) {
}
}
// 特殊处理获取最新数据的请求
if limit == 1 && sensorIDStr == "" {
log.Printf("检测到获取最新数据请求 (limit=1)")
// 业务要求:必须提供 device_id在设备下可选指定探头
if strings.TrimSpace(deviceID) == "" {
http.Error(w, "必须提供 device_id", http.StatusBadRequest)
return
}
var data []SensorData
if sensorIDStr == "all" || sensorIDStr == "" {
data, err = GetAllSensorData(limit, startDate, endDate)
log.Printf("查询所有传感器数据limit=%d, 结果数量=%d", limit, len(data))
data, err = GetSensorDataByDevice(deviceID, limit, startDate, endDate)
log.Printf("查询设备%s下所有传感器数据limit=%d, 结果数量=%d", deviceID, limit, len(data))
} else {
data, err = GetSensorData(sensorID, limit, startDate, endDate)
log.Printf("查询传感器%d数据limit=%d, 结果数量=%d", sensorID, limit, len(data))
data, err = GetSensorDataByDeviceAndSensor(deviceID, sensorID, limit, startDate, endDate)
log.Printf("查询设备%s下传感器%d数据limit=%d, 结果数量=%d", deviceID, sensorID, limit, len(data))
}
if err != nil {
@ -156,8 +163,16 @@ func handleGetSensors(w http.ResponseWriter, r *http.Request) {
log.Printf("接收到获取传感器列表请求")
w.Header().Set("Content-Type", "application/json")
sensorIDs, err := GetAllSensorIDs()
deviceID := r.URL.Query().Get("device_id")
var (
sensorIDs []int
err error
)
if strings.TrimSpace(deviceID) != "" {
sensorIDs, err = GetSensorIDsByDevice(deviceID)
} else {
sensorIDs, err = GetAllSensorIDs()
}
if err != nil {
log.Printf("错误: 获取传感器ID失败: %v", err)
http.Error(w, "获取传感器ID失败"+err.Error(), http.StatusInternalServerError)
@ -172,13 +187,52 @@ func handleGetSensors(w http.ResponseWriter, r *http.Request) {
}
}
func handleGetClients(w http.ResponseWriter, r *http.Request) {
// 已移除 /api/clients在线统计改由 /api/devices 提供3小时窗口
// 处理获取设备列表及统计的API
func handleGetDevices(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
clients := getAllClients()
list, err := GetDevicesWithStats()
if err != nil {
log.Printf("错误: 获取设备列表失败: %v", err)
http.Error(w, "获取设备列表失败:"+err.Error(), http.StatusInternalServerError)
return
}
if err := json.NewEncoder(w).Encode(clients); err != nil {
log.Printf("错误: 编码客户端信息JSON失败: %v", err)
// 构造响应计算是否在线3小时内有上报
type deviceResp struct {
ID int `json:"id"`
DeviceID string `json:"device_id"`
SensorCount int `json:"sensor_count"`
LastSeen *time.Time `json:"last_seen"`
Online bool `json:"online"`
}
shLoc, _ := time.LoadLocation("Asia/Shanghai")
resp := make([]deviceResp, 0, len(list))
now := time.Now().In(shLoc)
for _, d := range list {
var lsPtr *time.Time
online := false
if d.LastSeen.Valid {
ls := d.LastSeen.Time.In(shLoc)
lsPtr = &ls
if now.Sub(ls) <= 3*time.Hour {
online = true
}
}
resp = append(resp, deviceResp{
ID: d.ID,
DeviceID: d.DeviceID,
SensorCount: d.SensorCount,
LastSeen: lsPtr,
Online: online,
})
}
if err := json.NewEncoder(w).Encode(resp); err != nil {
log.Printf("错误: 编码设备列表JSON失败: %v", err)
http.Error(w, "编码JSON失败"+err.Error(), http.StatusInternalServerError)
}
}

1
static/css/tailwind.min.css vendored Normal file

File diff suppressed because one or more lines are too long

14
static/js/chart.js Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

View File

@ -19,7 +19,7 @@ func StartUDPServer(address string) error {
}
defer conn.Close()
startClientCleanup()
// 已移除客户端在线追踪;改由 DB 的 last_seen 统计在线
Logger.Printf("UDP服务器已启动正在监听 %s\n", address)
@ -40,7 +40,7 @@ func StartUDPServer(address string) error {
func handleUDPPacket(conn *net.UDPConn, addr *net.UDPAddr, data []byte) {
remoteAddr := addr.String()
addClient(remoteAddr)
// 已移除客户端在线追踪
rawData := string(data)
TCPDataLogger.Printf("从UDP客户端 %s 接收到原始数据: %s", remoteAddr, rawData)
@ -102,5 +102,5 @@ func handleUDPPacket(conn *net.UDPConn, addr *net.UDPAddr, data []byte) {
Logger.Printf("发送响应到UDP客户端 %s 失败: %v", remoteAddr, err)
}
updateClientLastSeen(remoteAddr)
// 已移除客户端在线追踪
}