Merge pull request '#87' from feature/69-sense-area into dev

[SEN] 重建多边形区域与方向警戒线配置(#69)

用户已明确验收通过。
This commit was merged in pull request #87.
This commit is contained in:
ila
2026-08-15 09:33:32 +08:00
27 changed files with 2113 additions and 10 deletions
@@ -0,0 +1,20 @@
package router
import (
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/area"
"git.ilapage.cn/ila/yovision/Sense/server/common/actions"
"git.ilapage.cn/ila/yovision/Sense/server/common/middleware"
"github.com/gin-gonic/gin"
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
)
func init() { routerCheckRole = append(routerCheckRole, registerSenseAreaRouter) }
func registerSenseAreaRouter(v1 *gin.RouterGroup, auth *jwt.GinJWTMiddleware) {
api := &area.API{}
r := v1.Group("/area").Use(auth.MiddlewareFunc()).Use(middleware.AuthCheckRole()).Use(actions.PermissionAction())
r.GET("/configurations", api.List)
r.POST("/configurations", api.Create)
r.PUT("/configurations/:id", api.Update)
r.GET("/configurations/:id/versions", api.Versions)
}
+11 -1
View File
@@ -12,6 +12,7 @@ import (
coreService "github.com/go-admin-team/go-admin-core/sdk/service"
"gorm.io/gorm"
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/area"
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/credential"
deviceModels "git.ilapage.cn/ila/yovision/Sense/server/app/sense/device/models"
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/onvif"
@@ -131,7 +132,16 @@ func (s *Service) save(result Result, request ProbeRequest, replaceProfiles bool
}
}
if replaceProfiles && len(result.Profiles) > 0 {
return tx.Create(&result.Profiles).Error
if err := tx.Create(&result.Profiles).Error; err != nil {
return err
}
}
if replaceProfiles {
snapshots := make([]area.ProfileSnapshot, 0, len(result.Profiles))
for _, profile := range result.Profiles {
snapshots = append(snapshots, area.ProfileSnapshot{Token: profile.Token, Width: profile.Width, Height: profile.Height, Encoding: profile.Encoding})
}
return area.MarkProfilesReplaced(tx, request.DeviceID, snapshots)
}
return nil
})
@@ -10,18 +10,25 @@ import (
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/area"
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/credential"
deviceModels "git.ilapage.cn/ila/yovision/Sense/server/app/sense/device/models"
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/onvif"
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/rtsp"
)
type fakeONVIF struct{ err error }
type fakeONVIF struct {
err error
profiles []onvif.Profile
}
func (f fakeONVIF) Profiles(context.Context, string, onvif.Credential) ([]onvif.Profile, error) {
if f.err != nil {
return nil, f.err
}
if f.profiles != nil {
return f.profiles, nil
}
return []onvif.Profile{{Token: "main", Name: "主码流", Width: 1920, Height: 1080, Encoding: "H264", StreamURI: "rtsp://192.0.2.10/main"}, {Token: "sub", Name: "子码流", Width: 640, Height: 360, Encoding: "H264", StreamURI: "rtsp://192.0.2.10/sub"}}, nil
}
@@ -105,3 +112,27 @@ func TestProbeErrorsHaveActionableStates(t *testing.T) {
}
}
}
func TestProfileResolutionChangeMarksAreaForRecalibration(t *testing.T) {
service := admissionService(t)
if _, err := service.Probe(context.Background(), ProbeRequest{DeviceID: "device-1", Address: "http://192.0.2.10/onvif", Version: 1}); err != nil {
t.Fatal(err)
}
if err := service.Orm.AutoMigrate(&area.Definition{}, &area.Version{}); err != nil {
t.Fatal(err)
}
definition := area.Definition{ID: "area-1", Name: "东门警戒线", Kind: area.KindDirectionLine, DeviceID: "device-1", ProfileToken: "main", ProfileWidth: 1920, ProfileHeight: 1080, ProfileEncoding: "H264", CurrentVersion: 1, CurrentVersionID: "version-1", Enabled: true, CreatedBy: 7, UpdatedBy: 7}
if err := service.Orm.Create(&definition).Error; err != nil {
t.Fatal(err)
}
service.ONVIF = fakeONVIF{profiles: []onvif.Profile{{Token: "main", Name: "主码流", Width: 1280, Height: 720, Encoding: "H264", StreamURI: "rtsp://192.0.2.10/main"}}}
if _, err := service.Probe(context.Background(), ProbeRequest{DeviceID: "device-1", Address: "http://192.0.2.10/onvif", Version: 2}); err != nil {
t.Fatal(err)
}
if err := service.Orm.First(&definition, "id = ?", "area-1").Error; err != nil {
t.Fatal(err)
}
if !definition.NeedsRecalibration {
t.Fatal("profile resolution change did not mark the bound area")
}
}
+126
View File
@@ -0,0 +1,126 @@
package area
import (
"encoding/json"
"errors"
"io"
"net/http"
"strconv"
"strings"
"github.com/gin-gonic/gin"
"github.com/go-admin-team/go-admin-core/sdk/api"
"github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth/user"
coreService "github.com/go-admin-team/go-admin-core/sdk/service"
)
type API struct{ api.Api }
func (e *API) service(c *gin.Context) (*Service, error) {
base := coreService.Service{}
if err := e.MakeContext(c).MakeOrm().MakeService(&base).Errors; err != nil {
return nil, err
}
return NewService(base.Orm), nil
}
func (e *API) List(c *gin.Context) {
service, err := e.service(c)
if err != nil {
e.Error(http.StatusInternalServerError, err, "区域配置服务初始化失败")
return
}
pageIndex, _ := strconv.Atoi(c.DefaultQuery("pageIndex", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("pageSize", "10"))
items, total, err := service.List(c.Request.Context(), PageRequest{Keyword: c.Query("keyword"), Kind: c.Query("kind"), RecalibrationState: c.Query("recalibrationState"), PageIndex: pageIndex, PageSize: pageSize})
if err != nil {
e.writeError(err)
return
}
e.PageOK(items, int(total), pageIndex, pageSize, "查询成功")
}
func (e *API) Create(c *gin.Context) {
service, err := e.service(c)
if err != nil {
e.Error(http.StatusInternalServerError, err, "区域配置服务初始化失败")
return
}
var request UpsertRequest
if err = decodeJSON(c, &request); err != nil {
e.Error(http.StatusBadRequest, err, "请求内容格式不正确")
return
}
request.UpdateBy = user.GetUserId(c)
item, err := service.Create(c.Request.Context(), request)
if err != nil {
e.writeError(err)
return
}
e.OK(item, "区域配置已创建")
}
func (e *API) Update(c *gin.Context) {
service, err := e.service(c)
if err != nil {
e.Error(http.StatusInternalServerError, err, "区域配置服务初始化失败")
return
}
var request UpsertRequest
if err = decodeJSON(c, &request); err != nil {
e.Error(http.StatusBadRequest, err, "请求内容格式不正确")
return
}
request.UpdateBy = user.GetUserId(c)
item, err := service.Update(c.Request.Context(), c.Param("id"), request)
if err != nil {
e.writeError(err)
return
}
e.OK(item, "已保存新版本")
}
func (e *API) Versions(c *gin.Context) {
service, err := e.service(c)
if err != nil {
e.Error(http.StatusInternalServerError, err, "区域配置服务初始化失败")
return
}
items, err := service.Versions(c.Request.Context(), c.Param("id"))
if err != nil {
e.writeError(err)
return
}
e.OK(items, "查询成功")
}
func (e *API) writeError(err error) {
switch {
case errors.Is(err, ErrInvalidRequest), errors.Is(err, ErrInvalidGeometry):
e.Error(http.StatusBadRequest, err, err.Error())
case errors.Is(err, ErrNotFound), errors.Is(err, ErrProfileMissing):
e.Error(http.StatusNotFound, err, err.Error())
case errors.Is(err, ErrConflict):
e.Error(http.StatusConflict, err, err.Error())
default:
e.Error(http.StatusInternalServerError, err, "区域配置操作失败")
}
}
func decodeJSON(c *gin.Context, target any) error {
if !strings.HasPrefix(strings.ToLower(strings.TrimSpace(c.GetHeader("Content-Type"))), "application/json") {
return errors.New("content type must be application/json")
}
decoder := json.NewDecoder(http.MaxBytesReader(c.Writer, c.Request.Body, 64<<10))
decoder.DisallowUnknownFields()
if err := decoder.Decode(target); err != nil {
return err
}
if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) {
if err == nil {
return errors.New("request body must contain one JSON object")
}
return err
}
return nil
}
+81
View File
@@ -0,0 +1,81 @@
package area
import "time"
const (
KindPolygon = "polygon"
KindDirectionLine = "direction_line"
DirectionForward = "forward"
DirectionReverse = "reverse"
)
type Point struct {
X float64 `json:"x"`
Y float64 `json:"y"`
}
type PageRequest struct {
Keyword string
Kind string
RecalibrationState string
PageIndex int
PageSize int
}
type UpsertRequest struct {
Name string `json:"name"`
Kind string `json:"kind"`
RouteID string `json:"routeId"`
Points []Point `json:"points"`
Direction string `json:"direction"`
Enabled bool `json:"enabled"`
ExpectedVersion int64 `json:"expectedVersion"`
UpdateBy int `json:"-"`
}
type Response struct {
ID string `json:"id"`
Name string `json:"name"`
Kind string `json:"kind"`
DeviceID string `json:"deviceId"`
DeviceName string `json:"deviceName"`
DeviceLocation string `json:"deviceLocation"`
ProfileToken string `json:"profileToken"`
ProfileName string `json:"profileName"`
ProfileWidth int `json:"profileWidth"`
ProfileHeight int `json:"profileHeight"`
ProfileEncoding string `json:"profileEncoding"`
RouteID string `json:"routeId"`
Version int64 `json:"version"`
Points []Point `json:"points"`
Direction string `json:"direction"`
Enabled bool `json:"enabled"`
NeedsRecalibration bool `json:"needsRecalibration"`
UpdatedBy int `json:"updatedBy"`
UpdatedAt time.Time `json:"updatedAt"`
}
type VersionResponse struct {
ID string `json:"id"`
Version int64 `json:"version"`
Name string `json:"name"`
Kind string `json:"kind"`
DeviceID string `json:"deviceId"`
ProfileToken string `json:"profileToken"`
ProfileWidth int `json:"profileWidth"`
ProfileHeight int `json:"profileHeight"`
ProfileEncoding string `json:"profileEncoding"`
Points []Point `json:"points"`
Direction string `json:"direction"`
Enabled bool `json:"enabled"`
SupersedesID string `json:"supersedesId"`
CreatedBy int `json:"createdBy"`
CreatedAt time.Time `json:"createdAt"`
}
type ProfileSnapshot struct {
Token string
Width int
Height int
Encoding string
}
+84
View File
@@ -0,0 +1,84 @@
package area
import (
"errors"
"math"
)
var ErrInvalidGeometry = errors.New("区域几何不符合要求")
func validateGeometry(kind, direction string, points []Point) error {
if kind != KindPolygon && kind != KindDirectionLine {
return ErrInvalidGeometry
}
if (kind == KindPolygon && (len(points) < 3 || len(points) > 64)) || (kind == KindDirectionLine && len(points) != 2) {
return ErrInvalidGeometry
}
if kind == KindDirectionLine && direction != DirectionForward && direction != DirectionReverse {
return ErrInvalidGeometry
}
if kind == KindPolygon && direction != "" {
return ErrInvalidGeometry
}
for i, point := range points {
if math.IsNaN(point.X) || math.IsNaN(point.Y) || math.IsInf(point.X, 0) || math.IsInf(point.Y, 0) || point.X < 0 || point.X > 1 || point.Y < 0 || point.Y > 1 {
return ErrInvalidGeometry
}
if i > 0 && samePoint(point, points[i-1]) {
return ErrInvalidGeometry
}
}
if kind == KindDirectionLine {
if samePoint(points[0], points[1]) {
return ErrInvalidGeometry
}
return nil
}
if samePoint(points[0], points[len(points)-1]) || math.Abs(polygonArea(points)) < 0.000001 || polygonSelfIntersects(points) {
return ErrInvalidGeometry
}
return nil
}
func samePoint(a, b Point) bool {
return math.Abs(a.X-b.X) < 0.0000001 && math.Abs(a.Y-b.Y) < 0.0000001
}
func polygonArea(points []Point) float64 {
area := 0.0
for i := range points {
next := points[(i+1)%len(points)]
area += points[i].X*next.Y - next.X*points[i].Y
}
return area / 2
}
func polygonSelfIntersects(points []Point) bool {
for i := range points {
a1, a2 := points[i], points[(i+1)%len(points)]
for j := i + 1; j < len(points); j++ {
if j == i || j == (i+1)%len(points) || i == (j+1)%len(points) {
continue
}
b1, b2 := points[j], points[(j+1)%len(points)]
if segmentsIntersect(a1, a2, b1, b2) {
return true
}
}
}
return false
}
func segmentsIntersect(a, b, c, d Point) bool {
orientation := func(p, q, r Point) float64 {
return (q.Y-p.Y)*(r.X-q.X) - (q.X-p.X)*(r.Y-q.Y)
}
o1, o2, o3, o4 := orientation(a, b, c), orientation(a, b, d), orientation(c, d, a), orientation(c, d, b)
if ((o1 > 0 && o2 < 0) || (o1 < 0 && o2 > 0)) && ((o3 > 0 && o4 < 0) || (o3 < 0 && o4 > 0)) {
return true
}
onSegment := func(p, q, r Point) bool {
return q.X <= math.Max(p.X, r.X)+0.0000001 && q.X >= math.Min(p.X, r.X)-0.0000001 && q.Y <= math.Max(p.Y, r.Y)+0.0000001 && q.Y >= math.Min(p.Y, r.Y)-0.0000001
}
return (math.Abs(o1) < 0.0000001 && onSegment(a, c, b)) || (math.Abs(o2) < 0.0000001 && onSegment(a, d, b)) || (math.Abs(o3) < 0.0000001 && onSegment(c, a, d)) || (math.Abs(o4) < 0.0000001 && onSegment(c, b, d))
}
@@ -0,0 +1,27 @@
package area
import "testing"
func TestGeometryValidation(t *testing.T) {
tests := []struct {
name string
kind string
direction string
points []Point
valid bool
}{
{"polygon", KindPolygon, "", []Point{{0.1, 0.1}, {0.8, 0.1}, {0.5, 0.8}}, true},
{"self intersecting", KindPolygon, "", []Point{{0.1, 0.1}, {0.8, 0.8}, {0.8, 0.1}, {0.1, 0.8}}, false},
{"outside", KindPolygon, "", []Point{{-0.1, 0.1}, {0.8, 0.1}, {0.5, 0.8}}, false},
{"line", KindDirectionLine, DirectionForward, []Point{{0.2, 0.5}, {0.8, 0.5}}, true},
{"line missing direction", KindDirectionLine, "", []Point{{0.2, 0.5}, {0.8, 0.5}}, false},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
err := validateGeometry(test.kind, test.direction, test.points)
if (err == nil) != test.valid {
t.Fatalf("valid=%v err=%v", test.valid, err)
}
})
}
}
+45
View File
@@ -0,0 +1,45 @@
package area
import "time"
type Definition struct {
ID string `gorm:"size:36;primaryKey"`
Name string `gorm:"size:128;not null;index"`
Kind string `gorm:"size:32;not null;index"`
DeviceID string `gorm:"size:36;not null;index"`
ProfileToken string `gorm:"size:255;not null"`
ProfileWidth int `gorm:"not null"`
ProfileHeight int `gorm:"not null"`
ProfileEncoding string `gorm:"size:32;not null"`
CurrentVersion int64 `gorm:"not null"`
CurrentVersionID string `gorm:"size:36;not null;uniqueIndex"`
Enabled bool `gorm:"not null"`
NeedsRecalibration bool `gorm:"not null;index"`
CreatedBy int `gorm:"not null"`
UpdatedBy int `gorm:"not null"`
CreatedAt time.Time `gorm:"not null"`
UpdatedAt time.Time `gorm:"not null"`
}
func (Definition) TableName() string { return "sense_area_definitions" }
type Version struct {
ID string `gorm:"size:36;primaryKey"`
DefinitionID string `gorm:"size:36;not null;uniqueIndex:ux_sense_area_version,priority:1;index"`
Version int64 `gorm:"not null;uniqueIndex:ux_sense_area_version,priority:2"`
Name string `gorm:"size:128;not null"`
Kind string `gorm:"size:32;not null"`
DeviceID string `gorm:"size:36;not null"`
ProfileToken string `gorm:"size:255;not null"`
ProfileWidth int `gorm:"not null"`
ProfileHeight int `gorm:"not null"`
ProfileEncoding string `gorm:"size:32;not null"`
GeometryJSON string `gorm:"type:text;not null"`
Direction string `gorm:"size:16;not null"`
Enabled bool `gorm:"not null"`
SupersedesID string `gorm:"size:36"`
CreatedBy int `gorm:"not null"`
CreatedAt time.Time `gorm:"not null"`
}
func (Version) TableName() string { return "sense_area_versions" }
@@ -0,0 +1,95 @@
package area
import (
"context"
"errors"
"os"
"strings"
"sync"
"testing"
"gorm.io/driver/postgres"
"gorm.io/gorm"
)
func TestConcurrentUpdateOnPostgresReturnsConflict(t *testing.T) {
baseDSN := os.Getenv("SENSE_AREA_TEST_DATABASE_URL")
if baseDSN == "" {
t.Skip("set SENSE_AREA_TEST_DATABASE_URL to run the PostgreSQL concurrency test")
}
admin, err := gorm.Open(postgres.Open(baseDSN), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
const schema = "sense_area_69_concurrency"
if err = admin.Exec("DROP SCHEMA IF EXISTS " + schema + " CASCADE").Error; err != nil {
t.Fatal(err)
}
if err = admin.Exec("CREATE SCHEMA " + schema).Error; err != nil {
t.Fatal(err)
}
t.Cleanup(func() { admin.Exec("DROP SCHEMA IF EXISTS " + schema + " CASCADE") })
separator := "?"
if strings.Contains(baseDSN, "?") {
separator = "&"
}
db, err := gorm.Open(postgres.Open(baseDSN+separator+"search_path="+schema), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err = db.AutoMigrate(&Definition{}, &Version{}); err != nil {
t.Fatal(err)
}
for _, statement := range []string{
`CREATE TABLE sense_devices (id text primary key, name text, location text, status text)`,
`CREATE TABLE sense_admission_profiles (device_id text, token text, name text, width integer, height integer, encoding text, verification_status text)`,
`CREATE TABLE sense_media_routes (id text primary key, device_id text, profile_token text)`,
`INSERT INTO sense_devices VALUES ('device-1','东门摄像机','教学楼东门','active')`,
`INSERT INTO sense_admission_profiles VALUES ('device-1','main','主码流',1920,1080,'H264','ready')`,
`INSERT INTO sense_media_routes VALUES ('device-1:main','device-1','main')`,
} {
if err = db.Exec(statement).Error; err != nil {
t.Fatal(err)
}
}
service := NewService(db)
created, err := service.Create(context.Background(), triangleRequest())
if err != nil {
t.Fatal(err)
}
start := make(chan struct{})
errorsChannel := make(chan error, 2)
var wait sync.WaitGroup
for index := 0; index < 2; index++ {
wait.Add(1)
go func() {
defer wait.Done()
<-start
request := triangleRequest()
request.ExpectedVersion = created.Version
_, updateErr := service.Update(context.Background(), created.ID, request)
errorsChannel <- updateErr
}()
}
close(start)
wait.Wait()
close(errorsChannel)
successes, conflicts := 0, 0
for updateErr := range errorsChannel {
switch {
case updateErr == nil:
successes++
case errors.Is(updateErr, ErrConflict):
conflicts++
default:
t.Fatalf("unexpected concurrent update error: %v", updateErr)
}
}
if successes != 1 || conflicts != 1 {
t.Fatalf("successes=%d conflicts=%d", successes, conflicts)
}
versions, err := service.Versions(context.Background(), created.ID)
if err != nil || len(versions) != 2 {
t.Fatalf("versions=%d err=%v", len(versions), err)
}
}
+304
View File
@@ -0,0 +1,304 @@
package area
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"unicode/utf8"
"github.com/google/uuid"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
var (
ErrInvalidRequest = errors.New("区域配置请求不符合要求")
ErrNotFound = errors.New("区域配置不存在")
ErrConflict = errors.New("区域配置已被其他用户更新,请刷新后重试")
ErrProfileMissing = errors.New("绑定的视频 Profile 不可用,请先完成视频接入")
)
type Service struct{ db *gorm.DB }
func NewService(db *gorm.DB) *Service { return &Service{db: db} }
type routeSnapshot struct {
RouteID string `gorm:"column:route_id"`
DeviceID string `gorm:"column:device_id"`
DeviceName string `gorm:"column:device_name"`
DeviceLocation string `gorm:"column:device_location"`
ProfileToken string `gorm:"column:profile_token"`
ProfileName string `gorm:"column:profile_name"`
Width int `gorm:"column:width"`
Height int `gorm:"column:height"`
Encoding string `gorm:"column:encoding"`
Verification string `gorm:"column:verification_status"`
}
func (s *Service) List(ctx context.Context, request PageRequest) ([]Response, int64, error) {
request.PageIndex, request.PageSize = normalizePage(request.PageIndex, request.PageSize)
if utf8.RuneCountInString(request.Keyword) > 128 || (request.Kind != "" && request.Kind != KindPolygon && request.Kind != KindDirectionLine) {
return nil, 0, ErrInvalidRequest
}
query := s.db.WithContext(ctx).Model(&Definition{})
if keyword := strings.TrimSpace(request.Keyword); keyword != "" {
pattern := "%" + escapeLike(keyword) + "%"
query = query.Where("LOWER(name) LIKE LOWER(?) ESCAPE '\\'", pattern)
}
if request.Kind != "" {
query = query.Where("kind = ?", request.Kind)
}
switch request.RecalibrationState {
case "", "all":
case "needed":
query = query.Where("needs_recalibration = ?", true)
case "ready":
query = query.Where("needs_recalibration = ?", false)
default:
return nil, 0, ErrInvalidRequest
}
var total int64
if err := query.Count(&total).Error; err != nil {
return nil, 0, err
}
var definitions []Definition
if err := query.Order("updated_at DESC, name ASC").Offset((request.PageIndex - 1) * request.PageSize).Limit(request.PageSize).Find(&definitions).Error; err != nil {
return nil, 0, err
}
items := make([]Response, 0, len(definitions))
for i := range definitions {
item, err := s.response(ctx, &definitions[i])
if err != nil {
return nil, 0, err
}
items = append(items, item)
}
return items, total, nil
}
func (s *Service) Create(ctx context.Context, request UpsertRequest) (Response, error) {
if request.ExpectedVersion != 0 {
return Response{}, ErrInvalidRequest
}
name, err := validateRequest(request)
if err != nil {
return Response{}, err
}
now := time.Now().UTC()
definitionID := uuid.NewString()
versionID := uuid.NewString()
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
route, routeErr := loadRoute(tx, request.RouteID)
if routeErr != nil {
return routeErr
}
geometry, marshalErr := json.Marshal(request.Points)
if marshalErr != nil {
return ErrInvalidGeometry
}
definition := Definition{ID: definitionID, Name: name, Kind: request.Kind, DeviceID: route.DeviceID, ProfileToken: route.ProfileToken, ProfileWidth: route.Width, ProfileHeight: route.Height, ProfileEncoding: route.Encoding, CurrentVersion: 1, CurrentVersionID: versionID, Enabled: request.Enabled, CreatedBy: request.UpdateBy, UpdatedBy: request.UpdateBy, CreatedAt: now, UpdatedAt: now}
version := Version{ID: versionID, DefinitionID: definitionID, Version: 1, Name: name, Kind: request.Kind, DeviceID: route.DeviceID, ProfileToken: route.ProfileToken, ProfileWidth: route.Width, ProfileHeight: route.Height, ProfileEncoding: route.Encoding, GeometryJSON: string(geometry), Direction: request.Direction, Enabled: request.Enabled, CreatedBy: request.UpdateBy, CreatedAt: now}
if err = tx.Create(&definition).Error; err != nil {
return err
}
return tx.Create(&version).Error
})
if err != nil {
return Response{}, err
}
return s.Get(ctx, definitionID)
}
func (s *Service) Update(ctx context.Context, id string, request UpsertRequest) (Response, error) {
if strings.TrimSpace(id) == "" || request.ExpectedVersion < 1 {
return Response{}, ErrInvalidRequest
}
name, err := validateRequest(request)
if err != nil {
return Response{}, err
}
now := time.Now().UTC()
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var current Definition
if readErr := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&current, "id = ?", id).Error; readErr != nil {
if errors.Is(readErr, gorm.ErrRecordNotFound) {
return ErrNotFound
}
return readErr
}
if current.CurrentVersion != request.ExpectedVersion {
return ErrConflict
}
route, routeErr := loadRoute(tx, request.RouteID)
if routeErr != nil {
return routeErr
}
geometry, marshalErr := json.Marshal(request.Points)
if marshalErr != nil {
return ErrInvalidGeometry
}
versionID := uuid.NewString()
version := Version{ID: versionID, DefinitionID: current.ID, Version: current.CurrentVersion + 1, Name: name, Kind: request.Kind, DeviceID: route.DeviceID, ProfileToken: route.ProfileToken, ProfileWidth: route.Width, ProfileHeight: route.Height, ProfileEncoding: route.Encoding, GeometryJSON: string(geometry), Direction: request.Direction, Enabled: request.Enabled, SupersedesID: current.CurrentVersionID, CreatedBy: request.UpdateBy, CreatedAt: now}
if createErr := tx.Create(&version).Error; createErr != nil {
return createErr
}
updates := map[string]any{"name": name, "kind": request.Kind, "device_id": route.DeviceID, "profile_token": route.ProfileToken, "profile_width": route.Width, "profile_height": route.Height, "profile_encoding": route.Encoding, "current_version": version.Version, "current_version_id": versionID, "enabled": request.Enabled, "needs_recalibration": false, "updated_by": request.UpdateBy, "updated_at": now}
result := tx.Model(&Definition{}).Where("id = ? AND current_version = ?", current.ID, request.ExpectedVersion).Updates(updates)
if result.Error != nil {
return result.Error
}
if result.RowsAffected != 1 {
return ErrConflict
}
return nil
})
if err != nil {
return Response{}, err
}
return s.Get(ctx, id)
}
func (s *Service) Get(ctx context.Context, id string) (Response, error) {
var definition Definition
if err := s.db.WithContext(ctx).First(&definition, "id = ?", id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return Response{}, ErrNotFound
}
return Response{}, err
}
return s.response(ctx, &definition)
}
func (s *Service) Versions(ctx context.Context, id string) ([]VersionResponse, error) {
var count int64
if err := s.db.WithContext(ctx).Model(&Definition{}).Where("id = ?", id).Count(&count).Error; err != nil {
return nil, err
}
if count == 0 {
return nil, ErrNotFound
}
var versions []Version
if err := s.db.WithContext(ctx).Where("definition_id = ?", id).Order("version DESC").Find(&versions).Error; err != nil {
return nil, err
}
out := make([]VersionResponse, 0, len(versions))
for _, version := range versions {
points, err := decodePoints(version.GeometryJSON)
if err != nil {
return nil, fmt.Errorf("decode area version %d: %w", version.Version, err)
}
out = append(out, VersionResponse{ID: version.ID, Version: version.Version, Name: version.Name, Kind: version.Kind, DeviceID: version.DeviceID, ProfileToken: version.ProfileToken, ProfileWidth: version.ProfileWidth, ProfileHeight: version.ProfileHeight, ProfileEncoding: version.ProfileEncoding, Points: points, Direction: version.Direction, Enabled: version.Enabled, SupersedesID: version.SupersedesID, CreatedBy: version.CreatedBy, CreatedAt: version.CreatedAt})
}
return out, nil
}
func (s *Service) response(ctx context.Context, definition *Definition) (Response, error) {
var version Version
if err := s.db.WithContext(ctx).First(&version, "id = ?", definition.CurrentVersionID).Error; err != nil {
return Response{}, err
}
points, err := decodePoints(version.GeometryJSON)
if err != nil {
return Response{}, err
}
var route routeSnapshot
query := s.db.WithContext(ctx).Table("sense_devices AS d").
Select("COALESCE(r.id, '') AS route_id, d.id AS device_id, d.name AS device_name, d.location AS device_location, COALESCE(p.token, '') AS profile_token, COALESCE(p.name, '') AS profile_name, COALESCE(p.width, 0) AS width, COALESCE(p.height, 0) AS height, COALESCE(p.encoding, '') AS encoding, COALESCE(p.verification_status, '') AS verification_status").
Joins("LEFT JOIN sense_admission_profiles AS p ON p.device_id = d.id AND p.token = ?", definition.ProfileToken).
Joins("LEFT JOIN sense_media_routes AS r ON r.device_id = d.id AND r.profile_token = ?", definition.ProfileToken).
Where("d.id = ?", definition.DeviceID).Limit(1).Scan(&route)
if query.Error != nil {
return Response{}, query.Error
}
recalibration := definition.NeedsRecalibration || route.ProfileToken == "" || route.Verification != "ready" || route.Width != definition.ProfileWidth || route.Height != definition.ProfileHeight || !strings.EqualFold(route.Encoding, definition.ProfileEncoding)
if recalibration && !definition.NeedsRecalibration {
if err = s.db.WithContext(ctx).Model(&Definition{}).Where("id = ? AND needs_recalibration = ?", definition.ID, false).Update("needs_recalibration", true).Error; err != nil {
return Response{}, err
}
definition.NeedsRecalibration = true
}
return Response{ID: definition.ID, Name: definition.Name, Kind: definition.Kind, DeviceID: definition.DeviceID, DeviceName: route.DeviceName, DeviceLocation: route.DeviceLocation, ProfileToken: definition.ProfileToken, ProfileName: route.ProfileName, ProfileWidth: definition.ProfileWidth, ProfileHeight: definition.ProfileHeight, ProfileEncoding: definition.ProfileEncoding, RouteID: route.RouteID, Version: definition.CurrentVersion, Points: points, Direction: version.Direction, Enabled: definition.Enabled, NeedsRecalibration: recalibration, UpdatedBy: definition.UpdatedBy, UpdatedAt: definition.UpdatedAt}, nil
}
func MarkProfilesReplaced(tx *gorm.DB, deviceID string, profiles []ProfileSnapshot) error {
if tx == nil || !tx.Migrator().HasTable(&Definition{}) {
return nil
}
available := make(map[string]ProfileSnapshot, len(profiles))
for _, profile := range profiles {
available[profile.Token] = profile
}
var definitions []Definition
if err := tx.Where("device_id = ? AND needs_recalibration = ?", deviceID, false).Find(&definitions).Error; err != nil {
return err
}
for _, definition := range definitions {
profile, ok := available[definition.ProfileToken]
if !ok || profile.Width != definition.ProfileWidth || profile.Height != definition.ProfileHeight || !strings.EqualFold(profile.Encoding, definition.ProfileEncoding) {
if err := tx.Model(&Definition{}).Where("id = ?", definition.ID).Update("needs_recalibration", true).Error; err != nil {
return err
}
}
}
return nil
}
func loadRoute(db *gorm.DB, routeID string) (routeSnapshot, error) {
if strings.TrimSpace(routeID) == "" {
return routeSnapshot{}, ErrProfileMissing
}
var route routeSnapshot
err := db.Table("sense_media_routes AS r").
Select("r.id AS route_id, r.device_id, d.name AS device_name, d.location AS device_location, r.profile_token, p.name AS profile_name, p.width, p.height, p.encoding, p.verification_status").
Joins("JOIN sense_devices AS d ON d.id = r.device_id").
Joins("JOIN sense_admission_profiles AS p ON p.device_id = r.device_id AND p.token = r.profile_token").
Where("r.id = ? AND p.verification_status = ?", routeID, "ready").Limit(1).Scan(&route).Error
if err != nil {
return routeSnapshot{}, err
}
if route.RouteID == "" || route.Width < 1 || route.Height < 1 {
return routeSnapshot{}, ErrProfileMissing
}
return route, nil
}
func validateRequest(request UpsertRequest) (string, error) {
name := strings.TrimSpace(request.Name)
if name == "" || utf8.RuneCountInString(name) > 128 || request.UpdateBy < 1 || strings.TrimSpace(request.RouteID) == "" {
return "", ErrInvalidRequest
}
if err := validateGeometry(request.Kind, request.Direction, request.Points); err != nil {
return "", err
}
return name, nil
}
func decodePoints(value string) ([]Point, error) {
var points []Point
if err := json.Unmarshal([]byte(value), &points); err != nil {
return nil, err
}
return points, nil
}
func normalizePage(index, size int) (int, int) {
if index < 1 {
index = 1
}
if size < 1 {
size = 10
}
if size > 50 {
size = 50
}
return index, size
}
func escapeLike(value string) string {
value = strings.ReplaceAll(value, `\`, `\\`)
value = strings.ReplaceAll(value, `%`, `\%`)
return strings.ReplaceAll(value, `_`, `\_`)
}
@@ -0,0 +1,95 @@
package area
import (
"context"
"errors"
"testing"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
func areaTestDB(t *testing.T) *gorm.DB {
t.Helper()
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err = db.AutoMigrate(&Definition{}, &Version{}); err != nil {
t.Fatal(err)
}
statements := []string{
`CREATE TABLE sense_devices (id text primary key, name text, location text, status text)`,
`CREATE TABLE sense_admission_profiles (device_id text, token text, name text, width integer, height integer, encoding text, verification_status text)`,
`CREATE TABLE sense_media_routes (id text primary key, device_id text, profile_token text)`,
`INSERT INTO sense_devices VALUES ('device-1','东门摄像机','教学楼东门','active')`,
`INSERT INTO sense_admission_profiles VALUES ('device-1','main','主码流',1920,1080,'H264','ready')`,
`INSERT INTO sense_media_routes VALUES ('device-1:main','device-1','main')`,
}
for _, statement := range statements {
if err = db.Exec(statement).Error; err != nil {
t.Fatal(err)
}
}
return db
}
func triangleRequest() UpsertRequest {
return UpsertRequest{Name: "操场危险区域", Kind: KindPolygon, RouteID: "device-1:main", Points: []Point{{0.1, 0.1}, {0.8, 0.1}, {0.5, 0.8}}, Enabled: true, UpdateBy: 7}
}
func TestVersionsAreAppendOnlyAndUseOptimisticConcurrency(t *testing.T) {
service := NewService(areaTestDB(t))
created, err := service.Create(context.Background(), triangleRequest())
if err != nil || created.Version != 1 || created.ProfileWidth != 1920 {
t.Fatalf("created=%+v err=%v", created, err)
}
request := triangleRequest()
request.Name = "操场危险区域(校准)"
request.ExpectedVersion = 1
updated, err := service.Update(context.Background(), created.ID, request)
if err != nil || updated.Version != 2 {
t.Fatalf("updated=%+v err=%v", updated, err)
}
if _, err = service.Update(context.Background(), created.ID, request); !errors.Is(err, ErrConflict) {
t.Fatalf("stale update err=%v", err)
}
versions, err := service.Versions(context.Background(), created.ID)
if err != nil || len(versions) != 2 || versions[0].Version != 2 || versions[1].Version != 1 || versions[0].SupersedesID != versions[1].ID {
t.Fatalf("versions=%+v err=%v", versions, err)
}
}
func TestProfileReplacementMarksOnlyChangedBindings(t *testing.T) {
db := areaTestDB(t)
service := NewService(db)
created, err := service.Create(context.Background(), triangleRequest())
if err != nil {
t.Fatal(err)
}
if err = MarkProfilesReplaced(db, "device-1", []ProfileSnapshot{{Token: "main", Width: 1920, Height: 1080, Encoding: "H264"}}); err != nil {
t.Fatal(err)
}
item, _ := service.Get(context.Background(), created.ID)
if item.NeedsRecalibration {
t.Fatal("unchanged profile was marked for recalibration")
}
if err = MarkProfilesReplaced(db, "device-1", []ProfileSnapshot{{Token: "main", Width: 1280, Height: 720, Encoding: "H264"}}); err != nil {
t.Fatal(err)
}
item, _ = service.Get(context.Background(), created.ID)
if !item.NeedsRecalibration {
t.Fatal("resolution change did not mark recalibration")
}
}
func TestListIsPaginatedAndDoesNotExposeMediaSecrets(t *testing.T) {
service := NewService(areaTestDB(t))
if _, err := service.Create(context.Background(), triangleRequest()); err != nil {
t.Fatal(err)
}
items, total, err := service.List(context.Background(), PageRequest{Keyword: "操场", PageIndex: 1, PageSize: 10})
if err != nil || total != 1 || len(items) != 1 || items[0].RouteID != "device-1:main" {
t.Fatalf("items=%+v total=%d err=%v", items, total, err)
}
}
@@ -0,0 +1,60 @@
package version
import (
"runtime"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/area"
"git.ilapage.cn/ila/yovision/Sense/server/cmd/migrate/migration"
migrationModels "git.ilapage.cn/ila/yovision/Sense/server/cmd/migrate/migration/models"
common "git.ilapage.cn/ila/yovision/Sense/server/common/models"
)
func init() {
_, fileName, _, _ := runtime.Caller(0)
migration.Migrate.SetVersion(migration.GetFilename(fileName), migrateSenseArea)
}
func migrateSenseArea(db *gorm.DB, version string) error {
return db.Transaction(func(tx *gorm.DB) error {
if err := tx.AutoMigrate(&area.Definition{}, &area.Version{}); err != nil {
return err
}
page, err := ensureDeviceMenu(tx, migrationModels.SysMenu{MenuName: "SenseArea", Title: "区域与警戒线", Icon: "guide", Path: "/sense/area", MenuType: "C", Permission: "sense:area:list", Component: "/sense/area/index", Sort: 9, Visible: "0", IsFrame: "1"})
if err != nil {
return err
}
create, err := ensureDeviceMenu(tx, migrationModels.SysMenu{MenuName: "SenseAreaCreate", Title: "新增配置", MenuType: "F", Action: "POST", Permission: "sense:area:create", ParentId: page.MenuId, Sort: 1, Visible: "1", IsFrame: "1"})
if err != nil {
return err
}
update, err := ensureDeviceMenu(tx, migrationModels.SysMenu{MenuName: "SenseAreaUpdate", Title: "编辑配置", MenuType: "F", Action: "PUT", Permission: "sense:area:update", ParentId: page.MenuId, Sort: 2, Visible: "1", IsFrame: "1"})
if err != nil {
return err
}
for _, role := range []string{"implementation_operator", "site_admin"} {
if err = attachDeviceRole(tx, role, []migrationModels.SysMenu{page, create, update}); err != nil {
return err
}
}
if err = attachDeviceRole(tx, "viewer", []migrationModels.SysMenu{page}); err != nil {
return err
}
read := [][2]string{{"/api/v1/area/configurations", "GET"}, {"/api/v1/area/configurations/:id/versions", "GET"}}
write := [][2]string{{"/api/v1/area/configurations", "POST"}, {"/api/v1/area/configurations/:id", "PUT"}}
for _, role := range []string{"implementation_operator", "site_admin", "viewer"} {
policies := append([][2]string{}, read...)
if role != "viewer" {
policies = append(policies, write...)
}
for _, policy := range policies {
if err = tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&deviceCasbinRule{Ptype: "p", V0: role, V1: policy[0], V2: policy[1]}).Error; err != nil {
return err
}
}
}
return tx.Create(&common.Migration{Version: version}).Error
})
}
@@ -0,0 +1,60 @@
package version
import (
"os"
"testing"
"gorm.io/driver/postgres"
"gorm.io/gorm"
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/area"
migrationModels "git.ilapage.cn/ila/yovision/Sense/server/cmd/migrate/migration/models"
common "git.ilapage.cn/ila/yovision/Sense/server/common/models"
)
func TestAreaMigrationOnPostgres(t *testing.T) {
dsn := os.Getenv("SENSE_AREA_MIGRATION_TEST_DATABASE_URL")
if dsn == "" {
t.Skip("set SENSE_AREA_MIGRATION_TEST_DATABASE_URL to run the PostgreSQL migration test")
}
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
const schema = "sense_area_69_test"
if err = db.Exec("DROP SCHEMA IF EXISTS " + schema + " CASCADE").Error; err != nil {
t.Fatal(err)
}
if err = db.Exec("CREATE SCHEMA " + schema).Error; err != nil {
t.Fatal(err)
}
t.Cleanup(func() { db.Exec("DROP SCHEMA IF EXISTS " + schema + " CASCADE") })
sqlDB, err := db.DB()
if err != nil {
t.Fatal(err)
}
sqlDB.SetMaxOpenConns(1)
if err = db.Exec("SET search_path TO " + schema).Error; err != nil {
t.Fatal(err)
}
if err = db.AutoMigrate(&migrationModels.SysRole{}, &migrationModels.SysMenu{}, &deviceCasbinRule{}, &common.Migration{}); err != nil {
t.Fatal(err)
}
for _, role := range []string{"implementation_operator", "site_admin", "viewer"} {
if err = db.Create(&migrationModels.SysRole{RoleName: role, RoleKey: role, Status: "2"}).Error; err != nil {
t.Fatal(err)
}
}
if err = migrateSenseArea(db, "2026081509000_area.go"); err != nil {
t.Fatal(err)
}
var menus, policies, definitions, versions, applied int64
db.Model(&migrationModels.SysMenu{}).Where("menu_name LIKE ?", "SenseArea%").Count(&menus)
db.Model(&deviceCasbinRule{}).Where("v1 LIKE ?", "/api/v1/area%").Count(&policies)
db.Model(&area.Definition{}).Count(&definitions)
db.Model(&area.Version{}).Count(&versions)
db.Model(&common.Migration{}).Where("version = ?", "2026081509000_area.go").Count(&applied)
if menus != 3 || policies != 10 || definitions != 0 || versions != 0 || applied != 1 {
t.Fatalf("menus=%d policies=%d definitions=%d versions=%d applied=%d", menus, policies, definitions, versions, applied)
}
}
+17
View File
@@ -0,0 +1,17 @@
import request from '@/utils/request'
export function listAreaConfigurations(query) {
return request({ url: '/api/v1/area/configurations', method: 'get', params: query })
}
export function createAreaConfiguration(data) {
return request({ url: '/api/v1/area/configurations', method: 'post', data })
}
export function updateAreaConfiguration(id, data) {
return request({ url: `/api/v1/area/configurations/${id}`, method: 'put', data })
}
export function listAreaVersions(id) {
return request({ url: `/api/v1/area/configurations/${id}/versions`, method: 'get' })
}
@@ -0,0 +1,55 @@
export const POLYGON = 'polygon'
export const DIRECTION_LINE = 'direction_line'
export function clamp(value) {
return Math.min(1, Math.max(0, Number(value)))
}
export function geometryError(kind, points, direction = '') {
if (!Array.isArray(points)) return '请在画面中添加坐标点'
if (kind === POLYGON && (points.length < 3 || points.length > 64)) return '多边形需要 3 到 64 个点'
if (kind === DIRECTION_LINE && points.length !== 2) return '方向警戒线需要恰好 2 个点'
if (kind === DIRECTION_LINE && !['forward', 'reverse'].includes(direction)) return '请选择警戒方向'
if (![POLYGON, DIRECTION_LINE].includes(kind)) return '请选择配置类型'
if (points.some(point => !Number.isFinite(point.x) || !Number.isFinite(point.y) || point.x < 0 || point.x > 1 || point.y < 0 || point.y > 1)) return '坐标必须位于画面范围内'
if (kind === POLYGON && selfIntersects(points)) return '多边形边线不能交叉,请调整顶点'
if (kind === POLYGON && Math.abs(polygonArea(points)) < 0.000001) return '多边形面积过小,请重新绘制'
if (kind === DIRECTION_LINE && samePoint(points[0], points[1])) return '警戒线起点和终点不能重合'
return ''
}
function samePoint(a, b) {
return Math.abs(a.x - b.x) < 0.0000001 && Math.abs(a.y - b.y) < 0.0000001
}
function polygonArea(points) {
return points.reduce((total, point, index) => {
const next = points[(index + 1) % points.length]
return total + point.x * next.y - next.x * point.y
}, 0) / 2
}
function selfIntersects(points) {
for (let first = 0; first < points.length; first += 1) {
const a = points[first]
const b = points[(first + 1) % points.length]
for (let second = first + 1; second < points.length; second += 1) {
if (second === first || second === (first + 1) % points.length || first === (second + 1) % points.length) continue
const c = points[second]
const d = points[(second + 1) % points.length]
if (segmentsIntersect(a, b, c, d)) return true
}
}
return false
}
function segmentsIntersect(a, b, c, d) {
const orientation = (p, q, r) => (q.y - p.y) * (r.x - q.x) - (q.x - p.x) * (r.y - q.y)
const values = [orientation(a, b, c), orientation(a, b, d), orientation(c, d, a), orientation(c, d, b)]
if (((values[0] > 0 && values[1] < 0) || (values[0] < 0 && values[1] > 0)) && ((values[2] > 0 && values[3] < 0) || (values[2] < 0 && values[3] > 0))) return true
const onSegment = (p, q, r) => q.x <= Math.max(p.x, r.x) + 0.0000001 && q.x >= Math.min(p.x, r.x) - 0.0000001 && q.y <= Math.max(p.y, r.y) + 0.0000001 && q.y >= Math.min(p.y, r.y) - 0.0000001
return (Math.abs(values[0]) < 0.0000001 && onSegment(a, c, b)) ||
(Math.abs(values[1]) < 0.0000001 && onSegment(a, d, b)) ||
(Math.abs(values[2]) < 0.0000001 && onSegment(c, a, d)) ||
(Math.abs(values[3]) < 0.0000001 && onSegment(c, b, d))
}
@@ -0,0 +1,307 @@
<template>
<section class="geometry-editor" aria-labelledby="geometry-editor-title">
<div class="editor-toolbar">
<div>
<strong id="geometry-editor-title">画面坐标</strong>
<span>{{ instruction }}</span>
</div>
<div class="editor-actions">
<el-button :disabled="disabled || !canAdd" @click="addCenterPoint">添加中心点</el-button>
<el-button :disabled="disabled || undoStack.length === 0" @click="undo">撤销</el-button>
<el-button :disabled="disabled || points.length === 0" @click="clear">清空</el-button>
</div>
</div>
<div class="canvas-shell" :class="{ 'is-disabled': disabled }">
<iframe
v-if="playerUrl"
:src="playerUrl"
title="区域校准实时视频"
allow="autoplay; fullscreen"
referrerpolicy="no-referrer"
tabindex="-1"
/>
<div v-else class="canvas-placeholder">选择可用视频后,可在实时画面上绘制</div>
<svg
ref="canvas"
class="geometry-canvas"
viewBox="0 0 1000 562.5"
role="application"
:aria-label="instruction"
tabindex="0"
@click="addFromPointer"
@pointermove="dragPoint"
@pointerup="stopDrag"
@pointercancel="stopDrag"
@keydown.enter.prevent="addCenterPoint"
@keydown.space.prevent="addCenterPoint"
>
<defs>
<marker id="sense-area-arrow" markerWidth="12" markerHeight="12" refX="8" refY="4" orient="auto" markerUnits="strokeWidth">
<path d="M0,0 L0,8 L10,4 z" class="arrow-head" />
</marker>
</defs>
<polygon v-if="kind === 'polygon' && points.length >= 2" :points="svgPoints" class="area-shape" />
<line
v-if="kind === 'direction_line' && points.length === 2"
:x1="scaledPoints[0].x"
:y1="scaledPoints[0].y"
:x2="scaledPoints[1].x"
:y2="scaledPoints[1].y"
class="direction-line"
:marker-start="direction === 'reverse' ? 'url(#sense-area-arrow)' : undefined"
:marker-end="direction === 'forward' ? 'url(#sense-area-arrow)' : undefined"
/>
<g
v-for="(point, index) in scaledPoints"
:key="index"
class="point-control"
role="button"
tabindex="0"
:aria-label="`坐标点 ${index + 1},横向 ${Math.round(points[index].x * 100)}%,纵向 ${Math.round(points[index].y * 100)}%`"
@click.stop
@pointerdown.stop.prevent="startDrag(index, $event)"
@keydown="movePointByKeyboard(index, $event)"
>
<circle :cx="point.x" :cy="point.y" r="12" />
<text :x="point.x" :y="point.y + 4" text-anchor="middle">{{ index + 1 }}</text>
</g>
</svg>
</div>
<div class="point-summary" aria-live="polite">
<span>已添加 {{ points.length }} 个点</span>
<span>键盘:Enter 添加中心点;聚焦顶点后用方向键移动,Delete 删除。</span>
</div>
<p v-if="error" class="geometry-error" role="alert">{{ error }}</p>
</section>
</template>
<script>
import { clamp, geometryError } from './geometry'
export default {
name: 'SenseGeometryEditor',
props: {
modelValue: { type: Array, default: () => [] },
kind: { type: String, required: true },
direction: { type: String, default: '' },
playerUrl: { type: String, default: '' },
disabled: { type: Boolean, default: false }
},
emits: ['update:modelValue'],
data() {
return { undoStack: [], draggingIndex: -1 }
},
computed: {
points() {
return this.modelValue || []
},
scaledPoints() {
return this.points.map(point => ({ x: point.x * 1000, y: point.y * 562.5 }))
},
svgPoints() {
return this.scaledPoints.map(point => `${point.x},${point.y}`).join(' ')
},
canAdd() {
return this.kind === 'polygon' ? this.points.length < 64 : this.points.length < 2
},
instruction() {
return this.kind === 'polygon' ? '点击画面添加顶点,拖动顶点调整危险区域' : '依次添加起点和终点,箭头表示警戒方向'
},
error() {
if (this.points.length === 0) return ''
return geometryError(this.kind, this.points, this.direction)
}
},
methods: {
commit(next, remember = true) {
if (this.disabled) return
if (remember) this.undoStack.push(this.points.map(point => ({ ...point })))
this.$emit('update:modelValue', next.map(point => ({ x: clamp(point.x), y: clamp(point.y) })))
},
addFromPointer(event) {
if (this.disabled || !this.canAdd || event.target.closest('.point-control')) return
this.commit([...this.points, this.eventPoint(event)])
},
addCenterPoint() {
if (!this.disabled && this.canAdd) this.commit([...this.points, { x: 0.5, y: 0.5 }])
},
clear() {
if (this.points.length) this.commit([])
},
undo() {
if (!this.undoStack.length || this.disabled) return
const previous = this.undoStack.pop()
this.commit(previous, false)
},
startDrag(index, event) {
if (this.disabled) return
this.undoStack.push(this.points.map(point => ({ ...point })))
this.draggingIndex = index
event.currentTarget.setPointerCapture?.(event.pointerId)
},
dragPoint(event) {
if (this.draggingIndex < 0 || this.disabled) return
const next = this.points.map(point => ({ ...point }))
next[this.draggingIndex] = this.eventPoint(event)
this.commit(next, false)
},
stopDrag() {
this.draggingIndex = -1
},
movePointByKeyboard(index, event) {
if (this.disabled) return
if (event.key === 'Delete' || event.key === 'Backspace') {
event.preventDefault()
this.commit(this.points.filter((_, pointIndex) => pointIndex !== index))
return
}
const movement = { ArrowLeft: [-0.01, 0], ArrowRight: [0.01, 0], ArrowUp: [0, -0.01], ArrowDown: [0, 0.01] }[event.key]
if (!movement) return
event.preventDefault()
const next = this.points.map(point => ({ ...point }))
next[index] = { x: next[index].x + movement[0], y: next[index].y + movement[1] }
this.commit(next)
},
eventPoint(event) {
const rect = this.$refs.canvas.getBoundingClientRect()
return { x: clamp((event.clientX - rect.left) / rect.width), y: clamp((event.clientY - rect.top) / rect.height) }
}
}
}
</script>
<style scoped>
.geometry-editor {
width: 100%;
}
.editor-toolbar,
.point-summary {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.editor-toolbar > div:first-child {
display: flex;
flex-direction: column;
gap: 4px;
}
.editor-toolbar span,
.point-summary {
color: var(--el-text-color-secondary);
font-size: 13px;
line-height: 1.5;
}
.editor-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.editor-actions :deep(.el-button + .el-button) {
margin-left: 0;
}
.canvas-shell {
position: relative;
width: 100%;
margin-top: 12px;
aspect-ratio: 16 / 9;
overflow: hidden;
border: 1px solid var(--el-border-color);
border-radius: var(--el-border-radius-base);
background: var(--el-fill-color-darker);
}
.canvas-shell iframe,
.geometry-canvas,
.canvas-placeholder {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
border: 0;
}
.canvas-placeholder {
display: flex;
align-items: center;
justify-content: center;
color: var(--el-text-color-secondary);
}
.geometry-canvas {
cursor: crosshair;
outline-offset: -3px;
touch-action: none;
}
.geometry-canvas:focus-visible {
outline: 3px solid var(--el-color-primary);
}
.area-shape {
fill: color-mix(in srgb, var(--el-color-warning) 24%, transparent);
stroke: var(--el-color-warning-dark-2);
stroke-width: 4;
}
.direction-line {
stroke: var(--el-color-danger);
stroke-width: 6;
}
.arrow-head {
fill: var(--el-color-danger);
}
.point-control {
cursor: grab;
outline: none;
}
.point-control circle {
fill: var(--el-color-primary);
stroke: var(--el-color-white);
stroke-width: 3;
}
.point-control text {
fill: var(--el-color-white);
font-size: 12px;
pointer-events: none;
}
.point-control:focus-visible circle {
stroke: var(--el-color-warning);
stroke-width: 6;
}
.point-summary {
margin-top: 8px;
flex-wrap: wrap;
}
.geometry-error {
margin: 8px 0 0;
color: var(--el-color-danger);
line-height: 1.5;
}
.is-disabled .geometry-canvas {
cursor: not-allowed;
}
@media (max-width: 767px) {
.editor-toolbar {
align-items: flex-start;
flex-direction: column;
}
}
</style>
@@ -0,0 +1,20 @@
import { DIRECTION_LINE, geometryError } from '@/components/sense/geometry-editor/geometry'
export function buildAreaPayload(form) {
return {
name: String(form.name || '').trim(),
kind: form.kind,
routeId: form.routeId,
points: (form.points || []).map(point => ({ x: Number(point.x), y: Number(point.y) })),
direction: form.kind === DIRECTION_LINE ? form.direction : '',
enabled: Boolean(form.enabled),
expectedVersion: Number(form.version || 0)
}
}
export function validateAreaForm(form) {
if (!String(form.name || '').trim()) return '请填写配置名称'
if (!form.routeId) return '请选择设备与视频码流'
const payload = buildAreaPayload(form)
return geometryError(payload.kind, payload.points, payload.direction)
}
+455
View File
@@ -0,0 +1,455 @@
<template>
<BasicLayout>
<template #wrapper>
<el-card class="box-card">
<div class="page-header">
<div>
<h3>区域与警戒线</h3>
<p>在设备画面上配置危险区域或方向警戒线;画面规格变化后必须重新校准。</p>
</div>
<el-button v-permisaction="['sense:area:create']" type="primary" @click="openCreate">新增配置</el-button>
</div>
<el-form ref="queryForm" :model="queryParams" inline class="search-form" @submit.prevent>
<el-form-item label="名称" prop="keyword">
<el-input v-model.trim="queryParams.keyword" clearable placeholder="输入配置名称" @keyup.enter="handleQuery" />
</el-form-item>
<el-form-item label="类型" prop="kind">
<el-select v-model="queryParams.kind" clearable placeholder="全部类型">
<el-option label="危险区域" value="polygon" />
<el-option label="方向警戒线" value="direction_line" />
</el-select>
</el-form-item>
<el-form-item label="校准状态" prop="recalibrationState">
<el-select v-model="queryParams.recalibrationState" clearable placeholder="全部状态">
<el-option label="需要重新校准" value="needed" />
<el-option label="已校准" value="ready" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="loading" @click="handleQuery">搜索</el-button>
<el-button @click="resetQuery">重置</el-button>
</el-form-item>
</el-form>
<el-alert
v-if="!loading && total === 0"
title="尚无区域配置。请先确认实时监看可用,再新增危险区域或方向警戒线。"
type="info"
:closable="false"
show-icon
class="empty-alert"
/>
<el-table v-loading="loading" :data="items" border stripe>
<el-table-column prop="name" label="配置名称" min-width="180" show-overflow-tooltip />
<el-table-column label="类型" width="130">
<template #default="scope">
<el-tag size="small" :type="scope.row.kind === 'polygon' ? 'warning' : 'danger'">{{ kindLabel(scope.row.kind) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="设备与码流" min-width="220">
<template #default="scope">
<div>{{ scope.row.deviceName || scope.row.deviceId }}</div>
<small class="muted-text">{{ profileLabel(scope.row) }}</small>
</template>
</el-table-column>
<el-table-column label="版本" width="90">
<template #default="scope">v{{ scope.row.version }}</template>
</el-table-column>
<el-table-column label="校准状态" width="150">
<template #default="scope">
<el-tag v-if="scope.row.needsRecalibration" type="danger" size="small">需要重新校准</el-tag>
<el-tag v-else type="success" size="small">已校准</el-tag>
</template>
</el-table-column>
<el-table-column label="启用状态" width="100">
<template #default="scope">
<el-tag :type="scope.row.enabled ? 'success' : 'info'" size="small">{{ scope.row.enabled ? '已启用' : '已停用' }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="updatedAt" label="最近更新" min-width="170">
<template #default="scope">{{ parseTime(scope.row.updatedAt) }}</template>
</el-table-column>
<el-table-column label="操作" width="180" fixed="right">
<template #default="scope">
<el-button v-permisaction="['sense:area:update']" type="primary" link @click="openEdit(scope.row)">编辑/校准</el-button>
<el-button type="primary" link @click="openVersions(scope.row)">版本记录</el-button>
</template>
</el-table-column>
</el-table>
<pagination
v-show="total > 0"
v-model:page="queryParams.pageIndex"
v-model:limit="queryParams.pageSize"
:total="total"
@pagination="getList"
/>
</el-card>
<el-dialog v-model="editorOpen" width="min(1100px, 94vw)" :close-on-click-modal="false" :before-close="beforeEditorClose" destroy-on-close>
<template #header>
<div class="dialog-header">
<div>
<strong>{{ form.id ? '编辑并生成新版本' : '新增区域配置' }}</strong>
<span v-if="form.id">当前 v{{ form.version }};保存后旧版本仍可追溯</span>
</div>
<el-tag v-if="form.needsRecalibration" type="danger">需要重新校准</el-tag>
</div>
</template>
<el-alert
v-if="form.needsRecalibration"
title="绑定的 Profile、分辨率或编码已变化。请重新选择码流并确认画面坐标后保存新版本。"
type="warning"
:closable="false"
show-icon
class="dialog-alert"
/>
<el-form ref="editorForm" :model="form" :rules="rules" label-width="110px" @change="dirty = true">
<div class="form-grid">
<el-form-item label="配置名称" prop="name">
<el-input v-model.trim="form.name" maxlength="128" show-word-limit placeholder="例如:操场北侧危险区域" @input="dirty = true" />
</el-form-item>
<el-form-item label="配置类型" prop="kind">
<el-select v-model="form.kind" :disabled="Boolean(form.id)" @change="handleKindChange">
<el-option label="危险区域" value="polygon" />
<el-option label="方向警戒线" value="direction_line" />
</el-select>
</el-form-item>
<el-form-item label="设备与码流" prop="routeId" class="route-field">
<el-select
v-model="form.routeId"
filterable
remote
:remote-method="searchRoutes"
:loading="routesLoading"
placeholder="输入设备、位置或码流名称"
@change="handleRouteChange"
>
<el-option v-for="route in routeOptions" :key="route.id" :label="routeLabel(route)" :value="route.id" />
</el-select>
</el-form-item>
<el-form-item v-if="form.kind === 'direction_line'" label="警戒方向" prop="direction">
<el-select v-model="form.direction" @change="dirty = true">
<el-option label="从起点到终点" value="forward" />
<el-option label="从终点到起点" value="reverse" />
</el-select>
</el-form-item>
<el-form-item label="启用配置">
<el-switch v-model="form.enabled" active-text="启用" inactive-text="停用" @change="dirty = true" />
</el-form-item>
</div>
<el-form-item label="绑定规格" class="profile-summary">
<span>{{ selectedProfileSummary }}</span>
</el-form-item>
<el-form-item label="绘制区域" prop="points">
<SenseGeometryEditor
:model-value="form.points"
:kind="form.kind"
:direction="form.direction"
:player-url="preview.playerUrl"
:disabled="!form.routeId"
@update:model-value="handlePointsChange"
/>
</el-form-item>
</el-form>
<p v-if="formError" class="form-error" role="alert">{{ formError }}</p>
<template #footer>
<el-button @click="requestEditorClose">取消</el-button>
<el-button type="primary" :loading="saving" @click="save">保存新版本</el-button>
</template>
</el-dialog>
<el-dialog v-model="versionsOpen" title="版本记录" width="min(900px, 92vw)">
<el-table v-loading="versionsLoading" :data="versions" border>
<el-table-column label="版本" width="80">
<template #default="scope">v{{ scope.row.version }}</template>
</el-table-column>
<el-table-column prop="name" label="名称" min-width="180" />
<el-table-column label="绑定规格" min-width="190">
<template #default="scope">{{ scope.row.profileWidth }} × {{ scope.row.profileHeight }} · {{ scope.row.profileEncoding }}</template>
</el-table-column>
<el-table-column label="状态" width="100">
<template #default="scope">{{ scope.row.enabled ? '已启用' : '已停用' }}</template>
</el-table-column>
<el-table-column label="坐标点" width="90">
<template #default="scope">{{ scope.row.points.length }}</template>
</el-table-column>
<el-table-column prop="createdAt" label="创建时间" min-width="170">
<template #default="scope">{{ parseTime(scope.row.createdAt) }}</template>
</el-table-column>
</el-table>
</el-dialog>
</template>
</BasicLayout>
</template>
<script>
import { createAreaConfiguration, listAreaConfigurations, listAreaVersions, updateAreaConfiguration } from '@/api/sense/area'
import { createLiveviewSession, listLiveviewRoutes } from '@/api/sense/liveview'
import SenseGeometryEditor from '@/components/sense/geometry-editor'
import { buildAreaPayload, validateAreaForm } from './areaPayload'
const emptyForm = () => ({ id: '', name: '', kind: 'polygon', routeId: '', points: [], direction: '', enabled: true, version: 0, needsRecalibration: false })
export default {
name: 'SenseArea',
components: { SenseGeometryEditor },
data() {
return {
loading: false,
items: [],
total: 0,
queryParams: { keyword: '', kind: '', recalibrationState: '', pageIndex: 1, pageSize: 10 },
editorOpen: false,
saving: false,
dirty: false,
form: emptyForm(),
formError: '',
rules: {
name: [{ required: true, message: '请填写配置名称', trigger: 'blur' }],
kind: [{ required: true, message: '请选择配置类型', trigger: 'change' }],
routeId: [{ required: true, message: '请选择设备与视频码流', trigger: 'change' }]
},
routeOptions: [],
routesLoading: false,
preview: {},
versionsOpen: false,
versionsLoading: false,
versions: []
}
},
computed: {
selectedRoute() {
return this.routeOptions.find(route => route.id === this.form.routeId) || null
},
selectedProfileSummary() {
if (!this.selectedRoute) return '尚未选择视频码流'
return `${this.selectedRoute.deviceName} · ${this.selectedRoute.profileName || this.selectedRoute.profileToken} · ${this.selectedRoute.width} × ${this.selectedRoute.height} · ${this.selectedRoute.encoding}`
}
},
created() {
this.getList()
},
methods: {
async getList() {
this.loading = true
try {
const response = await listAreaConfigurations(this.queryParams)
this.items = response.data.list || []
this.total = response.data.count || 0
} finally {
this.loading = false
}
},
handleQuery() {
this.queryParams.pageIndex = 1
this.getList()
},
resetQuery() {
this.$refs.queryForm.resetFields()
this.handleQuery()
},
kindLabel(kind) {
return kind === 'polygon' ? '危险区域' : '方向警戒线'
},
profileLabel(item) {
return `${item.profileName || item.profileToken} · ${item.profileWidth} × ${item.profileHeight} · ${item.profileEncoding}`
},
routeLabel(route) {
return `${route.deviceName} · ${route.profileName || route.profileToken} · ${route.width}×${route.height}`
},
async searchRoutes(keyword = '') {
this.routesLoading = true
try {
const response = await listLiveviewRoutes({ keyword, pageIndex: 1, pageSize: 50 })
const routes = response.data.list || []
const selected = this.routeOptions.find(route => route.id === this.form.routeId)
this.routeOptions = selected && !routes.some(route => route.id === selected.id) ? [selected, ...routes] : routes
} finally {
this.routesLoading = false
}
},
async openCreate() {
this.form = emptyForm()
this.formError = ''
this.preview = {}
this.dirty = false
this.editorOpen = true
await this.searchRoutes('')
},
async openEdit(item) {
this.form = { id: item.id, name: item.name, kind: item.kind, routeId: item.routeId, points: item.points.map(point => ({ ...point })), direction: item.direction, enabled: item.enabled, version: item.version, needsRecalibration: item.needsRecalibration }
this.routeOptions = [{ id: item.routeId, deviceName: item.deviceName, profileName: item.profileName, profileToken: item.profileToken, width: item.profileWidth, height: item.profileHeight, encoding: item.profileEncoding }]
this.formError = ''
this.preview = {}
this.dirty = false
this.editorOpen = true
if (item.routeId) await this.openPreview()
},
handleKindChange() {
this.form.points = []
this.form.direction = this.form.kind === 'direction_line' ? 'forward' : ''
this.dirty = true
this.formError = ''
},
async handleRouteChange() {
this.form.points = []
this.dirty = true
this.formError = ''
await this.openPreview()
},
handlePointsChange(points) {
this.form.points = points
this.dirty = true
this.formError = ''
},
async openPreview() {
this.preview = {}
if (!this.form.routeId) return
try {
const response = await createLiveviewSession(this.form.routeId)
this.preview = response.data || {}
} catch (error) {
this.formError = '实时画面暂不可用;请先到“实时监看”确认该码流。'
}
},
async save() {
this.formError = validateAreaForm(this.form)
if (this.formError) return
try {
await this.$refs.editorForm.validate()
} catch (error) {
return
}
this.saving = true
try {
const payload = buildAreaPayload(this.form)
if (this.form.id) await updateAreaConfiguration(this.form.id, payload)
else await createAreaConfiguration(payload)
this.dirty = false
this.editorOpen = false
this.$message.success(this.form.id ? '已保存新版本' : '区域配置已创建')
await this.getList()
} catch (error) {
this.formError = error.message || '保存失败,请刷新后重试'
} finally {
this.saving = false
}
},
requestEditorClose() {
this.beforeEditorClose(() => { this.editorOpen = false })
},
beforeEditorClose(done) {
if (!this.dirty || this.saving) {
done()
return
}
this.$confirm('尚未保存的绘制内容将丢失,确定关闭吗?', '放弃未保存内容', { type: 'warning', confirmButtonText: '放弃并关闭', cancelButtonText: '继续编辑' }).then(done).catch(() => {})
},
async openVersions(item) {
this.versionsOpen = true
this.versionsLoading = true
this.versions = []
try {
const response = await listAreaVersions(item.id)
this.versions = response.data || []
} finally {
this.versionsLoading = false
}
}
}
}
</script>
<style scoped>
.page-header,
.dialog-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.page-header h3 {
margin: 0 0 8px;
}
.page-header p,
.dialog-header span,
.muted-text,
.profile-summary span {
color: var(--el-text-color-secondary);
line-height: 1.5;
}
.dialog-header > div {
display: flex;
flex-direction: column;
gap: 4px;
}
.search-form,
.empty-alert,
.dialog-alert {
margin-top: 16px;
}
.search-form :deep(.el-input) {
width: 240px;
}
.search-form :deep(.el-select) {
width: 180px;
}
.form-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0 20px;
}
.route-field {
grid-column: 1 / -1;
}
.route-field :deep(.el-select),
.form-grid :deep(.el-select) {
width: 100%;
}
.profile-summary {
margin-bottom: 12px;
}
.form-error {
margin: 12px 0 0 110px;
color: var(--el-color-danger);
line-height: 1.5;
}
@media (max-width: 767px) {
.page-header,
.dialog-header {
align-items: flex-start;
flex-direction: column;
}
.form-grid {
grid-template-columns: 1fr;
}
.route-field {
grid-column: auto;
}
.form-error {
margin-left: 0;
}
}
</style>
@@ -0,0 +1,25 @@
import GeometryEditor from '@/components/sense/geometry-editor/index.vue'
describe('Sense geometry editor keyboard operations', () => {
it('adds, moves and removes a point without a mouse', () => {
const context = {
disabled: false,
kind: 'polygon',
points: [],
undoStack: [],
canAdd: true,
$emit(event, points) { this.points = points }
}
context.commit = GeometryEditor.methods.commit.bind(context)
GeometryEditor.methods.addCenterPoint.call(context)
expect(context.points).toEqual([{ x: 0.5, y: 0.5 }])
const moveEvent = { key: 'ArrowRight', preventDefault: jest.fn() }
GeometryEditor.methods.movePointByKeyboard.call(context, 0, moveEvent)
expect(context.points[0].x).toBeCloseTo(0.51)
expect(moveEvent.preventDefault).toHaveBeenCalled()
GeometryEditor.methods.movePointByKeyboard.call(context, 0, { key: 'Delete', preventDefault: jest.fn() })
expect(context.points).toEqual([])
})
})
@@ -0,0 +1,16 @@
import { DIRECTION_LINE, POLYGON, clamp, geometryError } from '@/components/sense/geometry-editor/geometry'
describe('Sense area geometry', () => {
it('validates polygon and direction-line constraints', () => {
expect(geometryError(POLYGON, [{ x: 0.1, y: 0.1 }, { x: 0.8, y: 0.1 }, { x: 0.5, y: 0.8 }])).toBe('')
expect(geometryError(POLYGON, [{ x: 0.1, y: 0.1 }, { x: 0.8, y: 0.8 }, { x: 0.8, y: 0.1 }, { x: 0.1, y: 0.8 }])).toContain('不能交叉')
expect(geometryError(DIRECTION_LINE, [{ x: 0.2, y: 0.5 }, { x: 0.8, y: 0.5 }], 'forward')).toBe('')
expect(geometryError(DIRECTION_LINE, [{ x: 0.2, y: 0.5 }], 'forward')).toContain('恰好 2 个点')
})
it('clamps pointer coordinates to the normalized frame', () => {
expect(clamp(-0.2)).toBe(0)
expect(clamp(0.4)).toBe(0.4)
expect(clamp(1.2)).toBe(1)
})
})
@@ -0,0 +1,13 @@
import { buildAreaPayload, validateAreaForm } from '@/views/sense/area/areaPayload'
describe('Sense area payload', () => {
it('keeps the optimistic version and normalized geometry', () => {
const form = { name: ' 东门警戒线 ', kind: 'direction_line', routeId: 'device-1:main', points: [{ x: '0.2', y: '0.5' }, { x: '0.8', y: '0.5' }], direction: 'reverse', enabled: true, version: 4 }
expect(validateAreaForm(form)).toBe('')
expect(buildAreaPayload(form)).toEqual({ name: '东门警戒线', kind: 'direction_line', routeId: 'device-1:main', points: [{ x: 0.2, y: 0.5 }, { x: 0.8, y: 0.5 }], direction: 'reverse', enabled: true, expectedVersion: 4 })
})
it('rejects incomplete geometry before sending', () => {
expect(validateAreaForm({ name: '危险区域', kind: 'polygon', routeId: 'route-1', points: [] })).toContain('3 到 64')
})
})
+14 -2
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Architecture-and-Code-Map
wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Architecture-and-Code-Map.-
wiki_revision: 0a15795aa69c5a0279a42ff1b48b44ddf68a9fad
synchronized_at: 2026-08-14T10:45:46Z
wiki_revision: 0ba2909431bd04320a9b31121126b59b1824e3dc
synchronized_at: 2026-08-15T01:13:02Z
<!-- gitea-wiki-mirror:end -->
# 架构与代码地图
@@ -118,3 +118,15 @@ ONVIF 支持 Basic 与 MD5/SHA-256 Digest challenge,Profile 与无凭据 Strea
旧 Event → Rule → Alert → ack/close 实现在 `explore`。迁移时保留业务语义、不可变与幂等约束,但新的通用认证、RBAC、菜单、审计和管理端外壳必须基于冻结 GoAdmin 源码。
<!-- bell-mvp:end -->
<!-- sense-area:start -->
## Sense 区域与警戒线代码入口
工单 #69 在 `Sense/server/app/sense/area/` 建立区域配置业务层,GoAdmin 路由位于 `Sense/server/app/admin/router/sense_area.go`,前端页面位于 `Sense/ui/src/views/sense/area/index.vue`。页面继续复用 BasicLayout、Axios、Element Plus 表单/表格/分页/Dialog/Tag/Alert 和权限指令;`Sense/ui/src/components/sense/geometry-editor/` 是唯一新增的业务专用绘制组件。
数据采用两层结构:`sense_area_definitions` 保存当前版本指针和当前校准状态,`sense_area_versions` 保存每次创建、编辑、启停或重校准形成的不可变快照。更新请求携带 `expectedVersion`,服务在 PostgreSQL 事务中锁定当前定义;并发保存只有一个成功,其余返回冲突。历史版本不覆盖、不删除。
几何坐标使用画面内 0–1 归一化值,同时每个版本固化 Device、Profile Token、分辨率和编码。视频接入成功替换 Profile 后,`admission` 服务比较 Token、分辨率和编码并主动标记当前定义 `needs_recalibration`;读取时也会对缺失或失效 Profile 进行保守校验。系统不自动重投影旧坐标,只有用户重新确认画面并保存新版本后才清除重校准状态。
认证 API 为 `/api/v1/area/configurations` 及其版本子资源,接入 GoAdmin JWT、Casbin、动态菜单和操作权限。API 只返回设备/Profile 展示字段、规格、归一化坐标和版本信息,不返回 RTSP URI、摄像头凭据或 MediaMTX 内部路径。
<!-- sense-area:end -->
+14 -2
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Business-Rules-and-Glossary
wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Business-Rules-and-Glossary.-
wiki_revision: e80685e19115e26384d58ddee8a15e5d80b03bb4
synchronized_at: 2026-08-14T10:45:50Z
wiki_revision: 2bcd90c508180eb9a2f9fe37b62115d7d9207e18
synchronized_at: 2026-08-15T01:13:04Z
<!-- gitea-wiki-mirror:end -->
# 业务规则与术语
@@ -134,3 +134,15 @@ synchronized_at: 2026-08-14T10:45:50Z
- Profile 保存 token、名称、分辨率、编码、用途、无凭据 Stream URI 和逐 Profile 验证状态;主码流默认取分辨率最高项,子码流取最低项。
- 认证失败、超时、时间异常、目标未授权和重定向拒绝必须给出不同状态。失败重探不得删除最后一次已验证 Profile;凭据更新后可重新探测。
<!-- sense-admission:end -->
<!-- sense-area:start -->
## Sense 区域与方向警戒线规则
- 区域配置必须绑定已验证的 Device、Profile Token、分辨率和编码;客户端只提交媒体路由引用,最终绑定规格由后端重新查询确认。
- 多边形使用 3–64 个画面内坐标点,必须有非零面积,边线不得自交或重叠;方向警戒线恰好两个不同点,并明确“起点到终点”或“终点到起点”。
- 坐标以 0–1 归一化值保存,同时固化当时分辨率和编码。Profile 删除、Token 替换、分辨率或编码变化必须设置 `needs_recalibration`,不得静默缩放或重投影旧几何。
- 新建为 v1;编辑、启停和重新校准都追加不可变版本。请求使用 `expectedVersion` 乐观并发,过期版本返回 409,旧版本保留用于审计。
- `admin`、`implementation_operator`、`site_admin` 可创建和保存新版本;`viewer` 只读。页面状态必须同时使用文字和 Tag,不能只靠颜色表达。
- 鼠标可点击/拖动顶点;键盘必须能添加、移动和删除顶点。错误在绘制区域附近以可被辅助技术感知的文字给出,并提供撤销、清空和未保存关闭确认。
- 区域配置是 Sense 内部事实;#69 不发布 Brain 契约。后续 Sense→Brain 配置协议必须由独立协调工单从当前版本投影生成,不能共享数据库模型。
<!-- sense-area:end -->
+39 -2
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Local-Development-and-Verification
wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Local-Development-and-Verification.-
wiki_revision: 95be7e2aa152205f524a185897dff5eb2c7e7e04
synchronized_at: 2026-08-14T10:45:55Z
wiki_revision: 31cad04ab68ee653f2ec3ca6d7297a6bef768f54
synchronized_at: 2026-08-15T01:13:07Z
<!-- gitea-wiki-mirror:end -->
# 本地开发与验证
@@ -245,3 +245,40 @@ corepack pnpm@9.15.1 build:prod
3. 阅读任务相关的 go-admin-doc 主题/文件,并把参考项记录到工单。
4. 记录计划继承的 go-admin/go-admin-ui 路径、计划隐藏/禁用的模块和许可证处理。
5. 验证最终产品树确实包含上游派生结构;只使用 Go、Vue、Element Plus 或相似视觉不算通过。
<!-- sense-area:start -->
### Sense 区域与警戒线验证
后端定向与全量验证:
```powershell
cd Sense/server
go test ./app/sense/area ./app/sense/admission
go test -race ./app/sense/area ./app/sense/admission
go test ./...
go vet ./...
go build ./...
```
使用隔离 PostgreSQL 验证迁移和真实并发;连接值只放当前进程环境,不写入仓库或日志:
```powershell
$env:SENSE_AREA_MIGRATION_TEST_DATABASE_URL = '<隔离 PostgreSQL 连接>'
go test ./cmd/migrate/migration/version -run TestAreaMigrationOnPostgres -count=1 -v
$env:SENSE_AREA_TEST_DATABASE_URL = '<隔离 PostgreSQL 连接>'
go test ./app/sense/area -run TestConcurrentUpdateOnPostgresReturnsConflict -count=1 -v
```
前端验证:
```powershell
cd Sense/ui
corepack pnpm@9.15.1 install --frozen-lockfile
corepack pnpm@9.15.1 lint
corepack pnpm@9.15.1 test:unit
corepack pnpm@9.15.1 build:prod
```
浏览器 smoke 至少覆盖:鼠标添加和拖动顶点;键盘 Enter 添加、方向键移动、Delete 删除;错误文字可见并具有 aria-live/alert 语义;刷新后版本、启停和重新校准状态仍可追溯。真实摄像机校准只使用明确授权设备,不记录地址、URI、凭据或视频内容。Brain、Bell 不启动时必须能独立保存、读取和预览。
<!-- sense-area:end -->
+17 -2
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Troubleshooting
wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Troubleshooting
wiki_revision: 331755cb39ac9d63c188395c5caafe0815d64975
synchronized_at: 2026-08-14T10:46:04Z
wiki_revision: 3523b7cd1f126ebc3ef77414cb6d658103f2491d
synchronized_at: 2026-08-15T01:13:11Z
<!-- gitea-wiki-mirror:end -->
# 故障排查
@@ -101,3 +101,18 @@ synchronized_at: 2026-08-14T10:46:04Z
<!-- sense-media:end -->
<!-- sense-admission:end -->
<!-- sense-area:start -->
## Sense 区域与警戒线排错
| 现象 | 原因与处理 |
|---|---|
| 显示“需要重新校准” | 绑定 Profile 已删除、验证失效,或 Token、分辨率、编码发生变化;打开“编辑/校准”,选择当前可用码流,在实际画面确认坐标后保存新版本。不要手工清状态或复制旧坐标冒充校准。 |
| 保存提示配置已被其他用户更新 | 当前页面的 `expectedVersion` 已过期;刷新列表,查看最新版本后重新编辑。系统会保留已成功写入的版本,不覆盖对方结果。 |
| 画面可见但无法添加更多顶点 | 方向警戒线最多两个点,多边形最多 64 个点;检查配置类型,必要时撤销或清空后重画。 |
| 提示边线交叉或面积过小 | 顶点顺序形成自交、重叠或退化多边形;拖动顶点消除交叉,确保至少三个不同且围成有效面积的点。 |
| 实时画面不可用 | 先到“实时监看”确认该 Profile 可播放,再检查 MediaMTX/WebRTC 公开地址。区域 API 不返回或要求填写 RTSP URI。 |
| Profile 已恢复但仍显示重校准 | 这是保守安全状态;恢复相同规格不会自动认可旧坐标。必须由有权限用户打开实际画面确认并保存新版本。 |
| viewer 看得到页面但不能保存 | 符合只读权限;由 implementation_operator、site_admin 或 admin 完成配置。 |
| 键盘无法操作顶点 | Tab 聚焦画布或编号顶点;Enter/Space 添加中心点,方向键移动,Delete/Backspace 删除。检查浏览器焦点轮廓是否可见。 |
<!-- sense-area:end -->
@@ -0,0 +1,77 @@
<!-- gitea-wiki-mirror:start -->
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Task-69-Sense多边形区域与方向警戒线配置
wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Task-69-Sense%E5%A4%9A%E8%BE%B9%E5%BD%A2%E5%8C%BA%E5%9F%9F%E4%B8%8E%E6%96%B9%E5%90%91%E8%AD%A6%E6%88%92%E7%BA%BF%E9%85%8D%E7%BD%AE.-
wiki_revision: 107d58783efef0d9876f082d5e61ed2b5bc6045d
synchronized_at: 2026-08-15T01:24:21Z
<!-- gitea-wiki-mirror:end -->
# 69 Sense 多边形区域与方向警戒线配置
- 类型:需求
- 所属 Epic:#7
- 所属 MVP:#8
- 状态:待验收
- 日期:2026-08-15
- Gitea 工单:https://git.ilapage.cn/ila/yovision/issues/69
- 评审 PR:https://git.ilapage.cn/ila/yovision/pulls/87
## 背景与目标
在 #61 冻结的 GoAdmin/go-admin-ui 源码派生基线上,为网管和非技术实施人员提供绑定 Device/Profile/分辨率的危险区域与方向警戒线配置。配置必须可追溯、能识别视频 Profile 变化,并且 Brain、Bell 不启动时可独立管理和预览。
## 最终方案
- 新增当前区域定义与不可变版本表;创建为 v1,编辑、启停和重新校准均追加版本,不覆盖历史。
- 服务层在 PostgreSQL 事务内锁定当前定义并校验 `expectedVersion`;并发保存只允许一方成功,另一方返回稳定 409 冲突。
- 服务端根据监看路由解析 Device/Profile,保存归一化坐标和冻结的 Profile token、分辨率、编码等非敏感快照;不信任客户端提交的绑定元数据。
- 多边形支持 3–64 个点并拒绝越界、零面积、自交和重叠边;方向线固定两个点并记录正向/反向。
- admission 完成 Profile 替换后按 token、分辨率和编码比较,主动标记 `needs_recalibration`;读取时也保守识别 Profile 缺失、未验证或发生变化。
- 前端复用 go-admin-ui 的 BasicLayout、Axios、Element Plus 搜索、表格、分页、表单、Dialog、Tag、Alert 和权限按钮,仅新增视频坐标几何编辑器。
- 编辑器支持鼠标点选/拖动,以及键盘 Enter/Space 加点、方向键移动、Delete/Backspace 删除;提供可见焦点、文字错误、撤销、清空和未保存关闭确认。
- 权限沿用 GoAdmin Casbin:viewer 只读,implementation_operator/site_admin 可写;页面预览复用 #68 的实时监看会话。
## 修改文件
- `Sense/server/app/sense/area/**`:模型、DTO、几何校验、服务、API 与测试。
- `Sense/server/cmd/migrate/migration/version/2026081509000_area*`:表、菜单、权限迁移及测试。
- `Sense/server/app/admin/router/sense_area.go`:区域 API 路由。
- `Sense/server/app/sense/admission/service*.go`:Profile 替换触发重新校准。
- `Sense/ui/src/views/sense/area/**`、`Sense/ui/src/api/sense/area.js`:区域管理页面、请求与载荷。
- `Sense/ui/src/components/sense/geometry-editor/**`:业务专用几何编辑器。
- `Sense/ui/tests/unit/sense/area*.spec.js`:载荷、几何和页面行为单测。
- Wiki 的 Architecture、Business Rules、Local Development、Troubleshooting:记录架构、规则、验证和排错;`docs/` 为同步镜像。
## 验收结果
| 验收标准 | 结果 |
|---|---|
| 多边形与方向线几何校验、版本并发和审计成立 | 通过 |
| 配置绑定明确的 Device/Profile/分辨率 | 通过 |
| Profile、分辨率或能力变化触发 needs_recalibration | 通过 |
| 旧版本可追溯且不能被静默覆盖 | 通过 |
| 画布键盘/鼠标操作、错误反馈和视觉风格一致 | 通过 |
| Brain/Bell 未启动时可独立保存、读取和预览配置 | 通过 |
## 测试
- `go test ./...`:通过。
- `go vet ./...`:通过。
- `go build ./...`:通过。
- `go test -race ./app/sense/area ./app/sense/admission`:通过。
- PostgreSQL 17 实库:迁移、3 个菜单、10 条 Casbin 策略及并发更新测试通过;并发结果为一次成功、一次冲突、两个版本。
- `pnpm lint`:通过,0 error;保留 32 个冻结上游 warning。
- `pnpm test:unit`:16 suites / 45 tests 通过。
- `pnpm build:prod`:通过,保留 6 个冻结上游 warning。
- Edge/Playwright 浏览器烟测:鼠标绘制、键盘移动/删除和 ARIA 错误提示通过。
- Brain、Bell 未启动,独立后端与前端验证通过。
- **未验证部分**:客户现场授权摄像头、实际部署反向代理及目标客户浏览器上的端到端人工操作尚未验证,留给本工单人工验收或后续集成验收。
## 遗留问题
- Sense→Brain 的版本化区域配置发布属于后续共享契约工单,本工单未写入 `contracts/`,不阻塞 Sense 独立配置能力。
## 相关提交
- `17bd383` feat: 重建 Sense 区域与警戒线配置 (#69)
- `f82dd51` docs: 记录 Sense 区域配置架构 (#69)
+4
View File
@@ -123,6 +123,10 @@
{
"page": "Task-68-Sense单路实时监看与播放状态反馈",
"path": "docs/task/68-Sense单路实时监看与播放状态反馈.md"
},
{
"page": "Task-69-Sense多边形区域与方向警戒线配置",
"path": "docs/task/69-Sense多边形区域与方向警戒线配置.md"
}
]
}