Bläddra i källkod

摄像头预览

xu 11 månader sedan
förälder
incheckning
ddf658cd5d

+ 233 - 0
server/api/v1/devices/camera.go

@@ -0,0 +1,233 @@
+package devices
+
+import (
+	"encoding/json"
+	"fmt"
+	"github.com/gin-gonic/gin"
+	"go.uber.org/zap"
+	"io"
+	"os"
+	"path/filepath"
+	"server/dao"
+	"server/global"
+	"server/model/common/response"
+	"server/model/devices"
+	"server/utils/cache"
+	"strconv"
+	"strings"
+	"time"
+)
+
+type CameraApi struct {
+}
+
+// DeviceEndianHeartbeat 摄像头心跳
+func (ca *CameraApi) DeviceEndianHeartbeat(c *gin.Context) {
+	var info devices.CameraEndianHeartbeatRequest
+
+	if err := c.ShouldBindJSON(&info); err != nil {
+		global.GVA_LOG.Error("DeviceEndianHeartbeat === ", zap.Error(err))
+		response.FailWithMessage("参数错误", c)
+		return
+	}
+
+	cache.UpdateDeviceState(info.SerialNum, 1)
+
+	resp := devices.CameraEndianHeartbeatResponse{
+		ReturnCode:   0,
+		PushEventPic: true,
+	}
+
+	response.OkWithData(resp, c)
+}
+
+func (ca *CameraApi) DeviceEndianEvent(c *gin.Context) {
+	// 步骤1:解析 multipart/form-data 表单(包含EventInfo和图片文件)
+	var form devices.CameraEventForm
+	if err := c.ShouldBind(&form); err != nil {
+		global.GVA_LOG.Error("表单解析失败", zap.Error(err))
+		response.FailWithMessage("表单格式错误:"+err.Error(), c)
+		return
+	}
+
+	// 步骤2:处理EventInfo.json文件(原有逻辑)
+	file, err := form.EventInfo.Open()
+	if err != nil {
+		global.GVA_LOG.Error("打开EventInfo文件失败", zap.Error(err))
+		response.FailWithMessage("读取事件文件失败:"+err.Error(), c)
+		return
+	}
+	defer file.Close()
+
+	fileContent, err := io.ReadAll(file)
+	if err != nil {
+		global.GVA_LOG.Error("读取EventInfo文件内容失败", zap.Error(err))
+		response.FailWithMessage("解析事件文件失败:"+err.Error(), c)
+		return
+	}
+
+	var eventReq devices.CameraEndianEventRequest
+	if err := json.Unmarshal(fileContent, &eventReq); err != nil {
+		global.GVA_LOG.Error("JSON解析失败", zap.Error(err), zap.String("content", string(fileContent)))
+		response.FailWithMessage("事件数据格式错误:"+err.Error(), c)
+		return
+	}
+
+	//if eventReq.EventType != "Alarm" {
+	//	global.GVA_LOG.Info("事件处理完成", zap.Any("event", eventReq))
+	//	response.OkWithData(devices.CameraEndianEventResponse{
+	//		ReturnCode: 0,
+	//	}, c)
+	//	return
+	//}
+
+	// 步骤3:新增逻辑 - 处理图片文件(form.Files)
+	var savedFiles string // 记录成功保存的图片路径
+	if form.File != nil {
+		// 遍历所有上传的图片
+		// 3.1 打开图片文件
+		imgFile, err := form.File.Open()
+		if err != nil {
+			global.GVA_LOG.Warn("打开图片文件失败", zap.String("filename", form.File.Filename), zap.Error(err))
+			return // 跳过当前文件,处理下一个
+		}
+		defer imgFile.Close()
+
+		// 3.2 读取图片内容
+		imgBytes, err := io.ReadAll(imgFile)
+		if err != nil {
+			global.GVA_LOG.Warn("读取图片内容失败", zap.String("filename", form.File.Filename), zap.Error(err))
+			return
+		}
+
+		// 3.3 简单校验图片类型(可选,根据需求调整)
+		if !isImageFile(form.File.Filename) {
+			global.GVA_LOG.Warn("非图片文件,跳过处理", zap.String("filename", form.File.Filename))
+			return
+		}
+
+		// 3.4 保存图片到本地(实际项目可改为上传OSS/云存储)
+		savePath, err := saveImageToLocal(eventReq.SerialNum, form.File.Filename, imgBytes)
+		if err != nil {
+			global.GVA_LOG.Warn("保存图片失败", zap.String("filename", form.File.Filename), zap.Error(err))
+		}
+		savedFiles = savePath
+	}
+
+	// 步骤4:返回响应(包含事件信息和图片处理结果)
+	resp := devices.CameraEndianEventResponse{
+		ReturnCode: 0,
+	}
+
+	global.GVA_LOG.Info("事件处理完成", zap.Any("event", eventReq), zap.String("saved_files", savedFiles))
+	response.OkWithData(resp, c)
+}
+
+// 辅助函数:判断是否为图片文件(通过文件名后缀)
+func isImageFile(filename string) bool {
+	ext := strings.ToLower(filepath.Ext(filename))
+	switch ext {
+	case ".jpg", ".jpeg", ".png", ".bmp", ".gif":
+		return true
+	default:
+		return false
+	}
+}
+
+// 辅助函数:保存图片到本地目录
+func saveImageToLocal(serialNum, filename string, data []byte) (string, error) {
+	// 构建保存路径:./uploads/{设备序列号}/{日期}/filename
+	dateDir := time.Now().Format("20060102")
+	saveDir := filepath.Join("./uploads", serialNum, dateDir)
+	if err := os.MkdirAll(saveDir, 0755); err != nil {
+		return "", fmt.Errorf("创建保存目录失败:%w", err)
+	}
+
+	// 生成唯一文件名(避免重名)
+	uniqueName := fmt.Sprintf("%s_%s", time.Now().Format("150405"), filename)
+	savePath := filepath.Join(saveDir, uniqueName)
+
+	// 写入文件
+	if err := os.WriteFile(savePath, data, 0644); err != nil {
+		return "", fmt.Errorf("写入文件失败:%w", err)
+	}
+
+	return savePath, nil
+}
+
+//---------------------------------------------------------------------------------------------------------------------
+
+func (ca *CameraApi) QueryAllCameras(c *gin.Context) {
+	cameras, err := cameraService.QueryAllCameras()
+	if err != nil {
+		global.GVA_LOG.Error("查询失败", zap.Error(err))
+		response.FailWithMessage("查询失败", c)
+		return
+	}
+	response.OkWithData(cameras, c)
+}
+
+func (ca *CameraApi) QueryCameraList(c *gin.Context) {
+	var info devices.SearchCamera
+	if err := c.ShouldBind(&info); err != nil {
+		global.GVA_LOG.Error("参数错误", zap.Error(err))
+		response.FailWithMessage("参数错误", c)
+		return
+	}
+	list, total, err := cameraService.QueryCameraList(info)
+	if err != nil {
+		global.GVA_LOG.Error("查询失败", zap.Error(err))
+		response.FailWithMessage("查询失败", c)
+		return
+	}
+	response.OkWithDetailed(response.PageResult{
+		List:     list,
+		Total:    total,
+		Page:     info.Page,
+		PageSize: info.PageSize,
+	}, "获取成功", c)
+}
+
+func (ca *CameraApi) CreateCamera(c *gin.Context) {
+	var camera dao.Camera
+	if err := c.ShouldBind(&camera); err != nil {
+		global.GVA_LOG.Error("参数错误", zap.Error(err))
+		response.FailWithMessage("参数错误", c)
+		return
+	}
+	if err := cameraService.CreateCamera(camera); err != nil {
+		global.GVA_LOG.Error("新增失败", zap.Error(err))
+		response.FailWithMessage("新增失败", c)
+		return
+	}
+	response.OkWithMessage("新增成功", c)
+}
+
+func (ca *CameraApi) UpdateCamera(c *gin.Context) {
+	var camera dao.Camera
+	if err := c.ShouldBind(&camera); err != nil {
+		global.GVA_LOG.Error("参数错误", zap.Error(err))
+		response.FailWithMessage("参数错误", c)
+		return
+	}
+	if err := cameraService.UpdateCamera(camera); err != nil {
+		global.GVA_LOG.Error("更新失败", zap.Error(err))
+		response.FailWithMessage("更新失败", c)
+		return
+	}
+	response.OkWithMessage("更新成功", c)
+}
+
+func (ca *CameraApi) DeleteCamera(c *gin.Context) {
+	id, err := strconv.Atoi(c.Query("id"))
+	if err != nil {
+		response.FailWithMessage("参数错误", c)
+		return
+	}
+	if err := cameraService.DeleteCamera(id); err != nil {
+		global.GVA_LOG.Error("删除失败", zap.Error(err))
+		response.FailWithMessage("删除失败", c)
+		return
+	}
+	response.OkWithMessage("删除成功", c)
+}

+ 2 - 0
server/api/v1/devices/enter.go

@@ -7,6 +7,7 @@ type ApiGroup struct {
 	ProgramApi
 	SoundPeriodApi
 	VoiceApi
+	CameraApi
 }
 
 var (
@@ -14,4 +15,5 @@ var (
 	programService     = service.ServiceGroupApp.DevicesServiceGroup.ProgramService
 	soundPeriodService = service.ServiceGroupApp.DevicesServiceGroup.SoundPeriodService
 	voiceService       = service.ServiceGroupApp.DevicesServiceGroup.VoiceService
+	cameraService      = service.ServiceGroupApp.DevicesServiceGroup.CameraService
 )

+ 10 - 0
server/api/v1/devices/screens.go

@@ -141,3 +141,13 @@ func (a ScreensApi) Sending(c *gin.Context) {
 	}
 	response.Ok(c)
 }
+
+func (a ScreensApi) QueryAllScreens(c *gin.Context) {
+	list, err := ScreensService.QueryAllScreens()
+	if err != nil {
+		global.GVA_LOG.Error("获取失败!", zap.Error(err))
+		response.FailWithMessage("获取失败", c)
+		return
+	}
+	response.OkWithDetailed(list, "获取成功", c)
+}

+ 146 - 0
server/api/v1/system/stream.go

@@ -0,0 +1,146 @@
+package system
+
+import (
+	"fmt"
+	"github.com/gin-gonic/gin"
+	"go.uber.org/zap"
+	"net/http"
+	"os"
+	"path/filepath"
+	"server/global"
+	"server/model/common/response"
+	"server/service/stream"
+)
+
+// StreamApi API接口结构体
+type StreamApi struct{}
+
+var (
+	StreamService = stream.NewStreamService()
+	// HLS文件存储的相对路径(跨平台兼容)
+	hlsBaseDir = filepath.Join("..\\", "server", "hls")
+)
+
+// StartStream 启动RTSP转HLS流
+// @Tags Stream
+// @Summary 启动RTSP转HLS流
+// @Security ApiKeyAuth
+// @accept application/json
+// @Produce application/json
+// @Param data body StartStreamRequest true "RTSP地址和流ID"
+// @Success 200 {object} response.Response{data=string} "返回HLS流地址"
+// @Router /stream/start [post]
+func (s *StreamApi) StartStream(c *gin.Context) {
+	var req StartStreamRequest
+	if err := c.ShouldBindJSON(&req); err != nil {
+		global.GVA_LOG.Error("参数验证失败", zap.Error(err))
+		response.FailWithMessage("参数错误: "+err.Error(), c)
+		return
+	}
+
+	hlsUrl, err := StreamService.StartStream(req.RTSPUrl, req.StreamID)
+	if err != nil {
+		global.GVA_LOG.Error("启动流转换失败", zap.Error(err))
+		response.FailWithMessage(err.Error(), c)
+		return
+	}
+
+	response.OkWithData(map[string]string{
+		"hlsUrl": hlsUrl,
+	}, c)
+}
+
+// StopStream 停止流转换
+// @Tags Stream
+// @Summary 停止流转换
+// @Security ApiKeyAuth
+// @accept application/json
+// @Produce application/json
+// @Param data body StopStreamRequest true "流ID"
+// @Success 200 {object} response.Response
+// @Router /stream/stop [post]
+func (s *StreamApi) StopStream(c *gin.Context) {
+	var req StopStreamRequest
+	if err := c.ShouldBindJSON(&req); err != nil {
+		global.GVA_LOG.Error("参数验证失败", zap.Error(err))
+		response.FailWithMessage("参数错误: "+err.Error(), c)
+		return
+	}
+
+	if err := StreamService.StopStream(req.StreamID); err != nil {
+		global.GVA_LOG.Error("停止流转换失败", zap.Error(err))
+		response.FailWithMessage(err.Error(), c)
+		return
+	}
+
+	response.OkWithMessage("流已成功停止", c)
+}
+
+// PlayHLS 播放HLS流
+// @Tags Stream
+// @Summary 播放HLS流
+// @accept application/json
+// @Produce application/x-mpegURL
+// @Param streamId path string true "流ID"
+// @Param any path string true "m3u8或ts文件名"
+// @Success 200 {file} file "返回视频流文件"
+// @Router /stream/hls/{streamId}/{any} [get]
+func (s *StreamApi) PlayHLS(c *gin.Context) {
+	streamId := c.Param("streamId")
+	filePath := c.Param("any")
+
+	// 构建完整的文件路径(跨平台兼容)
+	fullPath := filepath.Join(hlsBaseDir, streamId, filePath)
+
+	fmt.Println("fullPath:", fullPath)
+
+	// 检查文件是否存在
+	if _, err := os.Stat(fullPath); err != nil {
+		global.GVA_LOG.Error("HLS文件不存在", zap.String("path", fullPath), zap.Error(err))
+		c.JSON(http.StatusNotFound, gin.H{
+			"code": 404,
+			"msg":  "HLS文件不存在: " + fullPath,
+		})
+		return
+	}
+
+	// 设置正确的Content-Type
+	switch filepath.Ext(fullPath) {
+	case ".m3u8":
+		c.Header("Content-Type", "application/x-mpegURL")
+	case ".ts":
+		c.Header("Content-Type", "video/MP2T")
+	default:
+		c.Header("Content-Type", "application/octet-stream")
+	}
+
+	// 禁用缓存,支持断点续传
+	c.Header("Cache-Control", "no-cache")
+	c.Header("Accept-Ranges", "bytes")
+
+	// 发送文件内容
+	c.File(fullPath)
+}
+
+// GetStreamList 获取活跃流列表
+// @Tags Stream
+// @Summary 获取活跃流列表
+// @Security ApiKeyAuth
+// @accept application/json
+// @Produce application/json
+// @Success 200 {object} response.Response{data=[]string} "返回活跃流ID列表"
+// @Router /stream/list [get]
+func (s *StreamApi) GetStreamList(c *gin.Context) {
+	list := StreamService.GetActiveStreams()
+	response.OkWithData(list, c)
+}
+
+// 请求结构体定义
+type StartStreamRequest struct {
+	RTSPUrl  string `json:"rtspUrl" binding:"required"`  // RTSP流地址
+	StreamID string `json:"streamId" binding:"required"` // 流唯一标识
+}
+
+type StopStreamRequest struct {
+	StreamID string `json:"streamId" binding:"required"` // 流唯一标识
+}

+ 2 - 7
server/config.yaml

@@ -126,7 +126,7 @@ mysql:
     db-name: smart_intersection2.0
     username: root
 #    password: qWo2#inH7Bw28#M5
-    password: 123456
+    password: root
     path: 127.0.0.1
     engine: ""
     log-mode: error
@@ -173,12 +173,7 @@ qiniu:
 redis:
     addr: 127.0.0.1:6379
     password: ""
-    db: 0
-    useCluster: false
-    clusterAddrs:
-        - 172.21.0.3:7000
-        - 172.21.0.4:7001
-        - 172.21.0.2:7002
+    db: 1
 sqlite:
     prefix: ""
     port: ""

+ 49 - 0
server/dao/dev_camera.go

@@ -0,0 +1,49 @@
+package dao
+
+import (
+	"server/global"
+)
+
+type Camera struct {
+	global.GVA_MODEL
+	Name      string `json:"name" gorm:"column:name;comment:'名称'"`
+	Ip        string `json:"ip" form:"ip" gorm:"comment:'ip'"`
+	Port      string `json:"port" form:"port" gorm:"comment:'port'"`
+	UserName  string `json:"username" gorm:"column:username;comment:'用户名'"`
+	Password  string `json:"password" gorm:"column:password;comment:'密码'"`
+	Channel   string `json:"channel" gorm:"column:channel;comment:'通道号'"`
+	DevType   string `json:"devType" form:"devType" gorm:"comment:'设备类型'"`
+	SerialNum string `json:"serialNum" form:"serialNum" gorm:"comment:'设备序列号'"`
+	LocalTime string `json:"lacalTime" form:"lacalTime" gorm:"comment:'设备端系统时间'"`
+	State     int    `json:"state" gorm:"column:state;comment:'状态'"`
+
+	ScreensId int `json:"screensId" gorm:"column:screens_id;comment:'显示器id'"`
+}
+
+func QueryAllCameras() (cameras []Camera, err error) {
+	err = global.GVA_DB.Model(&Camera{}).Find(&cameras).Error
+	return
+}
+
+func QueryCameraList(limit, offset int) (cameras []Camera, total int64, err error) {
+	db := global.GVA_DB.Model(&Camera{})
+
+	err = db.Count(&total).Error
+	if err != nil {
+		return
+	}
+	err = db.Limit(limit).Offset(offset).Find(&cameras).Error
+	return
+}
+
+func (c Camera) CreateCamera() error {
+	return global.GVA_DB.Create(&c).Error
+}
+
+func (c Camera) UpdateCamera() error {
+	return global.GVA_DB.Where("id = ?", c.ID).Updates(&c).Error
+}
+
+func DeleteCamera(id int) error {
+	return global.GVA_DB.Unscoped().Delete(&Camera{}, id).Error
+}

+ 5 - 2
server/dao/dev_screens.go

@@ -25,6 +25,9 @@ type Screens struct {
 
 	VoiceId int   `gorm:"type:int" json:"voiceId"`
 	Voice   Voice `gorm:"foreignkey:VoiceId"`
+
+	CameraId int    `gorm:"type:int" json:"cameraId"`
+	Camera   Camera `gorm:"foreignkey:CameraId"`
 }
 
 func (Screens) TableName() string {
@@ -42,7 +45,7 @@ func (s Screens) DelScreens(id int) error {
 
 func (s Screens) GetScreensList(limit, offset, projectId int, sn string, uid uint) (screensList []Screens, total int64, err error) {
 
-	db := global.GVA_DB.Debug().Debug().Model(&Screens{})
+	db := global.GVA_DB.Model(&Screens{})
 
 	if sn != "" {
 		db.Where("sn like ?", "%"+sn+"%")
@@ -63,7 +66,7 @@ func (s Screens) GetScreensList(limit, offset, projectId int, sn string, uid uin
 	if err != nil {
 		return
 	}
-	err = db.Limit(limit).Offset(offset).Preload("Voice").Preload("SoundPeriod").Preload("Program").Preload("Project").Find(&screensList).Error
+	err = db.Limit(limit).Offset(offset).Preload("Camera").Preload("Voice").Preload("SoundPeriod").Preload("Program").Preload("Project").Find(&screensList).Error
 	return
 }
 

+ 16 - 15
server/go.mod

@@ -15,7 +15,7 @@ require (
 	github.com/flipped-aurora/ws v1.0.2
 	github.com/fsnotify/fsnotify v1.8.0
 	github.com/fvbock/endless v0.0.0-20170109170031-447134032cb6
-	github.com/gin-gonic/gin v1.10.0
+	github.com/gin-gonic/gin v1.10.1
 	github.com/glebarez/sqlite v1.11.0
 	github.com/go-sql-driver/mysql v1.8.1
 	github.com/gofrs/uuid/v5 v5.0.0
@@ -41,7 +41,7 @@ require (
 	go.mongodb.org/mongo-driver v1.17.2
 	go.uber.org/automaxprocs v1.6.0
 	go.uber.org/zap v1.27.0
-	golang.org/x/crypto v0.38.0
+	golang.org/x/crypto v0.39.0
 	golang.org/x/sync v0.15.0
 	golang.org/x/text v0.26.0
 	gorm.io/driver/mysql v1.5.7
@@ -55,8 +55,8 @@ require (
 	filippo.io/edwards25519 v1.1.0 // indirect
 	github.com/KyleBanks/depth v1.2.1 // indirect
 	github.com/bmatcuk/doublestar/v4 v4.8.0 // indirect
-	github.com/bytedance/sonic v1.12.7 // indirect
-	github.com/bytedance/sonic/loader v0.2.3 // indirect
+	github.com/bytedance/sonic v1.13.3 // indirect
+	github.com/bytedance/sonic/loader v0.2.4 // indirect
 	github.com/casbin/govaluate v1.3.0 // indirect
 	github.com/cespare/xxhash/v2 v2.3.0 // indirect
 	github.com/clbanning/mxj v1.8.4 // indirect
@@ -64,8 +64,9 @@ require (
 	github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
 	github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
 	github.com/dustin/go-humanize v1.0.1 // indirect
-	github.com/gabriel-vasile/mimetype v1.4.8 // indirect
-	github.com/gin-contrib/sse v1.0.0 // indirect
+	github.com/gabriel-vasile/mimetype v1.4.9 // indirect
+	github.com/gin-contrib/cors v1.7.6 // indirect
+	github.com/gin-contrib/sse v1.1.0 // indirect
 	github.com/glebarez/go-sqlite v1.22.0 // indirect
 	github.com/go-ole/go-ole v1.3.0 // indirect
 	github.com/go-openapi/jsonpointer v0.21.0 // indirect
@@ -74,8 +75,8 @@ require (
 	github.com/go-openapi/swag v0.23.0 // indirect
 	github.com/go-playground/locales v0.14.1 // indirect
 	github.com/go-playground/universal-translator v0.18.1 // indirect
-	github.com/go-playground/validator/v10 v10.24.0 // indirect
-	github.com/goccy/go-json v0.10.4 // indirect
+	github.com/go-playground/validator/v10 v10.26.0 // indirect
+	github.com/goccy/go-json v0.10.5 // indirect
 	github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 // indirect
 	github.com/golang-sql/sqlexp v0.1.0 // indirect
 	github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
@@ -93,7 +94,7 @@ require (
 	github.com/josharian/intern v1.0.0 // indirect
 	github.com/json-iterator/go v1.1.12 // indirect
 	github.com/klauspost/compress v1.17.11 // indirect
-	github.com/klauspost/cpuid/v2 v2.2.9 // indirect
+	github.com/klauspost/cpuid/v2 v2.2.10 // indirect
 	github.com/leodido/go-urn v1.4.0 // indirect
 	github.com/lufia/plan9stats v0.0.0-20240909124753-873cd0166683 // indirect
 	github.com/magiconair/properties v1.8.9 // indirect
@@ -106,7 +107,7 @@ require (
 	github.com/montanaflynn/stats v0.7.1 // indirect
 	github.com/mozillazg/go-httpheader v0.4.0 // indirect
 	github.com/ncruces/go-strftime v0.1.9 // indirect
-	github.com/pelletier/go-toml/v2 v2.2.3 // indirect
+	github.com/pelletier/go-toml/v2 v2.2.4 // indirect
 	github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
 	github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
 	github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
@@ -121,21 +122,21 @@ require (
 	github.com/tklauser/go-sysconf v0.3.14 // indirect
 	github.com/tklauser/numcpus v0.9.0 // indirect
 	github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
-	github.com/ugorji/go/codec v1.2.12 // indirect
+	github.com/ugorji/go/codec v1.3.0 // indirect
 	github.com/xdg-go/pbkdf2 v1.0.0 // indirect
 	github.com/xdg-go/scram v1.1.2 // indirect
 	github.com/xdg-go/stringprep v1.0.4 // indirect
 	github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
 	github.com/yusufpapurcu/wmi v1.2.4 // indirect
 	go.uber.org/multierr v1.11.0 // indirect
-	golang.org/x/arch v0.13.0 // indirect
+	golang.org/x/arch v0.18.0 // indirect
 	golang.org/x/exp v0.0.0-20250106191152-7588d65b2ba8 // indirect
 	golang.org/x/image v0.23.0 // indirect
-	golang.org/x/net v0.40.0 // indirect
+	golang.org/x/net v0.41.0 // indirect
 	golang.org/x/sys v0.33.0 // indirect
 	golang.org/x/time v0.9.0 // indirect
 	golang.org/x/tools v0.33.0 // indirect
-	google.golang.org/protobuf v1.36.3 // indirect
+	google.golang.org/protobuf v1.36.6 // indirect
 	gopkg.in/ini.v1 v1.67.0 // indirect
 	gopkg.in/yaml.v3 v3.0.1 // indirect
 	gorm.io/plugin/dbresolver v1.5.3 // indirect
@@ -143,4 +144,4 @@ require (
 	modernc.org/mathutil v1.7.1 // indirect
 	modernc.org/memory v1.8.2 // indirect
 	modernc.org/sqlite v1.34.5 // indirect
-)
+)

+ 27 - 0
server/go.sum

@@ -45,9 +45,13 @@ github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
 github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
 github.com/bytedance/sonic v1.12.7 h1:CQU8pxOy9HToxhndH0Kx/S1qU/CuS9GnKYrGioDcU1Q=
 github.com/bytedance/sonic v1.12.7/go.mod h1:tnbal4mxOMju17EGfknm2XyYcpyCnIROYOEYuemj13I=
+github.com/bytedance/sonic v1.13.3 h1:MS8gmaH16Gtirygw7jV91pDCN33NyMrPbN7qiYhEsF0=
+github.com/bytedance/sonic v1.13.3/go.mod h1:o68xyaF9u2gvVBuGHPlUVCy+ZfmNNO5ETf1+KgkJhz4=
 github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
 github.com/bytedance/sonic/loader v0.2.3 h1:yctD0Q3v2NOGfSWPLPvG2ggA2kV6TS6s4wioyEqssH0=
 github.com/bytedance/sonic/loader v0.2.3/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
+github.com/bytedance/sonic/loader v0.2.4 h1:ZWCw4stuXUsn1/+zQDqeE7JKP+QO47tz7QCNan80NzY=
+github.com/bytedance/sonic/loader v0.2.4/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
 github.com/casbin/casbin/v2 v2.103.0 h1:dHElatNXNrr8XcseUov0ZSiWjauwmZZE6YMV3eU1yic=
 github.com/casbin/casbin/v2 v2.103.0/go.mod h1:Ee33aqGrmES+GNL17L0h9X28wXuo829wnNUnS0edAco=
 github.com/casbin/casbin/v2 v2.107.0 h1:Kk1+9S2ou8aTTQd30L+vRvFBNf5YvbN65N5uCzdren8=
@@ -91,11 +95,17 @@ github.com/fvbock/endless v0.0.0-20170109170031-447134032cb6 h1:6VSn3hB5U5GeA6kQ
 github.com/fvbock/endless v0.0.0-20170109170031-447134032cb6/go.mod h1:YxOVT5+yHzKvwhsiSIWmbAYM3Dr9AEEbER2dVayfBkg=
 github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
 github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
+github.com/gabriel-vasile/mimetype v1.4.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY=
+github.com/gabriel-vasile/mimetype v1.4.9/go.mod h1:WnSQhFKJuBlRyLiKohA/2DtIlPFAbguNaG7QCHcyGok=
+github.com/gin-contrib/cors v1.7.6 h1:3gQ8GMzs1Ylpf70y8bMw4fVpycXIeX1ZemuSQIsnQQY=
+github.com/gin-contrib/cors v1.7.6/go.mod h1:Ulcl+xN4jel9t1Ry8vqph23a60FwH9xVLd+3ykmTjOk=
 github.com/gin-contrib/gzip v0.0.6 h1:NjcunTcGAj5CO1gn4N8jHOSIeRFHIbn51z6K+xaN4d4=
 github.com/gin-contrib/gzip v0.0.6/go.mod h1:QOJlmV2xmayAjkNS2Y8NQsMneuRShOU/kjovCXNuzzk=
 github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
 github.com/gin-contrib/sse v1.0.0 h1:y3bT1mUWUxDpW4JLQg/HnTqV4rozuW4tC9eFKTxYI9E=
 github.com/gin-contrib/sse v1.0.0/go.mod h1:zNuFdwarAygJBht0NTKiSi3jRf6RbqeILZ9Sp6Slhe0=
+github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
+github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
 github.com/gin-gonic/gin v1.6.3/go.mod h1:75u5sXoLsGZoRN5Sgbi1eraJ4GU3++wFwWzhwvtwp4M=
 github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
 github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
@@ -129,6 +139,8 @@ github.com/go-playground/validator/v10 v10.2.0/go.mod h1:uOYAAleCW8F/7oMFd6aG0GO
 github.com/go-playground/validator/v10 v10.4.1/go.mod h1:nlOn6nFhuKACm19sB/8EGNn9GlaMV7XkbRSipzJ0Ii4=
 github.com/go-playground/validator/v10 v10.24.0 h1:KHQckvo8G6hlWnrPX4NJJ+aBfWNAE/HH+qdL2cBpCmg=
 github.com/go-playground/validator/v10 v10.24.0/go.mod h1:GGzBIJMuE98Ic/kJsBXbz1x/7cByt++cQ+YOuDM5wus=
+github.com/go-playground/validator/v10 v10.26.0 h1:SP05Nqhjcvz81uJaRfEV0YBSSSGMc/iMaVtFbr3Sw2k=
+github.com/go-playground/validator/v10 v10.26.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
 github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
 github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
 github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
@@ -141,6 +153,8 @@ github.com/gobwas/pool v0.2.0/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6Wezm
 github.com/gobwas/ws v1.0.2/go.mod h1:szmBTxLgaFppYjEmNtny/v3w89xOydFnnZMcgRRu/EM=
 github.com/goccy/go-json v0.10.4 h1:JSwxQzIqKfmFX1swYPpUThQZp/Ka4wzJdK0LWVytLPM=
 github.com/goccy/go-json v0.10.4/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
+github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
+github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
 github.com/gofrs/uuid/v5 v5.0.0 h1:p544++a97kEL+svbcFbCQVM9KFu0Yo25UoISXGNNH9M=
 github.com/gofrs/uuid/v5 v5.0.0/go.mod h1:CDOjlDMVAtN56jqyRUZh58JT31Tiw7/oQyEXZV+9bD8=
 github.com/gofrs/uuid/v5 v5.3.2 h1:2jfO8j3XgSwlz/wHqemAEugfnTlikAYHhnqQ8Xh4fE0=
@@ -175,6 +189,7 @@ github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/
 github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
 github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
 github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
+github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
 github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck=
 github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8=
 github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU=
@@ -242,6 +257,8 @@ github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90
 github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
 github.com/klauspost/cpuid/v2 v2.2.9 h1:66ze0taIn2H33fBvCkXuv9BmCwDfafmiIVpKV9kKGuY=
 github.com/klauspost/cpuid/v2 v2.2.9/go.mod h1:rqkxqrZ1EhYM9G+hXH7YdowN5R5RGN6NK4QwQ3WMXF8=
+github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE=
+github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
 github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
 github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
 github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
@@ -293,6 +310,8 @@ github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdh
 github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
 github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M=
 github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc=
+github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
+github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
 github.com/pkg/browser v0.0.0-20210115035449-ce105d075bb4/go.mod h1:N6UoU20jOqggOuDwUaBQpluzLNDqif3kq9z2wpdYEfQ=
 github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8/go.mod h1:HKlIX3XHQyzLZPlr7++PzdhaXEj94dEiJgZDTsxEqUI=
 github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
@@ -389,6 +408,8 @@ github.com/ugorji/go v1.1.7/go.mod h1:kZn38zHttfInRq0xu/PH0az30d+z6vm202qpg1oXVM
 github.com/ugorji/go/codec v1.1.7/go.mod h1:Ax+UKWsSmolVDwsd+7N3ZtXu+yMGCf907BLYF3GoBXY=
 github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
 github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
+github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA=
+github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
 github.com/unrolled/secure v1.17.0 h1:Io7ifFgo99Bnh0J7+Q+qcMzWM6kaDPCA5FroFZEdbWU=
 github.com/unrolled/secure v1.17.0/go.mod h1:BmF5hyM6tXczk3MpQkFf1hpKSRqCyhqcbiQtiAF7+40=
 github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c=
@@ -423,6 +444,8 @@ go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
 go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
 golang.org/x/arch v0.13.0 h1:KCkqVVV1kGg0X87TFysjCJ8MxtZEIU4Ja/yXGeoECdA=
 golang.org/x/arch v0.13.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
+golang.org/x/arch v0.18.0 h1:WN9poc33zL4AzGxqf8VtpKUnGvMi8O9lhNyBMF/85qc=
+golang.org/x/arch v0.18.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
 golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
 golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
 golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
@@ -482,6 +505,8 @@ golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
 golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
 golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY=
 golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds=
+golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw=
+golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA=
 golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -580,6 +605,8 @@ golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8T
 golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 google.golang.org/protobuf v1.36.3 h1:82DV7MYdb8anAVi3qge1wSnMDrnKK7ebr+I0hHRN1BU=
 google.golang.org/protobuf v1.36.3/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
+google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
+google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
 gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
 gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=

+ 18 - 0
server/initialize/router.go

@@ -1,6 +1,7 @@
 package initialize
 
 import (
+	"github.com/gin-contrib/cors"
 	"github.com/gin-gonic/gin"
 	swaggerFiles "github.com/swaggo/files"
 	ginSwagger "github.com/swaggo/gin-swagger"
@@ -10,6 +11,7 @@ import (
 	"server/global"
 	"server/middleware"
 	"server/router"
+	"time"
 )
 
 type justFilesFilesystem struct {
@@ -39,6 +41,20 @@ func Routers() *gin.Engine {
 		Router.Use(gin.Logger())
 	}
 
+	// 关键:全局配置 CORS(必须在所有路由注册前添加)
+	Router.Use(cors.New(cors.Config{
+		// 1. 允许的前端域名:必须包含你的前端地址(http://localhost:8080)
+		AllowOrigins: []string{"http://localhost:8080"},
+		// 2. 允许的请求方法:HLS 请求是 GET,需包含;其他接口(POST)也需包含
+		AllowMethods: []string{"GET", "POST", "OPTIONS", "DELETE", "PUT"},
+		// 3. 允许的请求头:前端可能携带的头(如 Content-Type,若有 token 也需包含)
+		AllowHeaders: []string{"Origin", "Content-Type", "x-token", "x-user-id"},
+		// 4. 是否允许携带 Cookie(若前端需要传 Cookie 则设为 true)
+		AllowCredentials: true,
+		// 5. 预检请求的缓存时间(减少浏览器频繁发送预检请求)
+		MaxAge: 12 * time.Hour,
+	}))
+
 	InstallPlugin(Router) // 安装插件
 	systemRouter := router.RouterGroupApp.System
 	exampleRouter := router.RouterGroupApp.Example
@@ -85,6 +101,7 @@ func Routers() *gin.Engine {
 		systemRouter.InitSysOperationRecordRouter(PrivateGroup)  // 操作记录
 		systemRouter.InitSysDictionaryDetailRouter(PrivateGroup) // 字典详情管理
 		systemRouter.InitAuthorityBtnRouterRouter(PrivateGroup)  // 字典详情管理
+		systemRouter.InitStreamRouter(PrivateGroup, PublicGroup)
 
 		exampleRouter.InitFileUploadAndDownloadRouter(PrivateGroup)
 
@@ -93,6 +110,7 @@ func Routers() *gin.Engine {
 		devicesRouter.InitProgramRouter(PrivateGroup)
 		devicesRouter.InitSoundPeriodRouter(PrivateGroup)
 		devicesRouter.InitVoiceRouter(PrivateGroup)
+		devicesRouter.InitCameraRouter(PrivateGroup, PublicGroup)
 	}
 
 	global.GVA_LOG.Info("router register success")

+ 53 - 1
server/model/devices/common.go

@@ -1,6 +1,10 @@
 package devices
 
-import "server/dao"
+import (
+	"mime/multipart"
+	"server/dao"
+	"server/model/common/request"
+)
 
 type SearchInfo struct {
 	Page      int    `json:"page" form:"page"`           // 页码
@@ -79,3 +83,51 @@ type JSONForm struct {
 	Sn   string `json:"sn"`
 	Json string `json:"json"`
 }
+
+// CameraEndianHeartbeatRequest 摄像头心跳
+type CameraEndianHeartbeatRequest struct {
+	DevType     string `json:"DevType"`
+	DevName     string `json:"DevName"`
+	SerialNum   string `json:"SerialNum"`
+	LocalTime   string `json:"LocalTime"`
+	ReportCount int    `json:"ReportCount"`
+}
+
+type CameraEndianHeartbeatResponse struct {
+	ReturnCode   int  `json:"ReturnCode"`
+	PushEventPic bool `json:"PushEventPic"`
+}
+
+// 摄像头推送 -------------------------------------------------------------------------------
+
+// 表单绑定结构体:对应 multipart/form-data 中的字段
+
+type CameraEventForm struct {
+	// 匹配表单中的 "EventInfo" 字段(filename="EventInfo.json")
+	// binding:"required" 表示该字段必填
+	EventInfo *multipart.FileHeader `form:"EventInfo" binding:"required"`
+	File      *multipart.FileHeader `form:"file" binding:"omitempty"`
+}
+
+type CameraEndianEventRequest struct {
+	DevType        string      `json:"DevType"`
+	DevName        string      `json:"DevName"`
+	SerialNum      string      `json:"SerialNum"`
+	Channel        int         `json:"Channel"`
+	ChannelName    string      `json:"ChannelName"`
+	LocalTime      string      `json:"LocalTime"`
+	EventType      string      `json:"EventType"`
+	PersonInfo     interface{} `json:"PersonInfo"`
+	ElevatorInfo   interface{} `json:"ElevatorInfo"`
+	EbikeInfo      interface{} `json:"EbikeInfo"`
+	OverallImgInfo interface{} `json:"OverallImgInfo"`
+	AlarmInfo      interface{} `json:"AlarmInfo"`
+}
+
+type CameraEndianEventResponse struct {
+	ReturnCode int `json:"ReturnCode"`
+}
+
+type SearchCamera struct {
+	request.PageInfo
+}

+ 28 - 0
server/router/devices/camera.go

@@ -0,0 +1,28 @@
+package devices
+
+import (
+	"github.com/gin-gonic/gin"
+	v1 "server/api/v1"
+)
+
+type CameraRouter struct {
+}
+
+func (pr *CameraRouter) InitCameraRouter(Router *gin.RouterGroup, PubRouter *gin.RouterGroup) {
+	cameraRouter := Router.Group("camera")
+	cameraPubRouter := PubRouter.Group("camera")
+	cameraApi := v1.ApiGroupApp.DevicesApiGroup.CameraApi
+
+	{
+		cameraPubRouter.PUT("DeviceEndianHeartbeat", cameraApi.DeviceEndianHeartbeat)
+		cameraPubRouter.POST("DeviceEndianEvent", cameraApi.DeviceEndianEvent)
+	}
+
+	{
+		cameraRouter.GET("queryAllCameras", cameraApi.QueryAllCameras)
+		cameraRouter.POST("queryCameraList", cameraApi.QueryCameraList)
+		cameraRouter.POST("createCamera", cameraApi.CreateCamera)
+		cameraRouter.PUT("updateCamera", cameraApi.UpdateCamera)
+		cameraRouter.DELETE("deleteCamera", cameraApi.DeleteCamera)
+	}
+}

+ 1 - 0
server/router/devices/enter.go

@@ -5,4 +5,5 @@ type RouterGroup struct {
 	ProgramRouter
 	SoundPeriodRouter
 	VoiceRouter
+	CameraRouter
 }

+ 1 - 0
server/router/devices/screens.go

@@ -24,5 +24,6 @@ func (s *ScreensRouter) InitScreensRouter(Router *gin.RouterGroup) {
 	{
 		screensRouterWithoutRecord.POST("getScreensList", baseApi.GetScreensList) //获取显示屏列表
 		screensRouterWithoutRecord.GET("queryEventByUserId", baseApi.QueryEventByUserId)
+		screensRouterWithoutRecord.GET("queryAllScreens", baseApi.QueryAllScreens)
 	}
 }

+ 1 - 0
server/router/system/enter.go

@@ -12,4 +12,5 @@ type RouterGroup struct {
 	OperationRecordRouter
 	DictionaryDetailRouter
 	AuthorityBtnRouter
+	StreamRouter
 }

+ 33 - 0
server/router/system/stream.go

@@ -0,0 +1,33 @@
+package system
+
+import (
+	"github.com/gin-gonic/gin"
+	"server/api/v1/system"
+)
+
+// StreamRouter 流媒体路由结构体
+type StreamRouter struct{}
+
+// 全局API实例
+var streamApi = &system.StreamApi{}
+
+// InitStreamRouter 初始化流媒体路由
+func (s *StreamRouter) InitStreamRouter(Router *gin.RouterGroup, RouterPub *gin.RouterGroup) {
+	// 私有路由(需JWT鉴权):流控制接口
+	streamRouter := Router.Group("stream")
+
+	// 公开路由(无需鉴权):播放HLS流
+	streamPublicRouter := RouterPub.Group("stream")
+
+	{
+		// 公开路由:播放HLS流,支持.m3u8索引文件和.ts视频切片
+		streamPublicRouter.GET("/hls/:streamId/*any", streamApi.PlayHLS)
+	}
+
+	{
+		// 私有路由:流控制接口
+		streamRouter.POST("/start", streamApi.StartStream) // 启动RTSP转HLS流
+		streamRouter.POST("/stop", streamApi.StopStream)   // 停止流转换
+		streamRouter.GET("/list", streamApi.GetStreamList) // 获取活跃流列表
+	}
+}

+ 40 - 0
server/service/devices/camera.go

@@ -0,0 +1,40 @@
+package devices
+
+import (
+	"server/dao"
+	"server/model/devices"
+	"server/utils/cache"
+)
+
+type CameraService struct {
+}
+
+func (cs CameraService) QueryAllCameras() ([]dao.Camera, error) {
+	return dao.QueryAllCameras()
+}
+
+func (cs CameraService) QueryCameraList(info devices.SearchCamera) ([]dao.Camera, int64, error) {
+	limit := info.PageSize
+	offset := info.PageSize * (info.Page - 1)
+
+	cameras, total, err := dao.QueryCameraList(limit, offset)
+
+	for i, camera := range cameras {
+		status, _ := cache.GetCacheDeviceState(camera.SerialNum)
+		cameras[i].State = status
+	}
+
+	return cameras, total, err
+}
+
+func (cs CameraService) CreateCamera(camera dao.Camera) error {
+	return camera.CreateCamera()
+}
+
+func (cs CameraService) UpdateCamera(camera dao.Camera) error {
+	return camera.UpdateCamera()
+}
+
+func (cs CameraService) DeleteCamera(id int) error {
+	return dao.DeleteCamera(id)
+}

+ 1 - 0
server/service/devices/enter.go

@@ -5,4 +5,5 @@ type ServiceGroup struct {
 	ProgramService
 	SoundPeriodService
 	VoiceService
+	CameraService
 }

+ 4 - 0
server/service/devices/screens.go

@@ -36,3 +36,7 @@ func (s ScreensService) QueryEventByUserId(userId int) (data interface{}, err er
 func (s ScreensService) Sending(sn, json string) error {
 	return Sending(sn, json)
 }
+
+func (s ScreensService) QueryAllScreens() (interface{}, error) {
+	return dao.QueryAllScreens()
+}

+ 165 - 0
server/service/stream/stream.go

@@ -0,0 +1,165 @@
+package stream
+
+import (
+	"errors"
+	"fmt"
+	"go.uber.org/zap"
+	"net/http"
+	"os"
+	"os/exec"
+	"path/filepath"
+	"server/global"
+	"sync"
+)
+
+// StreamService 流媒体服务结构体
+type StreamService struct {
+	streamMap  map[string]*exec.Cmd // 存储活跃的流进程: streamId -> 进程
+	mutex      sync.RWMutex         // 保护streamMap的并发安全
+	hlsBaseDir string               // HLS文件存储根目录
+}
+
+// NewStreamService 创建流媒体服务实例
+func NewStreamService() *StreamService {
+	// 初始化HLS存储目录
+	hlsDir := filepath.Join(global.GVA_CONFIG.Local.StorePath, "hls")
+	if err := os.MkdirAll(hlsDir, 0755); err != nil {
+		global.GVA_LOG.Error("创建HLS目录失败", zap.Error(err))
+	}
+
+	return &StreamService{
+		streamMap:  make(map[string]*exec.Cmd),
+		hlsBaseDir: hlsDir,
+	}
+}
+
+// StartStream 启动RTSP转HLS流
+func (s *StreamService) StartStream(rtspUrl, streamId string) (string, error) {
+	s.mutex.Lock()
+	defer s.mutex.Unlock()
+
+	// 检查流是否已在运行
+	if _, exists := s.streamMap[streamId]; exists {
+		return s.getHLSUrl(streamId), nil
+	}
+
+	// 创建当前流的存储目录
+	streamDir := filepath.Join(s.hlsBaseDir, streamId)
+	if err := os.MkdirAll(streamDir, 0755); err != nil {
+		return "", fmt.Errorf("创建流目录失败: %v", err)
+	}
+
+	// 构建FFmpeg命令: RTSP转HLS
+	outputPath := filepath.Join(streamDir, "stream.m3u8")
+	cmd := exec.Command(
+		"ffmpeg",
+		"-rtsp_transport", "tcp", // 使用TCP传输,更稳定
+		"-i", rtspUrl, // 输入RTSP地址
+		"-c:v", "copy", // 视频不重新编码(快速)
+		"-c:a", "aac", // 音频转码为AAC(浏览器兼容)
+		"-f", "hls", // 输出格式为HLS
+		"-hls_time", "2", // 每个切片2秒
+		"-hls_list_size", "0", // 保留所有切片
+		"-hls_flags", "delete_segments", // 自动删除旧切片
+		outputPath,
+	)
+
+	// 启动进程
+	if err := cmd.Start(); err != nil {
+		os.RemoveAll(streamDir) // 启动失败,清理目录
+		return "", fmt.Errorf("启动FFmpeg失败: %v", err)
+	}
+
+	// 存储进程引用
+	s.streamMap[streamId] = cmd
+	global.GVA_LOG.Info("启动流转换成功", zap.String("streamId", streamId))
+
+	// 启动goroutine监控进程状态
+	go s.monitorStream(streamId, cmd, streamDir)
+
+	return s.getHLSUrl(streamId), nil
+}
+
+// StopStream 停止流转换
+func (s *StreamService) StopStream(streamId string) error {
+	s.mutex.Lock()
+	defer s.mutex.Unlock()
+
+	cmd, exists := s.streamMap[streamId]
+	if !exists {
+		return errors.New("流不存在或已停止")
+	}
+
+	// 终止进程
+	if err := cmd.Process.Kill(); err != nil {
+		global.GVA_LOG.Warn("终止流进程失败", zap.String("streamId", streamId), zap.Error(err))
+	}
+
+	// 清理资源
+	delete(s.streamMap, streamId)
+	streamDir := filepath.Join(s.hlsBaseDir, streamId)
+	if err := os.RemoveAll(streamDir); err != nil {
+		global.GVA_LOG.Warn("清理流目录失败", zap.String("streamId", streamId), zap.Error(err))
+	}
+
+	global.GVA_LOG.Info("停止流转换成功", zap.String("streamId", streamId))
+	return nil
+}
+
+// PlayHLS 提供HLS流文件访问
+func (s *StreamService) PlayHLS(w http.ResponseWriter, r *http.Request, streamId, filePath string) {
+	// 构建文件路径
+	filePath = filepath.Join(s.hlsBaseDir, streamId, filePath)
+
+	// 检查文件是否存在
+	if _, err := os.Stat(filePath); os.IsNotExist(err) {
+		http.NotFound(w, r)
+		return
+	}
+
+	// 设置MIME类型
+	switch filepath.Ext(filePath) {
+	case ".m3u8":
+		w.Header().Set("Content-Type", "application/x-mpegURL")
+	case ".ts":
+		w.Header().Set("Content-Type", "video/MP2T")
+	}
+
+	// 提供文件下载
+	http.ServeFile(w, r, filePath)
+}
+
+// GetActiveStreams 获取活跃流列表
+func (s *StreamService) GetActiveStreams() []string {
+	s.mutex.RLock()
+	defer s.mutex.RUnlock()
+
+	list := make([]string, 0, len(s.streamMap))
+	for streamId := range s.streamMap {
+		list = append(list, streamId)
+	}
+	return list
+}
+
+// 监控流进程状态,异常退出时清理资源
+func (s *StreamService) monitorStream(streamId string, cmd *exec.Cmd, streamDir string) {
+	// 等待进程退出
+	if err := cmd.Wait(); err != nil {
+		global.GVA_LOG.Error("流进程异常退出", zap.String("streamId", streamId), zap.Error(err))
+	}
+
+	// 清理资源
+	s.mutex.Lock()
+	defer s.mutex.Unlock()
+	if _, exists := s.streamMap[streamId]; exists {
+		delete(s.streamMap, streamId)
+		os.RemoveAll(streamDir)
+	}
+}
+
+// 获取HLS流的访问URL
+func (s *StreamService) getHLSUrl(streamId string) string {
+	return fmt.Sprintf("/%s/stream/hls/%s/stream.m3u8",
+		global.GVA_CONFIG.System.RouterPrefix,
+		streamId)
+}

+ 67 - 0
server/utils/cache/device_redis.go

@@ -0,0 +1,67 @@
+package cache
+
+import (
+	"context"
+	"encoding/json"
+	"fmt"
+	"github.com/redis/go-redis/v9"
+	"server/global"
+	"server/utils/common"
+	"time"
+)
+
+var Redis redis.UniversalClient
+
+func init() {
+	Redis = global.GVA_REDIS
+}
+
+const (
+	//设备在线状态
+	DeviceStateKey = "dev_state_%s"
+)
+
+func CacheDeviceInfoKey(sn string) string {
+	return fmt.Sprintf(DeviceStateKey, sn)
+}
+
+type CacheDeviceInfo struct {
+	LastTime *common.Time //同步时间
+	Status   int          //0不在线 1在线
+}
+
+// UpdateDeviceState 更新设备缓存的状态
+func UpdateDeviceState(sn string, status int) bool {
+	lastTime := common.Time(time.Now())
+	deviceInfo, _ := json.Marshal(CacheDeviceInfo{
+		LastTime: &lastTime,
+		Status:   status,
+	})
+	if global.GVA_REDIS == nil {
+		return false
+	}
+	err := global.GVA_REDIS.Set(context.Background(), CacheDeviceInfoKey(sn), deviceInfo, 0)
+	if err.Err() != nil {
+		global.GVA_LOG.Error(err.String())
+		return false
+	}
+	return true
+}
+
+// GetCacheDeviceState 取缓存中的值
+func GetCacheDeviceState(sn string) (status int, lastTime *common.Time) {
+	if global.GVA_REDIS == nil {
+		return 0, nil
+	}
+	result, _ := global.GVA_REDIS.Get(context.Background(), CacheDeviceInfoKey(sn)).Result()
+	if result == "" {
+		return 0, nil
+	}
+	cacheInfo := CacheDeviceInfo{}
+	err := json.Unmarshal([]byte(result), &cacheInfo)
+	if err != nil {
+		global.GVA_LOG.Error(err.Error())
+		return 0, nil
+	}
+	return cacheInfo.Status, cacheInfo.LastTime
+}

+ 164 - 0
server/utils/common/common.go

@@ -0,0 +1,164 @@
+package common
+
+import (
+	"fmt"
+	"math/rand"
+	"net"
+	"net/http"
+	"strconv"
+	"strings"
+	"time"
+)
+
+func StringToInt(id string) int {
+	if id != "" {
+		id, err := strconv.Atoi(id)
+		if err == nil {
+			return id
+		}
+	}
+	return -1
+}
+
+func RandomString(n int) string {
+	var letters = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789")
+	rand.Seed(time.Now().Unix())
+	b := make([]rune, n)
+	for i := range b {
+		b[i] = letters[rand.Intn(len(letters))]
+	}
+	return string(b)
+}
+
+func RandomString2(n int) string {
+	var letters = []rune("ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789")
+	rand.Seed(time.Now().Unix())
+	b := make([]rune, n)
+	for i := range b {
+		b[i] = letters[rand.Intn(len(letters))]
+	}
+	return string(b)
+}
+
+func StringToInt64Array(str string) []int64 {
+	tmp := strings.Split(str, ",")
+	var result []int64
+	for _, t := range tmp {
+		i, _ := strconv.ParseInt(t, 10, 64)
+		result = append(result, i)
+	}
+	return result
+}
+
+func IntInArray(value int, arr []int) bool {
+	for _, i := range arr {
+		if value == i {
+			return true
+		}
+	}
+	return false
+}
+
+func StringToIntArray(value string) []int {
+	var result []int
+	arr := strings.Split(value, ",")
+	for _, a := range arr {
+		i, _ := strconv.Atoi(a)
+		result = append(result, i)
+	}
+	return result
+}
+
+func CheckTimesHasOverlap(condition1Start, condition1End, condition2Start, condition2End float64) bool {
+	//时间不能都为0
+	if condition1Start == 0 && condition1End == 0 && condition2Start == 0 && condition2End == 0 {
+		return true
+	}
+	//不能2个都跨天
+	if condition1Start > condition1End && condition2Start > condition2End {
+		return true
+	}
+	//开始结束时间不能一致
+	if condition1Start == condition2End || condition2Start == condition1End || condition1Start == condition1End || condition2Start == condition2End {
+		return true
+	}
+	//时控1跨天时不能重叠
+	if condition1Start > condition1End {
+		return condition2Start < condition1End || condition2End > condition1Start
+	}
+	//时控2跨天时不能重叠
+	if condition2Start > condition2End {
+		return condition1Start < condition2End || condition1End > condition2Start
+	}
+	//都不跨天时不能重叠
+	return !(condition2End < condition1Start || condition2Start > condition1End)
+}
+
+func CheckHourInvalid(hour float64) bool {
+	if hour < 0 || hour > 24 {
+		return true
+	}
+	return false
+}
+
+func MlParseTime(strTime string) (time.Time, error) {
+	if strings.Contains(strTime, ".") {
+		return time.ParseInLocation("2006-01-02 15:04:05.000", strTime, time.Local)
+	}
+	return time.ParseInLocation("2006-01-02 15:04:05", strTime, time.Local)
+}
+
+// ControlRelayId 返回回路名
+func ControlRelayId(deviceSn string, relayId int) string {
+	if relayId == -1 {
+		return "全部回路"
+	}
+	return fmt.Sprintf("回路%v", relayId)
+}
+
+// ControlRelaysStatus 返回回路操作状态
+func ControlRelaysStatus(deviceSn string, status []uint16) string {
+	statusInt := status[0:1]
+	if statusInt[0] == 1 {
+		return "开"
+	}
+	return "关"
+}
+func GetClientIp(ip string) string {
+	if ip != "" {
+		return ip
+	}
+	addrs, err := net.InterfaceAddrs()
+	if err != nil {
+		return "none"
+	}
+	for _, address := range addrs {
+		// 检查ip地址判断是否回环地址
+		if ipnet, ok := address.(*net.IPNet); ok && !ipnet.IP.IsLoopback() {
+			if ipnet.IP.To4() != nil {
+				return ipnet.IP.String()
+			}
+
+		}
+	}
+	return "none"
+}
+
+func ClientIP(r *http.Request) string {
+	xForwardedFor := r.Header.Get("X-Forwarded-For")
+	ip := strings.TrimSpace(strings.Split(xForwardedFor, ",")[0])
+	if ip != "" {
+		return ip
+	}
+
+	ip = strings.TrimSpace(r.Header.Get("X-Real-Ip"))
+	if ip != "" {
+		return ip
+	}
+
+	if ip, _, err := net.SplitHostPort(strings.TrimSpace(r.RemoteAddr)); err == nil {
+		return ip
+	}
+
+	return ""
+}

+ 52 - 0
server/utils/common/errors.go

@@ -0,0 +1,52 @@
+package common
+
+type Errors struct {
+	Code int         `json:"code"`
+	Msg  string      `json:"msg"`
+	Data interface{} `json:"data"`
+}
+
+const (
+	CodeSucceed  = 0    //成功
+	CodeInternal = 9999 //内部错误
+
+	CodeParamsInvalid    = 10001 //非法参数
+	CodeOperationInvalid = 10002 //非法操作
+)
+
+const (
+	Success = "success"
+	Fail    = "fail"
+)
+
+func SuccessResponse(data interface{}) *Errors {
+	return &Errors{
+		Code: CodeSucceed,
+		Msg:  Success,
+		Data: data,
+	}
+}
+
+func FailResponse(msg string, data interface{}) *Errors {
+	return &Errors{
+		Code: CodeInternal,
+		Msg:  msg,
+		Data: data,
+	}
+}
+
+func ParamsInvalidResponse(msg string, data interface{}) *Errors {
+	return &Errors{
+		Code: CodeParamsInvalid,
+		Msg:  msg,
+		Data: data,
+	}
+}
+
+func NormalResponse(code int, msg string, data interface{}) *Errors {
+	return &Errors{
+		Code: code,
+		Msg:  msg,
+		Data: data,
+	}
+}

+ 90 - 0
server/utils/common/mytime.go

@@ -0,0 +1,90 @@
+package common
+
+import (
+	"time"
+)
+
+var bjlocation *time.Location
+
+func init() {
+	loc, err := time.LoadLocation("Asia/Shanghai")
+	if err != nil {
+		bjlocation = time.FixedZone("CST", 8*3600)
+	} else {
+		bjlocation = loc
+	}
+}
+
+func Unix2Time(sec int64) string {
+	return time.Unix(sec, 0).Format("2006-01-02 15:04:05")
+}
+
+func MlNow() time.Time {
+	return time.Now().In(bjlocation)
+}
+
+func MlParseTimeX(layout, strTime string) (time.Time, error) {
+	return time.ParseInLocation(layout, strTime, bjlocation)
+}
+
+type MLTime time.Time
+
+const (
+	timeFormart = "2006-01-02 15:04:05"
+)
+
+func (t *MLTime) UnmarshalJSON(data []byte) (err error) {
+	now, err := time.ParseInLocation(`"`+timeFormart+`"`, string(data), bjlocation)
+	*t = MLTime(now)
+	return
+}
+
+func (t MLTime) MarshalJSON() ([]byte, error) {
+	b := make([]byte, 0, len(timeFormart)+2)
+	b = append(b, '"')
+	tt := time.Time(t)
+	if !tt.IsZero() {
+		b = tt.AppendFormat(b, timeFormart)
+	}
+	b = append(b, '"')
+	return b, nil
+}
+
+func (t MLTime) String() string {
+	tt := time.Time(t)
+	if tt.IsZero() {
+		return ""
+	}
+	return tt.Format(timeFormart)
+}
+
+type MLTimeEx time.Time
+
+const (
+	timeFormartEx = "2006-01-02 15:04:05.000"
+)
+
+func (t *MLTimeEx) UnmarshalJSON(data []byte) (err error) {
+	now, err := time.ParseInLocation(`"`+timeFormartEx+`"`, string(data), bjlocation)
+	*t = MLTimeEx(now)
+	return
+}
+
+func (t MLTimeEx) MarshalJSON() ([]byte, error) {
+	b := make([]byte, 0, len(timeFormartEx)+2)
+	b = append(b, '"')
+	tt := time.Time(t)
+	if !tt.IsZero() {
+		b = tt.AppendFormat(b, timeFormartEx)
+	}
+	b = append(b, '"')
+	return b, nil
+}
+
+func (t MLTimeEx) String() string {
+	tt := time.Time(t)
+	if tt.IsZero() {
+		return ""
+	}
+	return tt.Format(timeFormartEx)
+}

+ 279 - 0
server/utils/common/now.go

@@ -0,0 +1,279 @@
+package common
+
+import (
+	"errors"
+	"regexp"
+	"time"
+)
+
+func (now *Now) BeginningOfMinute() time.Time {
+	return now.Truncate(time.Minute)
+}
+
+func (now *Now) BeginningOfHour() time.Time {
+	y, m, d := now.Date()
+	return time.Date(y, m, d, now.Time.Hour(), 0, 0, 0, now.Time.Location())
+}
+
+func (now *Now) BeginningOfDay() time.Time {
+	y, m, d := now.Date()
+	return time.Date(y, m, d, 0, 0, 0, 0, now.Time.Location())
+}
+
+func (now *Now) BeginningOfWeek() time.Time {
+	t := now.BeginningOfDay()
+	weekday := int(t.Weekday())
+
+	if WeekStartDay != time.Sunday {
+		weekStartDayInt := int(WeekStartDay)
+
+		if weekday < weekStartDayInt {
+			weekday = weekday + 7 - weekStartDayInt
+		} else {
+			weekday = weekday - weekStartDayInt
+		}
+	}
+	return t.AddDate(0, 0, -weekday)
+}
+
+func (now *Now) BeginningOfMonth() time.Time {
+	y, m, _ := now.Date()
+	return time.Date(y, m, 1, 0, 0, 0, 0, now.Location())
+}
+
+func (now *Now) BeginningOfQuarter() time.Time {
+	month := now.BeginningOfMonth()
+	offset := (int(month.Month()) - 1) % 3
+	return month.AddDate(0, -offset, 0)
+}
+
+func (now *Now) BeginningOfYear() time.Time {
+	y, _, _ := now.Date()
+	return time.Date(y, time.January, 1, 0, 0, 0, 0, now.Location())
+}
+
+func (now *Now) EndOfMinute() time.Time {
+	return now.BeginningOfMinute().Add(time.Minute - time.Nanosecond)
+}
+
+func (now *Now) EndOfHour() time.Time {
+	return now.BeginningOfHour().Add(time.Hour - time.Nanosecond)
+}
+
+func (now *Now) EndOfDay() time.Time {
+	y, m, d := now.Date()
+	return time.Date(y, m, d, 23, 59, 59, int(time.Second-time.Nanosecond), now.Location())
+}
+
+func (now *Now) EndOfWeek() time.Time {
+	return now.BeginningOfWeek().AddDate(0, 0, 7).Add(-time.Nanosecond)
+}
+
+func (now *Now) EndOfMonth() time.Time {
+	return now.BeginningOfMonth().AddDate(0, 1, 0).Add(-time.Nanosecond)
+}
+
+func (now *Now) EndOfQuarter() time.Time {
+	return now.BeginningOfQuarter().AddDate(0, 3, 0).Add(-time.Nanosecond)
+}
+
+func (now *Now) EndOfYear() time.Time {
+	return now.BeginningOfYear().AddDate(1, 0, 0).Add(-time.Nanosecond)
+}
+
+func (now *Now) Monday() time.Time {
+	t := now.BeginningOfDay()
+	weekday := int(t.Weekday())
+	if weekday == 0 {
+		weekday = 7
+	}
+	return t.AddDate(0, 0, -weekday+1)
+}
+
+func (now *Now) Sunday() time.Time {
+	t := now.BeginningOfDay()
+	weekday := int(t.Weekday())
+	if weekday == 0 {
+		return t
+	}
+	return t.AddDate(0, 0, (7 - weekday))
+}
+
+func (now *Now) EndOfSunday() time.Time {
+	return New(now.Sunday()).EndOfDay()
+}
+
+func parseWithFormat(str string) (t time.Time, err error) {
+	for _, format := range TimeFormats {
+		t, err = time.Parse(format, str)
+		if err == nil {
+			return
+		}
+	}
+	err = errors.New("Can't parse string as time: " + str)
+	return
+}
+
+var hasTimeRegexp = regexp.MustCompile(`(\s+|^\s*)\d{1,2}((:\d{1,2})*|((:\d{1,2}){2}\.(\d{3}|\d{6}|\d{9})))\s*$`)
+var onlyTimeRegexp = regexp.MustCompile(`^\s*\d{1,2}((:\d{1,2})*|((:\d{1,2}){2}\.(\d{3}|\d{6}|\d{9})))\s*$`)
+
+func (now *Now) Parse(strs ...string) (t time.Time, err error) {
+	var (
+		setCurrentTime  bool
+		parseTime       []int
+		currentTime     = []int{now.Nanosecond(), now.Second(), now.Minute(), now.Hour(), now.Day(), int(now.Month()), now.Year()}
+		currentLocation = now.Location()
+		onlyTimeInStr   = true
+	)
+
+	for _, str := range strs {
+		hasTimeInStr := hasTimeRegexp.MatchString(str)
+		onlyTimeInStr = hasTimeInStr && onlyTimeInStr && onlyTimeRegexp.MatchString(str)
+		if t, err = parseWithFormat(str); err == nil {
+			location := t.Location()
+			if location.String() == "UTC" {
+				location = currentLocation
+			}
+
+			parseTime = []int{t.Nanosecond(), t.Second(), t.Minute(), t.Hour(), t.Day(), int(t.Month()), t.Year()}
+
+			for i, v := range parseTime {
+				if hasTimeInStr && i <= 3 {
+					continue
+				}
+
+				if v == 0 {
+					if setCurrentTime {
+						parseTime[i] = currentTime[i]
+					}
+				} else {
+					setCurrentTime = true
+				}
+
+				if onlyTimeInStr {
+					if i == 4 || i == 5 {
+						parseTime[i] = currentTime[i]
+						continue
+					}
+				}
+			}
+
+			t = time.Date(parseTime[6], time.Month(parseTime[5]), parseTime[4], parseTime[3], parseTime[2], parseTime[1], parseTime[0], location)
+			currentTime = []int{t.Nanosecond(), t.Second(), t.Minute(), t.Hour(), t.Day(), int(t.Month()), t.Year()}
+		}
+	}
+	return
+}
+
+func (now *Now) MustParse(strs ...string) (t time.Time) {
+	t, err := now.Parse(strs...)
+	if err != nil {
+		panic(err)
+	}
+	return t
+}
+
+func (now *Now) Between(begin, end string) bool {
+	beginTime := now.MustParse(begin)
+	endTime := now.MustParse(end)
+	return now.After(beginTime) && now.Before(endTime)
+}
+
+var WeekStartDay = time.Sunday
+
+var TimeFormats = []string{"1/2/2006", "1/2/2006 15:4:5", "2006", "2006-1", "2006-1-2", "2006-1-2 15", "2006-1-2 15:4", "2006-1-2 15:4:5", "1-2", "15:4:5", "15:4", "15", "15:4:5 Jan 2, 2006 MST", "2006-01-02 15:04:05.999999999 -0700 MST"}
+
+type Now struct {
+	time.Time
+}
+
+func New(t time.Time) *Now {
+	return &Now{t}
+}
+
+func BeginningOfMinute() time.Time {
+	return New(time.Now()).BeginningOfMinute()
+}
+
+func BeginningOfHour() time.Time {
+	return New(time.Now()).BeginningOfHour()
+}
+
+func BeginningOfDay() time.Time {
+	return New(time.Now()).BeginningOfDay()
+}
+
+func BeginningOfWeek() time.Time {
+	return New(time.Now()).BeginningOfWeek()
+}
+
+func BeginningOfMonth() time.Time {
+	return New(time.Now()).BeginningOfMonth()
+}
+
+func BeginningOfQuarter() time.Time {
+	return New(time.Now()).BeginningOfQuarter()
+}
+
+func BeginningOfYear() time.Time {
+	return New(time.Now()).BeginningOfYear()
+}
+
+func EndOfMinute() time.Time {
+	return New(time.Now()).EndOfMinute()
+}
+
+func EndOfHour() time.Time {
+	return New(time.Now()).EndOfHour()
+}
+
+func EndOfDay() time.Time {
+	return New(time.Now()).EndOfDay()
+}
+
+func EndOfWeek() time.Time {
+	return New(time.Now()).EndOfWeek()
+}
+
+func EndOfMonth() time.Time {
+	return New(time.Now()).EndOfMonth()
+}
+
+func EndOfQuarter() time.Time {
+	return New(time.Now()).EndOfQuarter()
+}
+
+func EndOfYear() time.Time {
+	return New(time.Now()).EndOfYear()
+}
+
+func Monday() time.Time {
+	return New(time.Now()).Monday()
+}
+
+func Sunday() time.Time {
+	return New(time.Now()).Sunday()
+}
+
+func EndOfSunday() time.Time {
+	return New(time.Now()).EndOfSunday()
+}
+
+func Parse(strs ...string) (time.Time, error) {
+	return New(time.Now()).Parse(strs...)
+}
+
+func ParseInLocation(loc *time.Location, strs ...string) (time.Time, error) {
+	return New(time.Now().In(loc)).Parse(strs...)
+}
+
+func MustParse(strs ...string) time.Time {
+	return New(time.Now()).MustParse(strs...)
+}
+
+func MustParseInLocation(loc *time.Location, strs ...string) time.Time {
+	return New(time.Now().In(loc)).MustParse(strs...)
+}
+func Between(time1, time2 string) bool {
+	return New(time.Now()).Between(time1, time2)
+}

+ 446 - 0
server/utils/common/sunrisesunset.go

@@ -0,0 +1,446 @@
+// Package sunrisesunset should be used to calculate the apparent sunrise and sunset based on the latitude, longitude, UTC offset and date.
+// All calculations (formulas) were extracted from the Solar Calculation Details of the Earth System Research Laboratory:
+// https://www.esrl.noaa.gov/gmd/grad/solcalc/calcdetails.html
+package common
+
+import (
+	"errors"
+	"math"
+	"time"
+)
+
+// The Parameters struct can also be used to manipulate
+// the data and get the sunrise and sunset
+type Parameters struct {
+	Latitude  float64
+	Longitude float64
+	UtcOffset float64
+	Date      time.Time
+}
+
+// Just call the 'general' GetSunriseSunset function and return the results
+func (p *Parameters) GetSunriseSunset() (time.Time, time.Time, error) {
+	return GetSunriseSunset(p.Latitude, p.Longitude, p.UtcOffset, p.Date)
+}
+
+// Convert radians to degrees
+func rad2deg(radians float64) float64 {
+	return radians * (180.0 / math.Pi)
+}
+
+// Convert degrees to radians
+func deg2rad(degrees float64) float64 {
+	return degrees * (math.Pi / 180.0)
+}
+
+// Creates a vector with the seconds normalized to the range 0~1.
+// seconds - The number of seconds will be normalized to 1
+// Return A vector with the seconds normalized to 0~1
+func createSecondsNormalized(seconds int) (vector []float64) {
+	for index := 0; index < seconds; index++ {
+		temp := float64(index) / float64(seconds-1)
+		vector = append(vector, temp)
+	}
+	return
+}
+
+// Calculate Julian Day based on the formula: nDays+2415018.5+secondsNorm-UTCoff/24
+// numDays - The number of days calculated in the calculate function
+// secondsNorm - Seconds normalized calculated by the createSecondsNormalized function
+// utcOffset - UTC offset defined by the user
+// Return Julian day slice
+func calcJulianDay(numDays int64, secondsNorm []float64, utcOffset float64) (julianDay []float64) {
+	for index := 0; index < len(secondsNorm); index++ {
+		temp := float64(numDays) + 2415018.5 + secondsNorm[index] - utcOffset/24.0
+		julianDay = append(julianDay, temp)
+	}
+	return
+}
+
+// Calculate the Julian Century based on the formula: (julianDay - 2451545.0) / 36525.0
+// julianDay - Julian day vector calculated by the calcJulianDay function
+// Return Julian century slice
+func calcJulianCentury(julianDay []float64) (julianCentury []float64) {
+	for index := 0; index < len(julianDay); index++ {
+		temp := (julianDay[index] - 2451545.0) / 36525.0
+		julianCentury = append(julianCentury, temp)
+	}
+	return
+}
+
+// Calculate the Geom Mean Long Sun in degrees based on the formula: 280.46646 + julianCentury * (36000.76983 + julianCentury * 0.0003032)
+// julianCentury - Julian century calculated by the calcJulianCentury function
+// Return The Geom Mean Long Sun slice
+func calcGeomMeanLongSun(julianCentury []float64) (geomMeanLongSun []float64) {
+	for index := 0; index < len(julianCentury); index++ {
+		a := 280.46646 + julianCentury[index]*(36000.76983+julianCentury[index]*0.0003032)
+		temp := math.Mod(a, 360.0)
+		geomMeanLongSun = append(geomMeanLongSun, temp)
+	}
+	return
+}
+
+// Calculate the Geom Mean Anom Sun in degrees based on the formula: 357.52911 + julianCentury * (35999.05029 - 0.0001537 * julianCentury)
+// julianCentury - Julian century calculated by the calcJulianCentury function
+// Return The Geom Mean Anom Sun slice
+func calcGeomMeanAnomSun(julianCentury []float64) (geomMeanAnomSun []float64) {
+	for index := 0; index < len(julianCentury); index++ {
+		temp := 357.52911 + julianCentury[index]*(35999.05029-0.0001537*julianCentury[index])
+		geomMeanAnomSun = append(geomMeanAnomSun, temp)
+	}
+	return
+}
+
+// Calculate the Eccent Earth Orbit based on the formula: 0.016708634 - julianCentury * (0.000042037 + 0.0000001267 * julianCentury)
+// julianCentury - Julian century calculated by the calcJulianCentury function
+// Return The Eccent Earth Orbit slice
+func calcEccentEarthOrbit(julianCentury []float64) (eccentEarthOrbit []float64) {
+	for index := 0; index < len(julianCentury); index++ {
+		temp := 0.016708634 - julianCentury[index]*(0.000042037+0.0000001267*julianCentury[index])
+		eccentEarthOrbit = append(eccentEarthOrbit, temp)
+	}
+	return
+}
+
+// Calculate the Sun Eq Ctr based on the formula: sin(deg2rad(geomMeanAnomSun))*(1.914602-julianCentury*(0.004817+0.000014*julianCentury))+sin(deg2rad(2*geomMeanAnomSun))*(0.019993-0.000101*julianCentury)+sin(deg2rad(3*geomMeanAnomSun))*0.000289;
+// julianCentury - Julian century calculated by the calcJulianCentury function
+// geomMeanAnomSun - Geom Mean Anom Sun calculated by the calcGeomMeanAnomSun function
+// Return The Sun Eq Ctr slice
+func calcSunEqCtr(julianCentury []float64, geomMeanAnomSun []float64) (sunEqCtr []float64) {
+	if len(julianCentury) != len(geomMeanAnomSun) {
+		return
+	}
+
+	for index := 0; index < len(julianCentury); index++ {
+		temp := math.Sin(deg2rad(geomMeanAnomSun[index]))*(1.914602-julianCentury[index]*(0.004817+0.000014*julianCentury[index])) + math.Sin(deg2rad(2*geomMeanAnomSun[index]))*(0.019993-0.000101*julianCentury[index]) + math.Sin(deg2rad(3*geomMeanAnomSun[index]))*0.000289
+		sunEqCtr = append(sunEqCtr, temp)
+	}
+	return
+}
+
+// Calculate the Sun True Long in degrees based on the formula: sunEqCtr + geomMeanLongSun
+// sunEqCtr - Sun Eq Ctr calculated by the calcSunEqCtr function
+// geomMeanLongSun - Geom Mean Long Sun calculated by the calcGeomMeanLongSun function
+// Return The Sun True Long slice
+func calcSunTrueLong(sunEqCtr []float64, geomMeanLongSun []float64) (sunTrueLong []float64) {
+	if len(sunEqCtr) != len(geomMeanLongSun) {
+		return
+	}
+
+	for index := 0; index < len(sunEqCtr); index++ {
+		temp := sunEqCtr[index] + geomMeanLongSun[index]
+		sunTrueLong = append(sunTrueLong, temp)
+	}
+	return
+}
+
+// Calculate the Sun App Long in degrees based on the formula: sunTrueLong-0.00569-0.00478*sin(deg2rad(125.04-1934.136*julianCentury))
+// sunTrueLong - Sun True Long calculated by the calcSunTrueLong function
+// julianCentury - Julian century calculated by the calcJulianCentury function
+// Return The Sun App Long slice
+func calcSunAppLong(sunTrueLong []float64, julianCentury []float64) (sunAppLong []float64) {
+	if len(sunTrueLong) != len(julianCentury) {
+		return
+	}
+
+	for index := 0; index < len(sunTrueLong); index++ {
+		temp := sunTrueLong[index] - 0.00569 - 0.00478*math.Sin(deg2rad(125.04-1934.136*julianCentury[index]))
+		sunAppLong = append(sunAppLong, temp)
+	}
+	return
+}
+
+// Calculate the Mean Obliq Ecliptic in degrees based on the formula: 23+(26+((21.448-julianCentury*(46.815+julianCentury*(0.00059-julianCentury*0.001813))))/60)/60
+// julianCentury - Julian century calculated by the calcJulianCentury function
+// Return the Mean Obliq Ecliptic slice
+func calcMeanObliqEcliptic(julianCentury []float64) (meanObliqEcliptic []float64) {
+	for index := 0; index < len(julianCentury); index++ {
+		temp := 23.0 + (26.0+(21.448-julianCentury[index]*(46.815+julianCentury[index]*(0.00059-julianCentury[index]*0.001813)))/60.0)/60.0
+		meanObliqEcliptic = append(meanObliqEcliptic, temp)
+	}
+	return
+}
+
+// Calculate the Obliq Corr in degrees based on the formula: meanObliqEcliptic+0.00256*cos(deg2rad(125.04-1934.136*julianCentury))
+// meanObliqEcliptic - Mean Obliq Ecliptic calculated by the calcMeanObliqEcliptic function
+// julianCentury - Julian century calculated by the calcJulianCentury function
+// Return the Obliq Corr slice
+func calcObliqCorr(meanObliqEcliptic []float64, julianCentury []float64) (obliqCorr []float64) {
+	if len(meanObliqEcliptic) != len(julianCentury) {
+		return
+	}
+
+	for index := 0; index < len(julianCentury); index++ {
+		temp := meanObliqEcliptic[index] + 0.00256*math.Cos(deg2rad(125.04-1934.136*julianCentury[index]))
+		obliqCorr = append(obliqCorr, temp)
+	}
+	return
+}
+
+// Calculate the Sun Declination in degrees based on the formula: rad2deg(asin(sin(deg2rad(obliqCorr))*sin(deg2rad(sunAppLong))))
+// obliqCorr - Obliq Corr calculated by the calcObliqCorr function
+// sunAppLong - Sun App Long calculated by the calcSunAppLong function
+// Return the sun declination slice
+func calcSunDeclination(obliqCorr []float64, sunAppLong []float64) (sunDeclination []float64) {
+	if len(obliqCorr) != len(sunAppLong) {
+		return
+	}
+
+	for index := 0; index < len(obliqCorr); index++ {
+		temp := rad2deg(math.Asin(math.Sin(deg2rad(obliqCorr[index])) * math.Sin(deg2rad(sunAppLong[index]))))
+		sunDeclination = append(sunDeclination, temp)
+	}
+	return
+}
+
+// Calculate the equation of time (minutes) based on the formula:
+// 4*rad2deg(multiFactor*sin(2*deg2rad(geomMeanLongSun))-2*eccentEarthOrbit*sin(deg2rad(geomMeanAnomSun))+4*eccentEarthOrbit*multiFactor*sin(deg2rad(geomMeanAnomSun))*cos(2*deg2rad(geomMeanLongSun))-0.5*multiFactor*multiFactor*sin(4*deg2rad(geomMeanLongSun))-1.25*eccentEarthOrbit*eccentEarthOrbit*sin(2*deg2rad(geomMeanAnomSun)))
+// multiFactor - The Multi Factor vector calculated in the calculate function
+// geomMeanLongSun - The Geom Mean Long Sun vector calculated by the calcGeomMeanLongSun function
+// eccentEarthOrbit - The Eccent Earth vector calculated by the calcEccentEarthOrbit function
+// geomMeanAnomSun - The Geom Mean Anom Sun vector calculated by the calcGeomMeanAnomSun function
+// Return the equation of time slice
+func calcEquationOfTime(multiFactor []float64, geomMeanLongSun []float64, eccentEarthOrbit []float64, geomMeanAnomSun []float64) (equationOfTime []float64) {
+
+	if len(multiFactor) != len(geomMeanLongSun) ||
+		len(multiFactor) != len(eccentEarthOrbit) ||
+		len(multiFactor) != len(geomMeanAnomSun) {
+		return
+	}
+
+	for index := 0; index < len(multiFactor); index++ {
+		a := multiFactor[index] * math.Sin(2.0*deg2rad(geomMeanLongSun[index]))
+		b := 2.0 * eccentEarthOrbit[index] * math.Sin(deg2rad(geomMeanAnomSun[index]))
+		c := 4.0 * eccentEarthOrbit[index] * multiFactor[index] * math.Sin(deg2rad(geomMeanAnomSun[index]))
+		d := math.Cos(2.0 * deg2rad(geomMeanLongSun[index]))
+		e := 0.5 * multiFactor[index] * multiFactor[index] * math.Sin(4.0*deg2rad(geomMeanLongSun[index]))
+		f := 1.25 * eccentEarthOrbit[index] * eccentEarthOrbit[index] * math.Sin(2.0*deg2rad(geomMeanAnomSun[index]))
+		temp := 4.0 * rad2deg(a-b+c*d-e-f)
+		equationOfTime = append(equationOfTime, temp)
+	}
+	return
+}
+
+// Calculate the HaSunrise in degrees based on the formula: rad2deg(acos(cos(deg2rad(90.833))/(cos(deg2rad(latitude))*cos(deg2rad(sunDeclination)))-tan(deg2rad(latitude))*tan(deg2rad(sunDeclination))))
+// latitude - The latitude defined by the user
+// sunDeclination - The Sun Declination calculated by the calcSunDeclination function
+// Return the HaSunrise slice
+func calcHaSunrise(latitude float64, sunDeclination []float64) (haSunrise []float64) {
+	for index := 0; index < len(sunDeclination); index++ {
+		temp := rad2deg(math.Acos(math.Cos(deg2rad(90.833))/(math.Cos(deg2rad(latitude))*math.Cos(deg2rad(sunDeclination[index]))) - math.Tan(deg2rad(latitude))*math.Tan(deg2rad(sunDeclination[index]))))
+		haSunrise = append(haSunrise, temp)
+	}
+	return
+}
+
+// Calculate the Solar Noon based on the formula: (720 - 4 * longitude - equationOfTime + utcOffset * 60) * 60
+// longitude - The longitude is defined by the user
+// equationOfTime - The Equation of Time slice is calculated by the calcEquationOfTime function
+// utcOffset - The UTC offset is defined by the user
+// Return the Solar Noon slice
+func calcSolarNoon(longitude float64, equationOfTime []float64, utcOffset float64) (solarNoon []float64) {
+	for index := 0; index < len(equationOfTime); index++ {
+		temp := (720.0 - 4.0*longitude - equationOfTime[index] + utcOffset*60.0) * 60.0
+		solarNoon = append(solarNoon, temp)
+	}
+	return
+}
+
+// Check if the latitude is valid. Range: -90 - 90
+func checkLatitude(latitude float64) bool {
+	if latitude < -90.0 || latitude > 90.0 {
+		return false
+	}
+	return true
+}
+
+// Check if the longitude is valid. Range: -180 - 180
+func checkLongitude(longitude float64) bool {
+	if longitude < -180.0 || longitude > 180.0 {
+		return false
+	}
+	return true
+}
+
+// Check if the UTC offset is valid. Range: -12 - 14
+func checkUtcOffset(utcOffset float64) bool {
+	if utcOffset < -12.0 || utcOffset > 14.0 {
+		return false
+	}
+	return true
+}
+
+// Check if the date is valid.
+func checkDate(date time.Time) bool {
+	minDate := time.Date(1900, 1, 1, 0, 0, 0, 0, time.UTC)
+	maxDate := time.Date(2200, 1, 1, 0, 0, 0, 0, time.UTC)
+	if date.Before(minDate) || date.After(maxDate) {
+		return false
+	}
+	return true
+}
+
+// Compute the number of days between two dates
+func diffDays(date1, date2 time.Time) int64 {
+	return int64(date2.Sub(date1) / (24 * time.Hour))
+}
+
+// Find the index of the minimum value
+func minIndex(slice []float64) int {
+	if len(slice) == 0 {
+		return -1
+	}
+	min := slice[0]
+	minIndex := 0
+	for index := 0; index < len(slice); index++ {
+		if slice[index] < min {
+			min = slice[index]
+			minIndex = index
+		}
+	}
+	return minIndex
+}
+
+// Convert each value to the absolute value
+func abs(slice []float64) []float64 {
+	var newSlice []float64
+	for _, value := range slice {
+		if value < 0.0 {
+			value = math.Abs(value)
+		}
+		newSlice = append(newSlice, value)
+	}
+	return newSlice
+}
+
+func round(value float64) int {
+	if value < 0 {
+		return int(value - 0.5)
+	}
+	return int(value + 0.5)
+}
+
+// GetSunriseSunset function is responsible for calculate the apparent Sunrise and Sunset times.
+// If some parameter is wrong it will return an error.
+func GetSunriseSunset(latitude float64, longitude float64, utcOffset float64, date time.Time) (sunrise time.Time, sunset time.Time, err error) {
+	// Check latitude
+	if !checkLatitude(latitude) {
+		err = errors.New("Invalid latitude")
+		return
+	}
+	// Check longitude
+	if !checkLongitude(longitude) {
+		err = errors.New("Invalid longitude")
+		return
+	}
+	// Check UTC offset
+	if !checkUtcOffset(utcOffset) {
+		err = errors.New("Invalid UTC offset")
+		return
+	}
+	// Check date
+	if !checkDate(date) {
+		err = errors.New("Invalid date")
+		return
+	}
+
+	// The number of days since 30/12/1899
+	since := time.Date(1899, 12, 30, 0, 0, 0, 0, time.UTC)
+	numDays := diffDays(since, date)
+
+	// Seconds of a full day 86400
+	seconds := 24 * 60 * 60
+
+	// Creates a vector that represents each second in the range 0~1
+	secondsNorm := createSecondsNormalized(seconds)
+
+	// Calculate Julian Day
+	julianDay := calcJulianDay(numDays, secondsNorm, utcOffset)
+
+	// Calculate Julian Century
+	julianCentury := calcJulianCentury(julianDay)
+
+	// Geom Mean Long Sun (deg)
+	geomMeanLongSun := calcGeomMeanLongSun(julianCentury)
+
+	// Geom Mean Anom Sun (deg)
+	geomMeanAnomSun := calcGeomMeanAnomSun(julianCentury)
+
+	// Eccent Earth Orbit
+	eccentEarthOrbit := calcEccentEarthOrbit(julianCentury)
+
+	// Sun Eq of Ctr
+	sunEqCtr := calcSunEqCtr(julianCentury, geomMeanAnomSun)
+
+	// Sun True Long (deg)
+	sunTrueLong := calcSunTrueLong(sunEqCtr, geomMeanLongSun)
+
+	// Sun App Long (deg)
+	sunAppLong := calcSunAppLong(sunTrueLong, julianCentury)
+
+	// Mean Obliq Ecliptic (deg)
+	meanObliqEcliptic := calcMeanObliqEcliptic(julianCentury)
+
+	// Obliq Corr (deg)
+	obliqCorr := calcObliqCorr(meanObliqEcliptic, julianCentury)
+
+	// Sun Declin (deg)
+	sunDeclination := calcSunDeclination(obliqCorr, sunAppLong)
+
+	// var y
+	var multiFactor []float64
+	for index := 0; index < len(obliqCorr); index++ {
+		temp := math.Tan(deg2rad(obliqCorr[index]/2.0)) * math.Tan(deg2rad(obliqCorr[index]/2.0))
+		multiFactor = append(multiFactor, temp)
+	}
+
+	// Eq of Time (minutes)
+	equationOfTime := calcEquationOfTime(multiFactor, geomMeanLongSun, eccentEarthOrbit, geomMeanAnomSun)
+
+	// HA Sunrise (deg)
+	haSunrise := calcHaSunrise(latitude, sunDeclination)
+
+	// Solar Noon (LST)
+	solarNoon := calcSolarNoon(longitude, equationOfTime, utcOffset)
+
+	// Sunrise and Sunset Times (LST)
+	var tempSunrise []float64
+	var tempSunset []float64
+
+	for index := 0; index < len(solarNoon); index++ {
+		tempSunrise = append(tempSunrise, (solarNoon[index] - float64(round(haSunrise[index]*4.0*60.0)) - float64(seconds)*secondsNorm[index]))
+		tempSunset = append(tempSunset, (solarNoon[index] + float64(round(haSunrise[index]*4.0*60.0)) - float64(seconds)*secondsNorm[index]))
+	}
+
+	// Get the sunrise and sunset in seconds
+	sunriseSeconds := minIndex(abs(tempSunrise))
+	sunsetSeconds := minIndex(abs(tempSunset))
+
+	// Convert the seconds to time
+	defaultTime := new(time.Time)
+	sunrise = defaultTime.Add(time.Duration(sunriseSeconds) * time.Second)
+	sunset = defaultTime.Add(time.Duration(sunsetSeconds) * time.Second)
+
+	return
+}
+
+func SunriseSunsetForChina(latitude, longitude float64) (string, string, error) {
+	p := Parameters{
+		Latitude:  latitude,
+		Longitude: longitude,
+		UtcOffset: 8.0,
+		Date:      New(MlNow()).BeginningOfDay(),
+	}
+	sunrise, sunset, err := p.GetSunriseSunset()
+	if err != nil {
+		return "", "", err
+	}
+	if sunrise.Second() >= 30 {
+		sunrise = sunrise.Add(time.Minute)
+	}
+	if sunset.Second() >= 30 {
+		sunset = sunset.Add(time.Minute)
+	}
+	//return "16:17", "16:16", nil
+	return sunrise.Format("15:04"), sunset.Format("15:04"), nil
+}

+ 81 - 0
server/utils/common/time.go

@@ -0,0 +1,81 @@
+package common
+
+import (
+	"database/sql/driver"
+	"fmt"
+	"time"
+)
+
+const timeFormat1 = "2006-01-02"
+const timeFormat2 = "2006-01-02 15:04:05"
+const timezone = "Asia/Shanghai"
+
+type Time time.Time
+
+func (t Time) MarshalJSON() ([]byte, error) {
+	b := make([]byte, 0, len(timeFormat2)+2)
+	b = append(b, '"')
+	b = time.Time(t).AppendFormat(b, timeFormat2)
+	b = append(b, '"')
+	return b, nil
+}
+
+func (t *Time) UnmarshalJSON(data []byte) (err error) {
+	timeFormat := timeFormat2
+	if len(data) == 12 {
+		timeFormat = timeFormat1
+	}
+	now, err := time.ParseInLocation(`"`+timeFormat+`"`, string(data), time.Local)
+	*t = Time(now)
+	return
+}
+
+func (t Time) String() string {
+	return time.Time(t).Format(timeFormat2)
+}
+
+func (t Time) local() time.Time {
+	loc, _ := time.LoadLocation(timezone)
+	return time.Time(t).In(loc)
+}
+
+func (t Time) Value() (driver.Value, error) {
+	var zeroTime time.Time
+	var ti = time.Time(t)
+	if ti.UnixNano() == zeroTime.UnixNano() {
+		return nil, nil
+	}
+	return ti, nil
+}
+
+func (t *Time) Scan(v interface{}) error {
+	value, ok := v.(time.Time)
+	if ok {
+		*t = Time(value)
+		return nil
+	}
+	return fmt.Errorf("can not convert %v to timestamp", v)
+}
+
+func (t *Time) TimeCrossover(start1, end1, start2, end2 string) bool {
+
+	validValues := map[string]bool{"日落": true, "日出": true, "关闭": true}
+	//validValues := map[string]bool{"日落": true, "日出": true, "关闭": true, "不变": true}
+	if validValues[start1] || validValues[end1] || validValues[start2] || validValues[end2] {
+		return false
+	}
+
+	on1, _ := time.Parse("15:04", start1)
+	off1, _ := time.Parse("15:04", end1)
+
+	// 定义设备2的时间段
+	on2, _ := time.Parse("15:04", start2)
+	off2, _ := time.Parse("15:04", end2)
+
+	// 检查时间段是否重叠
+	if on1.Before(off2) && off1.After(on2) {
+		return true
+	} else {
+		return false
+	}
+}

+ 2 - 0
web/package.json

@@ -19,9 +19,11 @@
         "@wangeditor/editor-for-vue": "^5.1.12",
         "axios": "^1.4.0",
         "core-js": "^3.31.1",
+        "dayjs": "^1.11.18",
         "echarts": "5.4.3",
         "element-plus": "^2.3.8",
         "highlight.js": "^11.8.0",
+        "hls.js": "^1.6.13",
         "js-cookie": "^3.0.5",
         "jsencrypt": "^3.3.2",
         "marked": "4.3.0",

+ 39 - 0
web/src/api/camera.js

@@ -0,0 +1,39 @@
+import service from '@/utils/request'
+
+export const queryAllCamera = () => {
+  return service({
+    url: '/camera/queryAllCamera',
+    method: 'get'
+  })
+}
+
+export const queryCameraList = (data) => {
+  return service({
+    url: '/camera/queryCameraList',
+    method: 'post',
+    data
+  })
+}
+
+export const createCamera = (data) => {
+  return service({
+    url: '/camera/createCamera',
+    method: 'post',
+    data
+  })
+}
+
+export const updateCamera = (data) => {
+  return service({
+    url: '/camera/updateCamera',
+    method: 'put',
+    data
+  })
+}
+
+export const deleteCamera = (data) => {
+  return service({
+    url: '/camera/deleteCamera?id=' + data,
+    method: 'delete'
+  })
+}

+ 7 - 0
web/src/api/screens.js

@@ -53,3 +53,10 @@ export const sending = (data) => {
     data
   })
 }
+
+export const queryAllScreens = () => {
+  return service({
+    url: '/screens/queryAllScreens',
+    method: 'get'
+  })
+}

+ 60 - 0
web/src/api/stream.js

@@ -0,0 +1,60 @@
+import service from '@/utils/request'
+import {ElMessage} from "element-plus";
+
+export const startStream = (data) => {
+  return service({
+    url: '/stream/start',
+    method: 'post',
+    data
+  })
+}
+
+export const stopStream = (data) => {
+  return service({
+    url: '/stream/stop',
+    method: 'post',
+    data
+  })
+}
+
+export const getStreamList = () => {
+  return service({
+    url: '/stream/list',
+    method: 'get'
+  })
+}
+
+// 2. 新增:HLS流文件请求函数(适配封装axios)
+/**
+ * 请求HLS文件(.m3u8 或 .ts)
+ * @param {string} hlsUrl - 完整的HLS文件地址(如 /api/stream/hls/cam_xxx/stream.m3u8)
+ * @returns {Promise<Blob>} - 返回文件Blob对象(用于视频播放)
+ */
+export const requestHLSFile = async(hlsUrl) => {
+  try {
+    const response = await service({
+      url: hlsUrl, // 直接使用HLS相对路径(基于service的baseURL)
+      method: 'GET',
+      donNotShowLoading: true, // 关闭全局Loading(避免频繁切片请求触发Loading)
+      responseType: 'blob', // 关键:指定响应为Blob(文件流)
+      headers: {
+        // 移除鉴权头(HLS公开路由无需token)
+        'x-token': undefined,
+        'x-user-id': undefined,
+        // 可选:设置HLS文件专用请求头(如缓存控制)
+        'Cache-Control': 'no-cache'
+      }
+    })
+    return response // 返回Blob对象
+  } catch (err) {
+    ElMessage.error(`HLS文件加载失败:${err.message || '网络错误'}`)
+    throw err // 抛出错误,让上层处理
+  }
+}
+
+// 3. 辅助:生成完整HLS播放地址(适配环境变量)
+export const getFullHlsUrl = (streamId) => {
+  // 基于环境变量生成HLS相对路径(与后端路由匹配)
+  // 格式:/api/stream/hls/{streamId}/stream.m3u8
+  return `/stream/hls/${streamId}/stream.m3u8`
+}

+ 722 - 0
web/src/view/camera/cameraPlayer.vue

@@ -0,0 +1,722 @@
+<template>
+  <div class="camera-player-container">
+    <!-- 1. 摄像头配置表单 -->
+    <el-card
+      shadow="hover"
+      class="config-card"
+    >
+      <template #header>
+        <div class="card-header">
+          <span>摄像头配置</span>
+        </div>
+      </template>
+
+      <el-form
+        ref="cameraFormRef"
+        :model="cameraForm"
+        :rules="formRules"
+        label-width="120px"
+        class="config-form"
+        status-icon
+      >
+<!--        &lt;!&ndash; 摄像头IP &ndash;&gt;-->
+<!--        <el-form-item-->
+<!--          label="摄像头IP"-->
+<!--          prop="ip"-->
+<!--        >-->
+<!--          <el-input-->
+<!--            v-model="cameraForm.ip"-->
+<!--            placeholder="例如:192.168.1.100"-->
+<!--            clearable-->
+<!--            max-length="15"-->
+<!--          />-->
+<!--        </el-form-item>-->
+
+<!--        &lt;!&ndash; RTSP端口(默认554) &ndash;&gt;-->
+<!--        <el-form-item-->
+<!--          label="RTSP端口"-->
+<!--          prop="port"-->
+<!--        >-->
+<!--          <el-input-->
+<!--            v-model="cameraForm.port"-->
+<!--            placeholder="默认554"-->
+<!--            clearable-->
+<!--            max-length="5"-->
+<!--            oninput="this.value = this.value.replace(/[^0-9]/g, '')"-->
+<!--          />-->
+<!--        </el-form-item>-->
+
+<!--        &lt;!&ndash; 摄像头账号密码 &ndash;&gt;-->
+<!--        <el-form-item-->
+<!--          label="用户名"-->
+<!--          prop="username"-->
+<!--        >-->
+<!--          <el-input-->
+<!--            v-model="cameraForm.username"-->
+<!--            placeholder="摄像头登录账号"-->
+<!--            clearable-->
+<!--            max-length="32"-->
+<!--          />-->
+<!--        </el-form-item>-->
+<!--        <el-form-item-->
+<!--          label="密码"-->
+<!--          prop="password"-->
+<!--        >-->
+<!--          <el-input-->
+<!--            v-model="cameraForm.password"-->
+<!--            placeholder="摄像头登录密码"-->
+<!--            type="password"-->
+<!--            clearable-->
+<!--            max-length="32"-->
+<!--          />-->
+<!--        </el-form-item>-->
+
+<!--        &lt;!&ndash; 通道号(默认1) &ndash;&gt;-->
+<!--        <el-form-item-->
+<!--          label="通道号"-->
+<!--          prop="channel"-->
+<!--        >-->
+<!--          <el-input-->
+<!--            v-model="cameraForm.channel"-->
+<!--            placeholder="默认1(主码流)"-->
+<!--            clearable-->
+<!--            max-length="2"-->
+<!--            oninput="this.value = this.value.replace(/[^0-9]/g, '')"-->
+<!--          />-->
+<!--        </el-form-item>-->
+
+        <!-- 操作按钮 -->
+        <el-form-item>
+          <el-button
+            type="primary"
+            :loading="isPreviewLoading"
+            :disabled="isPreviewing"
+            @click="handleStartPreview"
+          >
+            启动预览
+          </el-button>
+          <el-button
+            type="danger"
+            :disabled="!isPreviewing"
+            style="margin-left: 10px"
+            @click="handleStopPreview"
+          >
+            停止预览
+          </el-button>
+          <el-button
+            type="info"
+            style="margin-left: 10px"
+            @click="handleGetActiveStreams"
+          >
+            查看活跃流
+          </el-button>
+        </el-form-item>
+      </el-form>
+    </el-card>
+
+    <!-- 2. 视频播放区域 -->
+    <el-card
+      shadow="hover"
+      class="video-card"
+    >
+      <template #header>
+        <div class="card-header">
+          <span>实时预览({{ cameraForm.ip || '未配置' }})</span>
+          <el-tooltip
+            content="当前HLS流地址"
+            placement="right"
+          >
+            <el-button
+              v-if="fullHlsUrl"
+              type="text"
+              size="small"
+              :disabled="!isPreviewing"
+              @click="copyHlsUrl"
+            >
+              复制流地址
+            </el-button>
+          </el-tooltip>
+        </div>
+      </template>
+
+      <!-- 错误提示 -->
+      <el-alert
+        v-if="previewError"
+        type="error"
+        description="预览失败:{{ previewErrorMsg }}"
+        show-icon
+        closable
+        style="margin-bottom: 16px"
+        @close="handleClearError"
+      />
+
+      <!-- 视频容器(video始终渲染,避免ref空值) -->
+      <div class="video-container">
+        <video
+          v-show="isPreviewing && !previewError"
+          ref="videoRef"
+          autoplay
+          controls
+          muted
+          class="video-player"
+          :poster="previewPoster"
+        />
+        <div
+          v-show="!isPreviewing || previewError"
+          class="video-placeholder"
+        >
+          <p class="placeholder-text">
+            {{ previewError ? '预览异常,请检查配置或网络' : '请配置摄像头信息并启动预览' }}
+          </p>
+        </div>
+      </div>
+    </el-card>
+
+    <!-- 3. 活跃流列表弹窗 -->
+    <el-dialog
+      v-model="streamListVisible"
+      title="活跃流列表"
+      width="500px"
+      :before-close="handleDialogClose"
+    >
+      <el-table
+        :data="activeStreamList"
+        border
+        stripe
+        :empty-text="activeStreamList.length === 0 ? '暂无活跃流' : ''"
+      >
+        <el-table-column
+          label="流ID"
+          prop="streamId"
+          align="center"
+        />
+        <el-table-column
+          label="操作"
+          align="center"
+        >
+          <template #default="scope">
+            <el-button
+              type="text"
+              @click="handlePlayStream(scope.row.streamId)"
+            >
+              播放该流
+            </el-button>
+          </template>
+        </el-table-column>
+      </el-table>
+    </el-dialog>
+  </div>
+</template>
+
+<script setup>
+import { nextTick, onBeforeUnmount, reactive, ref } from 'vue'
+import {
+  ElAlert,
+  ElButton,
+  ElCard,
+  ElDialog,
+  ElForm,
+  ElFormItem,
+  ElInput,
+  ElMessage,
+  ElTable,
+  ElTableColumn,
+  ElTooltip
+} from 'element-plus'
+import Hls from 'hls.js'
+// 导入后端接口(确保接口基础路径已适配环境变量)
+import {getStreamList, requestHLSFile, startStream, stopStream} from '@/api/stream'
+import { useRoute, useRouter } from 'vue-router'
+import { useUserStore } from '@/pinia/modules/user'
+const route = useRoute()
+
+const userStore = useUserStore()
+
+const userInfo = userStore.userInfo
+
+// 1. 响应式数据(明确区分相对路径和完整路径)
+const videoRef = ref(null) // 视频标签引用(始终存在)
+const cameraFormRef = ref(null) // 表单引用
+const isPreviewLoading = ref(false) // 预览加载状态
+const isPreviewing = ref(false) // 是否正在预览
+const previewError = ref(false) // 预览错误状态
+const previewErrorMsg = ref('') // 错误提示文本
+const hlsRelativeUrl = ref('') // 后端返回的HLS相对路径(如/stream/hls/xxx.m3u8)
+const fullHlsUrl = ref('') // 完整HLS地址(环境变量+相对路径)
+const hlsInstance = ref(null) // HLS播放器实例
+const streamListVisible = ref(false) // 活跃流弹窗显示状态
+const activeStreamList = ref([]) // 活跃流列表
+// 视频占位图(加载中显示)
+const previewPoster = ref('https://via.placeholder.com/1280x720?text=视频预览加载中...')
+
+// 2. 摄像头配置表单(默认值适配海康摄像头)
+const cameraForm = reactive({
+  ip: route.query.ip, // 默认摄像头IP
+  port: route.query.port, // RTSP默认端口
+  username: route.query.username, // 海康默认用户名
+  password: route.query.password, // 海康默认密码(首次登录需修改)
+  channel: route.query.channel, // 默认通道号
+})
+
+// 3. 表单校验规则(严格校验格式)
+const formRules = reactive({
+  ip: [
+    { required: true, message: '请输入摄像头IP', trigger: 'blur' },
+    {
+      pattern: /^((25[0-5]|2[0-4]\d|[01]?\d\d?)\.){3}(25[0-5]|2[0-4]\d|[01]?\d\d?)$/,
+      message: 'IP格式错误(如192.168.1.1)',
+      trigger: 'blur'
+    }
+  ],
+  port: [
+    { required: true, message: '请输入RTSP端口', trigger: 'blur' },
+    { pattern: /^[0-9]{1,5}$/, message: '端口需为1-65535的数字', trigger: 'blur' },
+    {
+      validator: (rule, value, callback) => {
+        if (Number(value) < 1 || Number(value) > 65535) {
+          callback(new Error('端口范围:1-65535'))
+        } else {
+          callback()
+        }
+      },
+      trigger: 'blur'
+    }
+  ],
+  username: [
+    { required: true, message: '请输入摄像头用户名', trigger: 'blur' },
+    { max: 32, message: '用户名长度不超过32位', trigger: 'blur' }
+  ],
+  password: [
+    { required: true, message: '请输入摄像头密码', trigger: 'blur' },
+    { max: 32, message: '密码长度不超过32位', trigger: 'blur' }
+  ],
+  channel: [
+    { required: true, message: '请输入通道号', trigger: 'blur' },
+    { pattern: /^[1-9]\d?$/, message: '通道号:1-99', trigger: 'blur' }
+  ],
+})
+
+// 4. 工具函数:生成RTSP地址(适配海康,可修改为其他品牌)
+const generateRTSPUrl = () => {
+  const { ip, port, username, password, channel } = cameraForm
+  // 密码编码(处理特殊字符如@、#)
+  const encodedPwd = encodeURIComponent(password)
+  // 海康RTSP格式:rtsp://user:pass@ip:port/Streaming/Channels/通道号+码流类型
+  // 例:1通道主码流 → /Streaming/Channels/101,子码流→102
+  return `rtsp://${username}:${encodedPwd}@${ip}:${port}/stream${channel}`
+}
+
+// 1. 先封装一个「延迟函数」(可复用):延迟指定毫秒后 resolve
+const delay = (ms) => {
+  return new Promise(resolve => setTimeout(resolve, ms))
+}
+
+// 5. 核心方法:启动预览(完整日志+双斜杠修复+环境变量适配)
+const handleStartPreview = async() => {
+  console.log('=== 【启动预览】流程开始 ===')
+
+  // 步骤1:表单校验(确保配置合法)
+  let formValid = false
+  try {
+    console.log('1. 执行表单校验...')
+    formValid = await cameraFormRef.value.validate()
+    console.log('1. 表单校验结果:', formValid) // 预期为true
+  } catch (err) {
+    console.error('1. 表单校验失败:', err) // 打印具体校验错误(如IP格式、必填项缺失)
+    ElMessage.warning('请完善摄像头配置(如IP需符合192.168.1.1格式、端口1-65535)')
+    return
+  }
+  if (!formValid) {
+    console.log('1. 表单校验未通过,终止预览流程')
+    return
+  }
+
+  // 步骤2:初始化状态(清空错误+开启加载)
+  console.log('2. 初始化预览状态...')
+  isPreviewLoading.value = true // 开启按钮加载
+  handleClearError() // 清除之前的预览错误
+  console.log('2. 状态初始化完成:isPreviewLoading=', isPreviewLoading.value, 'previewError=', previewError.value)
+
+  try {
+    // 步骤3:生成RTSP地址和流ID(确保与后端目录命名一致)
+    console.log('3. 生成RTSP地址和流ID...')
+    const rtspUrl = generateRTSPUrl() // 生成摄像头RTSP拉流地址(适配海康/大华)
+    // 流ID格式:cam_IP_通道号_时间戳后4位(与后端生成的HLS目录名完全匹配)
+    const streamId = `cam_${cameraForm.ip}_${cameraForm.channel}_${userInfo.ID}`
+    console.log('3. 生成结果:')
+    console.log('   - RTSP地址:', rtspUrl) // 示例:rtsp://admin:123456@192.168.110.190:554/Streaming/Channels/101
+    console.log('   - 流ID:', streamId) // 示例:cam_192.168.110.190_1(与后端HLS目录名一致)
+
+    // 步骤4:调用后端startStream接口(请求RTSP转HLS)
+    console.log('4. 调用后端startStream接口...')
+    console.log('   - 接口请求参数:', { rtspUrl, streamId })
+    const response = await startStream({ rtspUrl, streamId })
+    hlsRelativeUrl.value = response.data.hlsUrl
+    // 此时 hlsRelativeUrl.value 应为:/stream/hls/cam_xxx/stream.m3u8(后端返回的相对路径,含文件名)
+    console.log('后端返回的HLS相对路径:', hlsRelativeUrl.value)
+
+    // 步骤2:等待2秒(关键:延迟2秒)
+    console.log('开始等待5秒...')
+    await delay(5000) // 等待5000毫秒(5秒),await 会阻塞后续代码
+    console.log('5秒等待结束,准备执行下一个方法')
+
+    // 关键:只拼接后端相关地址,不涉及前端地址(localhost:8080)
+    fullHlsUrl.value = joinPath(
+      import.meta.env.VITE_BASE_PATH, // 后端IP:http://127.0.0.1(不含端口)
+      import.meta.env.VITE_SERVER_PORT, // 后端端口:8889
+      hlsRelativeUrl.value // 后端返回的相对路径:/stream/hls/.../stream.m3u8
+    )
+    console.log('最终正确HLS地址:', fullHlsUrl.value)
+
+    // 步骤6:等待DOM更新(确保video标签已渲染,避免ref空值)
+    console.log('6. 等待DOM更新(确保video元素可获取)...')
+    await nextTick() // 等待Vue DOM更新完成
+    const videoDom = videoRef.value
+    if (!videoDom) throw new Error('DOM更新后仍未找到video元素,请检查模板中video标签的ref是否为"videoRef"')
+    console.log('6. DOM更新完成:video元素已获取,准备初始化播放器')
+
+    // 步骤7:初始化HLS播放器(传入正确的完整地址)
+    console.log('7. 初始化HLS播放器...')
+    await initHLSPlayer(fullHlsUrl.value) // 传入修复后的完整HLS地址
+    console.log('7. HLS播放器初始化成功(流已开始加载)')
+
+    // 步骤8:更新预览状态(标记为正在预览)
+    isPreviewing.value = true
+    console.log('8. 预览状态更新:isPreviewing=', isPreviewing.value)
+    ElMessage.success('预览启动成功!可查看视频区域播放')
+    console.log('=== 【启动预览】流程全部完成(成功) ===')
+  } catch (err) {
+    // 步骤9:错误处理(分场景提示,方便调试)
+    console.error('=== 【启动预览】流程出错 ===', err) // 打印完整错误栈
+    previewError.value = true // 标记预览错误状态
+
+    // 提取错误详情(区分后端错误、前端错误、网络错误)
+    if (err.response) {
+      // 后端返回错误(如404、500)
+      previewErrorMsg.value = `后端接口错误(状态码:${err.response.status}):${err.response.data.message || '后端未返回错误信息'}`
+    } else if (err.message) {
+      // 前端已知错误(如表单校验、DOM未找到、地址拼接异常)
+      previewErrorMsg.value = err.message
+    } else {
+      // 未知错误
+      previewErrorMsg.value = '未知预览错误,请检查:1. 摄像头是否在线 2. 后端服务是否正常 3. 网络是否连通'
+    }
+    console.error('5. 错误详情提示:', previewErrorMsg.value)
+    ElMessage.error('预览启动失败:' + previewErrorMsg.value) // 前端用户提示
+  } finally {
+    // 步骤10:无论成功/失败,结束加载状态(避免按钮一直loading)
+    isPreviewLoading.value = false
+    console.log('10. 预览加载状态重置:isPreviewLoading=', isPreviewLoading.value)
+    console.log('=== 【启动预览】流程最终结束 ===\n')
+  }
+}
+
+// 路径处理工具函数:正确拼接 IP+端口(冒号分隔),其他路径用斜杠分隔
+const joinPath = (...parts) => {
+  // 1. 过滤空字符串和无效片段
+  const validParts = parts.filter(part => typeof part === 'string' && part.trim() !== '')
+  if (validParts.length === 0) return ''
+
+  // 2. 处理第一个片段(可能包含协议+IP,如 http://127.0.0.1)
+  const firstPart = validParts[0].trim()
+  const restParts = validParts.slice(1)
+
+  // 3. 单独拼接「IP+端口」(若第二个片段是端口,用冒号连接)
+  let fullPath = firstPart
+  if (restParts.length > 0) {
+    const secondPart = restParts[0].trim()
+    // 判断第一个片段是否是「协议+IP」(如 http://127.0.0.1),且第二个片段是纯数字(端口)
+    const isProtocolIp = /^https?:\/\/\d+\.\d+\.\d+\.\d+$/.test(firstPart)
+    const isPort = /^\d{1,5}$/.test(secondPart)
+
+    if (isProtocolIp && isPort) {
+      // IP+端口:用冒号连接(如 http://127.0.0.1 + 8889 → http://127.0.0.1:8889)
+      fullPath += `:${secondPart}`
+      // 剩余片段从第三个开始处理
+      restParts.shift()
+    }
+  }
+
+  // 4. 处理剩余路径片段(用斜杠连接,避免重复斜杠)
+  restParts.forEach(part => {
+    const trimmedPart = part.trim()
+    // 去除片段开头的斜杠(避免和前面的路径重复)
+    const normalizedPart = trimmedPart.replace(/^\/+/, '')
+    // 若当前路径末尾没有斜杠,添加一个斜杠
+    if (!fullPath.endsWith('/')) {
+      fullPath += '/'
+    }
+    fullPath += normalizedPart
+  })
+
+  return fullPath
+}
+
+// 核心:初始化HLS播放器(适配封装axios)
+const initHLSPlayer = async(hlsRelativeUrl) => {
+  // 1. 销毁旧实例
+  if (hlsInstance.value) {
+    hlsInstance.value.destroy()
+    hlsInstance.value = null
+  }
+
+  // 2. 获取video元素
+  const video = videoRef.value
+  if (!video) throw new Error('视频元素未找到')
+  video.src = ''
+  video.load()
+
+  // 3. 生成完整HLS相对路径(基于封装axios的baseURL)
+  // const hlsRelativeUrl = getFullHlsUrl(streamId)
+  console.log('HLS请求地址(基于axios baseURL):', hlsRelativeUrl)
+
+  try {
+    // 4. 浏览器兼容性处理
+    if (video.canPlayType('application/vnd.apple.mpegurl')) {
+      // Safari原生支持:通过axios获取Blob,创建URL给video
+      const blob = await requestHLSFile(hlsRelativeUrl)
+      const blobUrl = URL.createObjectURL(blob)
+      video.src = blobUrl
+      await video.play().catch(err => {
+        console.warn('Safari自动播放失败:', err)
+        ElMessage.info('请点击视频区域开始播放')
+      })
+      // 组件卸载时释放Blob URL(避免内存泄漏)
+      onBeforeUnmount(() => {
+        URL.revokeObjectURL(blobUrl)
+      })
+      return
+    }
+
+    // 其他浏览器:用hls.js,适配axios请求
+    if (!Hls.isSupported()) throw new Error('浏览器不支持HLS播放')
+
+    hlsInstance.value = new Hls({
+      enableWorker: true,
+      lowLatencyMode: true,
+      // 关键:让hls.js使用我们封装的axios请求HLS文件
+      fetchSetup: (context, init) => {
+        // 复用封装axios的请求配置(如baseURL、跨域、错误处理)
+        return requestHLSFile(context.url).then(blob => {
+          // 将Blob转换为hls.js可识别的Response对象
+          return new Response(blob, {
+            headers: {
+              'Content-Type': context.url.endsWith('.m3u8')
+                ? 'application/x-mpegURL'
+                : 'video/MP2T'
+            }
+          })
+        })
+      }
+    })
+
+    // 绑定视频并加载流
+    hlsInstance.value.attachMedia(video)
+    hlsInstance.value.on(Hls.Events.MANIFEST_PARSED, async() => {
+      console.log('HLS流解析完成')
+      await video.play().catch(err => {
+        console.warn('自动播放失败:', err)
+        ElMessage.info('请点击视频区域开始播放')
+      })
+    })
+
+    // 加载HLS流(传入相对路径,axios会自动拼接baseURL)
+    hlsInstance.value.loadSource(hlsRelativeUrl)
+  } catch (err) {
+    previewError.value = true
+    previewErrorMsg.value = `HLS播放初始化失败:${err.message}`
+    throw err
+  }
+}
+
+// 7. 核心方法:停止预览(资源清理)
+const handleStopPreview = async() => {
+  if (!isPreviewing.value) return
+
+  try {
+    // 调用后端停止流
+    const streamId = `cam_${cameraForm.ip}_${cameraForm.channel}_${userInfo.ID}`
+    await stopStream({ streamId }).then(res => {
+      console.log('后端停止流成功:', res)
+    }).catch(err => {
+      console.warn('后端停止流失败:', err)
+      ElMessage.warning('后端流停止异常,已清理前端')
+    })
+
+    // 清理前端资源
+    if (hlsInstance.value) {
+      hlsInstance.value.destroy()
+      hlsInstance.value = null
+    }
+    const video = videoRef.value
+    if (video) {
+      video.src = ''
+      video.pause()
+      video.load()
+    }
+
+    // 重置状态
+    isPreviewing.value = false
+    hlsRelativeUrl.value = ''
+    fullHlsUrl.value = ''
+    handleClearError()
+
+    ElMessage.success('预览已停止')
+  } catch (err) {
+    ElMessage.error('停止失败:' + (err.message || '未知错误'))
+    console.error('停止预览错误:', err)
+  }
+}
+
+// 8. 辅助方法:获取活跃流列表
+const handleGetActiveStreams = async() => {
+  try {
+    const response = await getStreamList()
+    if (!response || !response.data || !Array.isArray(response.data.data)) {
+      throw new Error('后端返回格式异常')
+    }
+    // 去重并格式化列表
+    activeStreamList.value = [...new Set(response.data.data)].map(id => ({ streamId: id }))
+    streamListVisible.value = true
+  } catch (err) {
+    const errMsg = err.response?.data?.message || err.message || '获取失败'
+    ElMessage.error('获取活跃流失败:' + errMsg)
+    console.error('获取活跃流错误:', err)
+  }
+}
+
+// 9. 辅助方法:播放指定活跃流
+const handlePlayStream = async(streamId) => {
+  if (!streamId) {
+    ElMessage.warning('无效流ID')
+    return
+  }
+  // 解析流ID(格式:cam_IP_通道号_时间戳)
+  const match = streamId.match(/cam_([\d.]+)_(\d+)_?\d*/)
+  if (!match || match.length < 3) {
+    ElMessage.warning('流ID格式错误,无法解析')
+    return
+  }
+  // 填充表单并启动预览
+  cameraForm.ip = match[1]
+  cameraForm.channel = match[2]
+  streamListVisible.value = false
+  // 延迟启动(确保弹窗关闭后DOM更新)
+  setTimeout(handleStartPreview, 300)
+}
+
+// 10. 辅助方法:复制HLS地址(兼容旧浏览器)
+const copyHlsUrl = async() => {
+  if (!fullHlsUrl.value) return
+
+  try {
+    // 现代浏览器:navigator.clipboard
+    await navigator.clipboard.writeText(fullHlsUrl.value)
+    ElMessage.success('地址已复制:' + fullHlsUrl.value.slice(0, 50) + '...')
+  } catch (err) {
+    // 旧浏览器:临时输入框
+    const input = document.createElement('input')
+    input.value = fullHlsUrl.value
+    document.body.appendChild(input)
+    input.select()
+    document.execCommand('copy')
+    document.body.removeChild(input)
+    ElMessage.success('地址已复制(兼容模式)')
+  }
+}
+
+// 11. 辅助方法:清除错误状态
+const handleClearError = () => {
+  previewError.value = false
+  previewErrorMsg.value = ''
+}
+
+// 12. 辅助方法:关闭弹窗时重置
+const handleDialogClose = () => {
+  streamListVisible.value = false
+}
+
+// 13. 生命周期:卸载时清理资源
+onBeforeUnmount(() => {
+  if (isPreviewing.value) {
+    handleStopPreview().catch(err => console.warn('卸载时停止预览错误:', err))
+  }
+  if (hlsInstance.value) {
+    hlsInstance.value.destroy()
+    hlsInstance.value = null
+  }
+  const video = videoRef.value
+  if (video) {
+    video.src = ''
+    video.pause()
+  }
+})
+</script>
+
+<style scoped>
+.camera-player-container {
+  padding: 20px;
+  max-width: 1400px;
+  margin: 0 auto;
+  box-sizing: border-box;
+}
+
+.config-card, .video-card {
+  margin-bottom: 20px;
+  border-radius: 8px;
+}
+
+.config-form {
+  margin-top: 15px;
+}
+
+/* 视频容器:16:9比例,避免变形 */
+.video-container {
+  width: 100%;
+  background: #000;
+  border-radius: 4px;
+  overflow: hidden;
+  aspect-ratio: 16/9;
+  position: relative;
+}
+
+.video-player {
+  width: 100%;
+  height: 100%;
+  object-fit: contain; /* 保持视频比例 */
+}
+
+/* 占位符:居中显示 */
+.video-placeholder {
+  width: 100%;
+  height: 100%;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  color: #aaa;
+  background: #000;
+}
+
+.placeholder-text {
+  font-size: 16px;
+  padding: 0 20px;
+  text-align: center;
+}
+
+.card-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+}
+
+/* 输入框聚焦效果 */
+:deep(.el-input__wrapper):focus-within {
+  box-shadow: 0 0 0 2px rgba(144, 147, 153, 0.2);
+}
+
+/* 按钮hover效果 */
+:deep(.el-button):hover:not(:disabled) {
+  transform: translateY(-1px);
+  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
+}
+</style>

+ 463 - 0
web/src/view/devicesAdmin/camera/camera.vue

@@ -0,0 +1,463 @@
+<template>
+  <div>
+    <div class="gva-search-box">
+      <el-form
+        ref="searchForm"
+        :inline="true"
+        :model="searchCamera"
+      />
+    </div>
+
+    <div class="gva-table-box">
+      <div class="gva-btn-list">
+        <el-button
+          type="primary"
+          icon="plus"
+          @click="isAddCameraDialog = true"
+        >新增
+        </el-button>
+      </div>
+      <el-table
+        :data="cameraData"
+        style="width: 100%; margin-bottom: 20px"
+        row-key="id"
+        border
+        :table-layout="'fixed'"
+      >
+        <el-table-column
+          label="名称"
+          :width="200"
+          prop="name"
+        />
+        <el-table-column
+          label="IP"
+          :width="220"
+          prop="ip"
+        />
+        <el-table-column
+          label="端口"
+          :width="200"
+          prop="port"
+        />
+        <el-table-column
+          label="用户名"
+          :width="180"
+          prop="username"
+        />
+        <el-table-column
+          prop="password"
+          label="密码"
+        />
+        <el-table-column
+          prop="channel"
+          label="通道号"
+        />
+        <el-table-column
+          prop="serialNum"
+          label="序列号"
+        />
+        <el-table-column
+          prop="status"
+          label="在线状态"
+          :width="100"
+        >
+          <template #default="scope">
+            <div
+              class="onlinebox"
+              :style="{'background': scope.row.state===0 ? 'red':'green' }"
+            />
+            <span :style="{ color: scope.row.state === 0 ? 'red' : 'green' }">{{
+              scope.row.state === 0 ? '离线' : '在线'
+            }}</span>
+          </template>
+        </el-table-column>
+        <el-table-column
+          align="left"
+          fixed="right"
+          label="操作"
+          :width="300"
+        >
+          <template #default="scope">
+            <el-button
+              type="primary"
+              link
+              @click="jumpRelayTimeSet(scope.row)"
+            >预览
+            </el-button>
+            <el-button
+              type="primary"
+              link
+              icon="edit"
+              @click="openEditCameraDialog(scope.row)"
+            >编辑
+            </el-button>
+            <el-button
+              type="primary"
+              link
+              icon="delete"
+              @click="removeCamera(scope.row.ID)"
+            >删除
+            </el-button>
+          </template>
+        </el-table-column>
+      </el-table>
+      <div class="gva-pagination">
+        <el-pagination
+          :current-page="searchCamera.page"
+          :page-size="searchCamera.pageSize"
+          :page-sizes="[10, 30, 50, 100]"
+          :total="total"
+          layout="total, sizes, prev, pager, next, jumper"
+          @current-change="handleCurrentChange"
+          @size-change="handleSizeChange"
+        />
+      </div>
+
+      <el-dialog
+        v-model="isAddCameraDialog"
+        title="新增摄像头"
+        width="600"
+      >
+        <el-form
+          :model="addCameraData"
+          label-width="100px"
+          style="padding: 15px"
+        >
+          <el-row>
+            <el-col :span="24">
+              <el-form-item
+                label="名称:"
+                :inline="false"
+                prop="name"
+              >
+                <el-input v-model="addCameraData.name" />
+              </el-form-item>
+              <el-form-item
+                label="IP:"
+                :inline="false"
+                prop="ip"
+              >
+                <el-input v-model="addCameraData.ip" />
+              </el-form-item>
+              <el-form-item
+                label="端口:"
+                :inline="false"
+                prop="port"
+              >
+                <el-input v-model="addCameraData.port" />
+              </el-form-item>
+              <el-form-item
+                label="用户名:"
+                :inline="false"
+                prop="username"
+              >
+                <el-input v-model="addCameraData.username" />
+              </el-form-item>
+              <el-form-item
+                label="密码:"
+                :inline="false"
+                prop="password"
+              >
+                <el-input v-model="addCameraData.password" />
+              </el-form-item>
+              <el-form-item
+                label="通道号:"
+                :inline="false"
+                prop="channel"
+              >
+                <el-input v-model="addCameraData.channel" />
+              </el-form-item>
+              <el-form-item
+                label="序列号:"
+                :inline="false"
+                prop="serialNum"
+              >
+                <el-input v-model="addCameraData.serialNum" />
+              </el-form-item>
+              <el-form-item
+                label="所属显示器:"
+                prop="screensId"
+              >
+                <el-select
+                  v-model.number="addCameraData.screensId"
+                  filterable
+                  collapse-tags
+                  placeholder="选择显示器"
+                >
+                  <el-option
+                    v-for="item in screensData"
+                    :key="item.ID"
+                    :label="item.screensName"
+                    :value="item.ID"
+                  />
+                </el-select>
+              </el-form-item>
+            </el-col>
+          </el-row>
+        </el-form>
+        <template #footer>
+          <div class="dialog-footer">
+            <el-button @click="isAddCameraDialog = false">取消</el-button>
+            <el-button
+              type="primary"
+              @click="addCamera"
+            >
+              确定
+            </el-button>
+          </div>
+        </template>
+      </el-dialog>
+
+      <el-dialog
+        v-model="isEditCameraDialog"
+        title="修改摄像头"
+        width="600"
+      >
+        <el-form
+          :model="editCameraData"
+          label-width="100px"
+          style="padding: 15px"
+        >
+          <el-row>
+            <el-col :span="24">
+              <el-form-item
+                label="名称:"
+                :inline="false"
+                prop="name"
+              >
+                <el-input v-model="editCameraData.name" />
+              </el-form-item>
+              <el-form-item
+                label="IP:"
+                :inline="false"
+                prop="ip"
+              >
+                <el-input v-model="editCameraData.ip" />
+              </el-form-item>
+              <el-form-item
+                label="端口:"
+                :inline="false"
+                prop="port"
+              >
+                <el-input v-model="editCameraData.port" />
+              </el-form-item>
+              <el-form-item
+                label="用户名:"
+                :inline="false"
+                prop="username"
+              >
+                <el-input v-model="editCameraData.username" />
+              </el-form-item>
+              <el-form-item
+                label="密码:"
+                :inline="false"
+                prop="password"
+              >
+                <el-input v-model="editCameraData.password" />
+              </el-form-item>
+              <el-form-item
+                label="通道号:"
+                :inline="false"
+                prop="channel"
+              >
+                <el-input v-model="editCameraData.channel" />
+              </el-form-item>
+              <el-form-item
+                  label="序列号:"
+                  :inline="false"
+                  prop="serialNum"
+              >
+                <el-input v-model="editCameraData.serialNum" />
+              </el-form-item>
+              <el-form-item
+                label="所属显示器:"
+                prop="screensId"
+              >
+                <el-select
+                  v-model.number="editCameraData.screensId"
+                  filterable
+                  collapse-tags
+                  placeholder="选择显示器"
+                >
+                  <el-option
+                    v-for="item in screensData"
+                    :key="item.ID"
+                    :label="item.screensName"
+                    :value="item.ID"
+                  />
+                </el-select>
+              </el-form-item>
+            </el-col>
+          </el-row>
+        </el-form>
+        <template #footer>
+          <div class="dialog-footer">
+            <el-button @click="openEditCameraDialog = false">取消</el-button>
+            <el-button
+              type="primary"
+              @click="editCamera"
+            >
+              确定
+            </el-button>
+          </div>
+        </template>
+      </el-dialog>
+
+    </div>
+  </div>
+</template>
+<script setup>
+import { ref, onMounted } from 'vue'
+import { ElMessage, ElMessageBox } from 'element-plus'
+import { createCamera, deleteCamera, queryCameraList, updateCamera } from '@/api/camera'
+import { queryAllScreens } from '@/api/screens'
+import { useRoute, useRouter } from 'vue-router'
+
+const router = useRouter()
+const cameraData = ref()
+const searchCamera = ref({
+  page: 1,
+  pageSize: 10,
+})
+const total = ref(0)
+
+const screensData = ref()
+
+const getData = async() => {
+  await queryCameraList(searchCamera.value).then(res => {
+    cameraData.value = res.data.list
+    total.value = res.data.total
+    console.log(res.data.list)
+  })
+  await queryAllScreens().then(res => {
+    screensData.value = res.data
+  })
+}
+
+const handleSizeChange = (val) => {
+  searchCamera.value.pageSize = val
+  getData()
+}
+
+const handleCurrentChange = (val) => {
+  searchCamera.value.page = val
+  getData()
+}
+
+// 新增
+
+const isAddCameraDialog = ref(false)
+const addCameraData = ref({
+  name: '测试',
+  ip: '192.168.110.109',
+  port: '554',
+  username: 'admin',
+  password: '123456',
+  channel: '1',
+  serialNum: '',
+  screensId: undefined
+})
+
+const addCamera = async() => {
+  await createCamera(addCameraData.value).then(res => {
+    if (res.code === 0) {
+      ElMessage({
+        type: 'success',
+        message: '新增成功!',
+      })
+      getData()
+    }
+  })
+}
+
+//  修改
+
+const isEditCameraDialog = ref(false)
+const editCameraData = ref({})
+
+const openEditCameraDialog = (val) => {
+  editCameraData.value = val
+  isEditCameraDialog.value = true
+}
+
+const editCamera = async() => {
+  await updateCamera(editCameraData.value).then(res => {
+    if (res.code === 0) {
+      ElMessage({
+        type: 'success',
+        message: '修改成功!',
+      })
+      getData()
+    }
+  })
+}
+
+// 删除
+
+const removeCamera = (val) => {
+  ElMessageBox.confirm('您确定要删除吗?', '提示', {
+    confirmButtonText: '确定',
+    cancelButtonText: '取消',
+    type: 'warning',
+  })
+    .then(async() => {
+      const res = await deleteCamera(val)
+      if (res.code === 0) {
+        ElMessage({
+          type: 'success',
+          message: '删除成功!',
+        })
+        await getData()
+      }
+    })
+    .catch(() => {
+      ElMessage({
+        type: 'info',
+        message: '已取消删除',
+      })
+    })
+}
+
+// 跳转
+
+const jumpRelayTimeSet = (item) => {
+  const { href } = router.resolve({
+    path: '/layout/devicesAdmin/cameraPlayer', // 路径
+    query: {
+      ip: item.ip,
+      port: item.port,
+      username: item.username,
+      password: item.password,
+      channel: item.channel,
+    }// 传参
+  })
+  location.href = (href)
+}
+
+onMounted(() => {
+  getData()
+})
+</script>
+<style>
+.el-table .success-row {
+  background: #f3fdef;
+}
+
+.onlinebox {
+  width: 10px;
+  height: 10px;
+  border-radius: 50%;
+  display: inline-block;
+  margin-right: 4px;
+  position: relative;
+  top: 1px;
+}
+
+.el-dialog__header {
+  border-bottom: 1px solid #e8eaec;
+}
+
+.el-dialog__footer {
+  border-top: 1px solid #e8eaec;
+}
+</style>

+ 2 - 2
web/src/view/monitor/monitor.vue

@@ -194,10 +194,10 @@ export default {
       {
         id: '1',
         name: '设备1',
-        ip: '192.168.110.65',
+        ip: '192.168.110.190',
         port: 80,
         username: 'admin',
-        password: '123456qwe',
+        password: '123456',
         expanded: false,
         channels: [
           { id: 1, name: '通道1' },