From e2e18b03802d1777f2d02c9d099579d38ed51a46 Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Wed, 12 Aug 2026 17:29:09 +0800 Subject: [PATCH] feat: add versioned Sense areas and directional lines (#27) --- Sense/server/app/sense/area/export_port.go | 19 +++ Sense/server/app/sense/area/geometry.go | 77 +++++++++++ Sense/server/app/sense/area/geometry_test.go | 19 +++ Sense/server/app/sense/area/http.go | 89 +++++++++++++ Sense/server/app/sense/area/migration.go | 3 + Sense/server/app/sense/area/service.go | 117 +++++++++++++++++ Sense/server/app/sense/area/service_test.go | 36 ++++++ Sense/server/app/sense/area/store.go | 120 ++++++++++++++++++ Sense/server/app/sense/area/types.go | 32 +++++ Sense/server/cmd/sense/modules_area.go | 20 +++ .../migrations/sense_area/2026081204_area.sql | 3 + Sense/testdata/area/geometry.json | 1 + Sense/tests/area/README.md | 7 + Sense/ui/src/api/sense/area/index.js | 7 + .../src/components/sense/area/AreaCanvas.vue | 21 +++ Sense/ui/src/router/modules/sense-area.js | 3 + Sense/ui/src/views/sense/area/Areas.vue | 19 +++ 17 files changed, 593 insertions(+) create mode 100644 Sense/server/app/sense/area/export_port.go create mode 100644 Sense/server/app/sense/area/geometry.go create mode 100644 Sense/server/app/sense/area/geometry_test.go create mode 100644 Sense/server/app/sense/area/http.go create mode 100644 Sense/server/app/sense/area/migration.go create mode 100644 Sense/server/app/sense/area/service.go create mode 100644 Sense/server/app/sense/area/service_test.go create mode 100644 Sense/server/app/sense/area/store.go create mode 100644 Sense/server/app/sense/area/types.go create mode 100644 Sense/server/cmd/sense/modules_area.go create mode 100644 Sense/server/database/migrations/sense_area/2026081204_area.sql create mode 100644 Sense/testdata/area/geometry.json create mode 100644 Sense/tests/area/README.md create mode 100644 Sense/ui/src/api/sense/area/index.js create mode 100644 Sense/ui/src/components/sense/area/AreaCanvas.vue create mode 100644 Sense/ui/src/router/modules/sense-area.js create mode 100644 Sense/ui/src/views/sense/area/Areas.vue diff --git a/Sense/server/app/sense/area/export_port.go b/Sense/server/app/sense/area/export_port.go new file mode 100644 index 0000000..44ca851 --- /dev/null +++ b/Sense/server/app/sense/area/export_port.go @@ -0,0 +1,19 @@ +package area + +import ( + "context" + "fmt" + "sync/atomic" +) + +var activeService atomic.Pointer[Service] + +// ExportCurrent is a project-internal adapter port for a future versioned +// contract mapper. The internal Area type is not a shared Brain contract. +func ExportCurrent(ctx context.Context, deviceID string) ([]Area, error) { + service := activeService.Load() + if service == nil { + return nil, fmt.Errorf("area service is not ready") + } + return service.List(ctx, deviceID) +} diff --git a/Sense/server/app/sense/area/geometry.go b/Sense/server/app/sense/area/geometry.go new file mode 100644 index 0000000..39044fe --- /dev/null +++ b/Sense/server/app/sense/area/geometry.go @@ -0,0 +1,77 @@ +package area + +import ( + "fmt" + "math" +) + +const epsilon = 1e-9 + +func ValidateGeometry(kind, direction string, points []Point) error { + for _, point := range points { + if point.X < 0 || point.X > 1 || point.Y < 0 || point.Y > 1 { + return fmt.Errorf("坐标必须位于画面范围内") + } + } + switch kind { + case KindPolygon: + if len(points) < 3 { + return fmt.Errorf("多边形至少需要 3 个点") + } + if math.Abs(polygonArea(points)) < epsilon { + return fmt.Errorf("多边形不能退化为直线") + } + if selfIntersects(points, true) { + return fmt.Errorf("多边形不能自交") + } + case KindLine: + if len(points) != 2 { + return fmt.Errorf("警戒线必须恰好包含 2 个点") + } + if distance(points[0], points[1]) < epsilon { + return fmt.Errorf("警戒线两点不能重合") + } + if direction != DirectionAToB && direction != DirectionBToA && direction != DirectionBoth { + return fmt.Errorf("警戒线方向不正确") + } + default: + return fmt.Errorf("区域类型不正确") + } + return nil +} +func polygonArea(points []Point) float64 { + sum := 0.0 + for i := range points { + next := (i + 1) % len(points) + sum += points[i].X*points[next].Y - points[next].X*points[i].Y + } + return sum / 2 +} +func distance(a, b Point) float64 { dx, dy := a.X-b.X, a.Y-b.Y; return dx*dx + dy*dy } +func selfIntersects(points []Point, closed bool) bool { + segments := len(points) - 1 + if closed { + segments = len(points) + } + for i := 0; i < segments; i++ { + a1, a2 := points[i], points[(i+1)%len(points)] + if distance(a1, a2) < epsilon { + return true + } + for j := i + 1; j < segments; j++ { + if j == i+1 || closed && i == 0 && j == segments-1 { + 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 { + o1, o2, o3, o4 := orientation(a, b, c), orientation(a, b, d), orientation(c, d, a), orientation(c, d, b) + return o1*o2 < 0 && o3*o4 < 0 +} +func orientation(a, b, c Point) float64 { return (b.X-a.X)*(c.Y-a.Y) - (b.Y-a.Y)*(c.X-a.X) } diff --git a/Sense/server/app/sense/area/geometry_test.go b/Sense/server/app/sense/area/geometry_test.go new file mode 100644 index 0000000..d46f924 --- /dev/null +++ b/Sense/server/app/sense/area/geometry_test.go @@ -0,0 +1,19 @@ +package area + +import "testing" + +func TestValidateGeometry(t *testing.T) { + tests := []struct { + name, kind, direction string + points []Point + valid bool + }{{"polygon", KindPolygon, "", []Point{{.1, .1}, {.9, .1}, {.5, .8}}, true}, {"self intersection", KindPolygon, "", []Point{{.1, .1}, {.9, .9}, {.9, .1}, {.1, .9}}, false}, {"outside", KindPolygon, "", []Point{{-.1, .1}, {.9, .1}, {.5, .8}}, false}, {"line", KindLine, DirectionAToB, []Point{{.1, .1}, {.9, .9}}, true}, {"line direction", KindLine, "", []Point{{.1, .1}, {.9, .9}}, false}, {"line degenerate", KindLine, DirectionBoth, []Point{{.1, .1}, {.1, .1}}, 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("err=%v valid=%v", err, test.valid) + } + }) + } +} diff --git a/Sense/server/app/sense/area/http.go b/Sense/server/app/sense/area/http.go new file mode 100644 index 0000000..ae805da --- /dev/null +++ b/Sense/server/app/sense/area/http.go @@ -0,0 +1,89 @@ +package area + +import ( + "net/http" + "yovision.local/sense/app/sense/identity" + "yovision.local/sense/internal/platform" +) + +type Module struct{ service *Service } + +func NewModule(service *Service) *Module { return &Module{service: service} } +func (m *Module) Register(app *platform.App) { + activeService.Store(m.service) + app.Handle("GET /api/v1/areas", identity.Require(identity.PermissionAreaRead, http.HandlerFunc(m.list))) + app.Handle("POST /api/v1/areas", identity.Require(identity.PermissionAreaWrite, http.HandlerFunc(m.create))) + app.Handle("PATCH /api/v1/areas/{id}", identity.Require(identity.PermissionAreaWrite, http.HandlerFunc(m.update))) + app.Handle("POST /api/v1/areas/{id}/recalibrate", identity.Require(identity.PermissionAreaWrite, http.HandlerFunc(m.recalibrate))) + app.Handle("POST /api/v1/areas/{id}/disable", identity.Require(identity.PermissionAreaWrite, http.HandlerFunc(m.disable))) +} + +type writeRequest struct { + DeviceID string `json:"device_id"` + ProfileToken string `json:"profile_token"` + Name string `json:"name"` + Kind string `json:"kind"` + Direction string `json:"direction"` + Points []Point `json:"points"` + Version int64 `json:"version"` +} + +func (m *Module) list(w http.ResponseWriter, r *http.Request) { + items, err := m.service.List(r.Context(), r.URL.Query().Get("device_id")) + if err != nil { + platform.WriteError(w, err) + return + } + platform.WriteJSON(w, http.StatusOK, map[string]any{"items": items, "total": len(items)}) +} +func (m *Module) create(w http.ResponseWriter, r *http.Request) { + var req writeRequest + if err := platform.DecodeJSON(r, &req); err != nil { + platform.WriteError(w, err) + return + } + actor, _ := identity.PrincipalFromContext(r.Context()) + item, err := m.service.Create(r.Context(), actor, req.DeviceID, req.ProfileToken, req.Name, req.Kind, req.Direction, req.Points) + writeResult(w, item, err, http.StatusCreated) +} +func (m *Module) update(w http.ResponseWriter, r *http.Request) { m.writeExisting(w, r, false) } +func (m *Module) recalibrate(w http.ResponseWriter, r *http.Request) { m.writeExisting(w, r, true) } +func (m *Module) writeExisting(w http.ResponseWriter, r *http.Request, recalibrate bool) { + var req writeRequest + if err := platform.DecodeJSON(r, &req); err != nil { + platform.WriteError(w, err) + return + } + actor, _ := identity.PrincipalFromContext(r.Context()) + item, err := m.service.Update(r.Context(), actor, r.PathValue("id"), req.Name, req.Direction, req.Points, req.Version, recalibrate) + writeResult(w, item, err, http.StatusOK) +} +func (m *Module) disable(w http.ResponseWriter, r *http.Request) { + var req struct { + Version int64 `json:"version"` + } + if err := platform.DecodeJSON(r, &req); err != nil { + platform.WriteError(w, err) + return + } + actor, _ := identity.PrincipalFromContext(r.Context()) + item, err := m.service.Disable(r.Context(), actor, r.PathValue("id"), req.Version) + writeResult(w, item, err, http.StatusOK) +} +func writeResult(w http.ResponseWriter, item Area, err error, status int) { + if err != nil { + code := "area_invalid" + httpStatus := http.StatusBadRequest + if err == ErrConflict { + code = "version_conflict" + httpStatus = http.StatusConflict + } + if err == ErrNotFound { + code = "area_not_found" + httpStatus = http.StatusNotFound + } + platform.WriteError(w, &platform.APIError{Status: httpStatus, Code: code, Message: err.Error()}) + return + } + platform.WriteJSON(w, status, item) +} diff --git a/Sense/server/app/sense/area/migration.go b/Sense/server/app/sense/area/migration.go new file mode 100644 index 0000000..8a4bc94 --- /dev/null +++ b/Sense/server/app/sense/area/migration.go @@ -0,0 +1,3 @@ +package area + +const MigrationSQL = `CREATE TABLE IF NOT EXISTS sense_area_versions(id TEXT NOT NULL,version BIGINT NOT NULL,device_id TEXT NOT NULL,profile_token TEXT NOT NULL,width INTEGER NOT NULL,height INTEGER NOT NULL,name TEXT NOT NULL,kind TEXT NOT NULL,direction TEXT NOT NULL DEFAULT '',points JSONB NOT NULL,enabled BOOLEAN NOT NULL,recalibration_required BOOLEAN NOT NULL DEFAULT FALSE,created_at TIMESTAMPTZ NOT NULL,updated_at TIMESTAMPTZ NOT NULL,PRIMARY KEY(id,version));CREATE INDEX IF NOT EXISTS sense_area_device_idx ON sense_area_versions(device_id,id,version DESC);` diff --git a/Sense/server/app/sense/area/service.go b/Sense/server/app/sense/area/service.go new file mode 100644 index 0000000..b019956 --- /dev/null +++ b/Sense/server/app/sense/area/service.go @@ -0,0 +1,117 @@ +package area + +import ( + "context" + "crypto/rand" + "encoding/hex" + "fmt" + "strings" + "time" + "yovision.local/sense/app/sense/admission" + "yovision.local/sense/app/sense/identity" +) + +type Service struct { + store Store + profile func(string, string) (admission.Profile, error) + now func() time.Time +} + +func NewService(store Store) *Service { + return &Service{store: store, profile: admission.VerifiedProfile, now: time.Now} +} +func (s *Service) Create(ctx context.Context, actor identity.Principal, deviceID, profileToken, name, kind, direction string, points []Point) (Area, error) { + profile, err := s.profile(deviceID, profileToken) + if err != nil { + return Area{}, err + } + if err := ValidateGeometry(kind, direction, points); err != nil { + return Area{}, err + } + name = strings.TrimSpace(name) + if name == "" { + return Area{}, fmt.Errorf("名称不能为空") + } + now := s.now().UTC() + item := Area{ID: newID(), DeviceID: deviceID, ProfileToken: profileToken, Width: profile.Width, Height: profile.Height, Name: name, Kind: kind, Direction: direction, Points: points, Enabled: true, Version: 1, CreatedAt: now, UpdatedAt: now} + if err := s.store.Save(ctx, item, 0); err != nil { + return Area{}, err + } + identity.RecordAudit(ctx, actor.UserID, "area.create", item.ID, "success", map[string]any{"version": 1, "kind": kind}) + return item, nil +} +func (s *Service) Update(ctx context.Context, actor identity.Principal, id, name, direction string, points []Point, expected int64, recalibrate bool) (Area, error) { + current, err := s.store.Get(ctx, id) + if err != nil { + return Area{}, err + } + if current.Version != expected { + return Area{}, ErrConflict + } + profile, err := s.profile(current.DeviceID, current.ProfileToken) + if err != nil { + return Area{}, err + } + resolutionChanged := profile.Width != current.Width || profile.Height != current.Height + if resolutionChanged && !recalibrate { + current.RecalibrationRequired = true + current.Version++ + current.UpdatedAt = s.now().UTC() + if err := s.store.Save(ctx, current, expected); err != nil { + return Area{}, err + } + return current, nil + } + if err := ValidateGeometry(current.Kind, direction, points); err != nil { + return Area{}, err + } + current.Name = strings.TrimSpace(name) + current.Direction = direction + current.Points = points + current.Width = profile.Width + current.Height = profile.Height + current.RecalibrationRequired = false + current.Version++ + current.UpdatedAt = s.now().UTC() + if err := s.store.Save(ctx, current, expected); err != nil { + return Area{}, err + } + identity.RecordAudit(ctx, actor.UserID, "area.publish", id, "success", map[string]any{"version": current.Version, "recalibrated": recalibrate}) + return current, nil +} +func (s *Service) Disable(ctx context.Context, actor identity.Principal, id string, expected int64) (Area, error) { + item, err := s.store.Get(ctx, id) + if err != nil { + return Area{}, err + } + if item.Version != expected { + return Area{}, ErrConflict + } + item.Enabled = false + item.Version++ + item.UpdatedAt = s.now().UTC() + if err := s.store.Save(ctx, item, expected); err != nil { + return Area{}, err + } + identity.RecordAudit(ctx, actor.UserID, "area.disable", id, "success", map[string]any{"version": item.Version}) + return item, nil +} +func (s *Service) List(ctx context.Context, deviceID string) ([]Area, error) { + items, err := s.store.List(ctx, deviceID) + if err != nil { + return nil, err + } + for i := range items { + if profile, profileErr := s.profile(items[i].DeviceID, items[i].ProfileToken); profileErr == nil && (profile.Width != items[i].Width || profile.Height != items[i].Height) { + items[i].RecalibrationRequired = true + } + } + return items, nil +} +func newID() string { + b := make([]byte, 16) + if _, err := rand.Read(b); err != nil { + panic(err) + } + return "area_" + hex.EncodeToString(b) +} diff --git a/Sense/server/app/sense/area/service_test.go b/Sense/server/app/sense/area/service_test.go new file mode 100644 index 0000000..38c8d36 --- /dev/null +++ b/Sense/server/app/sense/area/service_test.go @@ -0,0 +1,36 @@ +package area + +import ( + "context" + "testing" + "yovision.local/sense/app/sense/admission" + "yovision.local/sense/app/sense/identity" +) + +func TestVersionsConflictAndRecalibration(t *testing.T) { + service := NewService(NewMemoryStore()) + width := 1920 + service.profile = func(string, string) (admission.Profile, error) { + return admission.Profile{Width: width, Height: 1080}, nil + } + item, err := service.Create(context.Background(), identity.Principal{}, "device", "main", "区域", KindPolygon, "", []Point{{.1, .1}, {.9, .1}, {.5, .8}}) + if err != nil { + t.Fatal(err) + } + updated, err := service.Update(context.Background(), identity.Principal{}, item.ID, "区域2", "", item.Points, item.Version, false) + if err != nil || updated.Version != 2 { + t.Fatalf("updated=%#v err=%v", updated, err) + } + if _, err := service.Update(context.Background(), identity.Principal{}, item.ID, "冲突", "", item.Points, item.Version, false); err != ErrConflict { + t.Fatalf("conflict err=%v", err) + } + width = 1280 + flagged, err := service.Update(context.Background(), identity.Principal{}, item.ID, updated.Name, "", updated.Points, updated.Version, false) + if err != nil || !flagged.RecalibrationRequired { + t.Fatalf("flagged=%#v err=%v", flagged, err) + } + recalibrated, err := service.Update(context.Background(), identity.Principal{}, item.ID, updated.Name, "", updated.Points, flagged.Version, true) + if err != nil || recalibrated.RecalibrationRequired || recalibrated.Width != 1280 { + t.Fatalf("recalibrated=%#v err=%v", recalibrated, err) + } +} diff --git a/Sense/server/app/sense/area/store.go b/Sense/server/app/sense/area/store.go new file mode 100644 index 0000000..90aae6b --- /dev/null +++ b/Sense/server/app/sense/area/store.go @@ -0,0 +1,120 @@ +package area + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "sync" +) + +var ( + ErrNotFound = errors.New("area not found") + ErrConflict = errors.New("area version conflict") +) + +type Store interface { + Save(context.Context, Area, int64) error + Get(context.Context, string) (Area, error) + List(context.Context, string) ([]Area, error) +} +type MemoryStore struct { + mu sync.RWMutex + versions map[string][]Area +} + +func NewMemoryStore() *MemoryStore { return &MemoryStore{versions: map[string][]Area{}} } +func (s *MemoryStore) Save(_ context.Context, item Area, expected int64) error { + s.mu.Lock() + defer s.mu.Unlock() + values := s.versions[item.ID] + if len(values) == 0 { + if expected != 0 { + return ErrConflict + } + } else if values[len(values)-1].Version != expected { + return ErrConflict + } + s.versions[item.ID] = append(values, item) + return nil +} +func (s *MemoryStore) Get(_ context.Context, id string) (Area, error) { + s.mu.RLock() + defer s.mu.RUnlock() + values := s.versions[id] + if len(values) == 0 { + return Area{}, ErrNotFound + } + return values[len(values)-1], nil +} +func (s *MemoryStore) List(_ context.Context, deviceID string) ([]Area, error) { + s.mu.RLock() + defer s.mu.RUnlock() + items := []Area{} + for _, values := range s.versions { + item := values[len(values)-1] + if deviceID == "" || item.DeviceID == deviceID { + items = append(items, item) + } + } + return items, nil +} + +type PostgresStore struct{ database *sql.DB } + +func NewPostgresStore(db *sql.DB) *PostgresStore { return &PostgresStore{database: db} } +func (s *PostgresStore) Save(ctx context.Context, item Area, expected int64) error { + tx, err := s.database.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelSerializable}) + if err != nil { + return err + } + defer tx.Rollback() + var current int64 + err = tx.QueryRowContext(ctx, `SELECT COALESCE(max(version),0) FROM sense_area_versions WHERE id=$1`, item.ID).Scan(¤t) + if err != nil { + return err + } + if current != expected { + return ErrConflict + } + points, _ := json.Marshal(item.Points) + _, err = tx.ExecContext(ctx, `INSERT INTO sense_area_versions(id,version,device_id,profile_token,width,height,name,kind,direction,points,enabled,recalibration_required,created_at,updated_at) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14)`, item.ID, item.Version, item.DeviceID, item.ProfileToken, item.Width, item.Height, item.Name, item.Kind, item.Direction, points, item.Enabled, item.RecalibrationRequired, item.CreatedAt, item.UpdatedAt) + if err != nil { + return err + } + return tx.Commit() +} +func (s *PostgresStore) Get(ctx context.Context, id string) (Area, error) { + return scan(s.database.QueryRowContext(ctx, `SELECT id,version,device_id,profile_token,width,height,name,kind,direction,points,enabled,recalibration_required,created_at,updated_at FROM sense_area_versions WHERE id=$1 ORDER BY version DESC LIMIT 1`, id)) +} + +type scanner interface{ Scan(...any) error } + +func scan(row scanner) (Area, error) { + var item Area + var points []byte + err := row.Scan(&item.ID, &item.Version, &item.DeviceID, &item.ProfileToken, &item.Width, &item.Height, &item.Name, &item.Kind, &item.Direction, &points, &item.Enabled, &item.RecalibrationRequired, &item.CreatedAt, &item.UpdatedAt) + if errors.Is(err, sql.ErrNoRows) { + return Area{}, ErrNotFound + } + if err == nil { + err = json.Unmarshal(points, &item.Points) + } + return item, err +} +func (s *PostgresStore) List(ctx context.Context, deviceID string) ([]Area, error) { + rows, err := s.database.QueryContext(ctx, `SELECT DISTINCT ON(id) id,version,device_id,profile_token,width,height,name,kind,direction,points,enabled,recalibration_required,created_at,updated_at FROM sense_area_versions WHERE ($1='' OR device_id=$1) ORDER BY id,version DESC`, deviceID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []Area{} + for rows.Next() { + item, err := scan(rows) + if err != nil { + return nil, err + } + items = append(items, item) + } + return items, rows.Err() +} diff --git a/Sense/server/app/sense/area/types.go b/Sense/server/app/sense/area/types.go new file mode 100644 index 0000000..a9b4d4e --- /dev/null +++ b/Sense/server/app/sense/area/types.go @@ -0,0 +1,32 @@ +package area + +import "time" + +const ( + KindPolygon = "polygon" + KindLine = "line" + DirectionAToB = "a_to_b" + DirectionBToA = "b_to_a" + DirectionBoth = "both" +) + +type Point struct { + X float64 `json:"x"` + Y float64 `json:"y"` +} +type Area struct { + ID string `json:"id"` + DeviceID string `json:"device_id"` + ProfileToken string `json:"profile_token"` + Width int `json:"width"` + Height int `json:"height"` + Name string `json:"name"` + Kind string `json:"kind"` + Direction string `json:"direction,omitempty"` + Points []Point `json:"points"` + Enabled bool `json:"enabled"` + Version int64 `json:"version"` + RecalibrationRequired bool `json:"recalibration_required"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} diff --git a/Sense/server/cmd/sense/modules_area.go b/Sense/server/cmd/sense/modules_area.go new file mode 100644 index 0000000..baeb3d7 --- /dev/null +++ b/Sense/server/cmd/sense/modules_area.go @@ -0,0 +1,20 @@ +package sense + +import ( + "yovision.local/sense/app/sense/area" + "yovision.local/sense/internal/platform" +) + +func init() { + registerModule(func(app *platform.App) error { + var store area.Store + if app.Config().DatabaseMode == platform.DatabaseModeMemory { + store = area.NewMemoryStore() + } else { + store = area.NewPostgresStore(app.Database()) + } + app.RegisterMigration(platform.Migration{Version: 2026081204, Name: "sense_area", SQL: area.MigrationSQL}) + area.NewModule(area.NewService(store)).Register(app) + return nil + }) +} diff --git a/Sense/server/database/migrations/sense_area/2026081204_area.sql b/Sense/server/database/migrations/sense_area/2026081204_area.sql new file mode 100644 index 0000000..6a2e772 --- /dev/null +++ b/Sense/server/database/migrations/sense_area/2026081204_area.sql @@ -0,0 +1,3 @@ +CREATE TABLE IF NOT EXISTS sense_area_versions(id TEXT NOT NULL,version BIGINT NOT NULL,device_id TEXT NOT NULL,profile_token TEXT NOT NULL,width INTEGER NOT NULL,height INTEGER NOT NULL,name TEXT NOT NULL,kind TEXT NOT NULL,direction TEXT NOT NULL DEFAULT '',points JSONB NOT NULL,enabled BOOLEAN NOT NULL,recalibration_required BOOLEAN NOT NULL DEFAULT FALSE,created_at TIMESTAMPTZ NOT NULL,updated_at TIMESTAMPTZ NOT NULL,PRIMARY KEY(id,version)); +CREATE INDEX IF NOT EXISTS sense_area_device_idx ON sense_area_versions(device_id,id,version DESC); + diff --git a/Sense/testdata/area/geometry.json b/Sense/testdata/area/geometry.json new file mode 100644 index 0000000..165ac61 --- /dev/null +++ b/Sense/testdata/area/geometry.json @@ -0,0 +1 @@ +{"kind":"polygon","points":[{"x":0.1,"y":0.1},{"x":0.9,"y":0.1},{"x":0.5,"y":0.8}]} diff --git a/Sense/tests/area/README.md b/Sense/tests/area/README.md new file mode 100644 index 0000000..5c47a2b --- /dev/null +++ b/Sense/tests/area/README.md @@ -0,0 +1,7 @@ +# Area acceptance + +Tests cover normalized coordinate bounds, minimum points, polygon self- +intersection and degeneration, directional line validation, immutable version +increments, optimistic conflicts and explicit recalibration after a Profile +resolution change. No customer image or production geometry is included. + diff --git a/Sense/ui/src/api/sense/area/index.js b/Sense/ui/src/api/sense/area/index.js new file mode 100644 index 0000000..3756999 --- /dev/null +++ b/Sense/ui/src/api/sense/area/index.js @@ -0,0 +1,7 @@ +import request from '../../../bootstrap/request' +export const listAreas = (deviceID) => request.get('/areas', { params: { device_id: deviceID || undefined } }) +export const createArea = (data) => request.post('/areas', data) +export const updateArea = (id, data) => request.patch(`/areas/${id}`, data) +export const recalibrateArea = (id, data) => request.post(`/areas/${id}/recalibrate`, data) +export const disableArea = (id, version) => request.post(`/areas/${id}/disable`, { version }) + diff --git a/Sense/ui/src/components/sense/area/AreaCanvas.vue b/Sense/ui/src/components/sense/area/AreaCanvas.vue new file mode 100644 index 0000000..b400b61 --- /dev/null +++ b/Sense/ui/src/components/sense/area/AreaCanvas.vue @@ -0,0 +1,21 @@ + + + 在画面上单击添加{{ kind === 'polygon' ? '多边形顶点' : '警戒线端点' }}撤销一点清空 + + + 视频画面区域 + + + + + 坐标按当前 Profile 分辨率归一化保存;切换分辨率后必须重新校准。 + + + + + diff --git a/Sense/ui/src/router/modules/sense-area.js b/Sense/ui/src/router/modules/sense-area.js new file mode 100644 index 0000000..bfa78d2 --- /dev/null +++ b/Sense/ui/src/router/modules/sense-area.js @@ -0,0 +1,3 @@ +import Areas from '../../views/sense/area/Areas.vue' +export default { path: 'areas', name: 'sense-areas', component: Areas, meta: { title: '区域与警戒线', icon: 'Crop', order: 50, permission: 'area.read' } } + diff --git a/Sense/ui/src/views/sense/area/Areas.vue b/Sense/ui/src/views/sense/area/Areas.vue new file mode 100644 index 0000000..28eb883 --- /dev/null +++ b/Sense/ui/src/views/sense/area/Areas.vue @@ -0,0 +1,19 @@ + + + 区域与警戒线在已验证画面上配置检测区域和通行方向,每次发布都会保留版本。 + 刷新 + 配置列表新增配置 + {{ scope.row.kind === 'polygon' ? '多边形区域' : '方向警戒线' }}{{ scope.row.width }} × {{ scope.row.height }}v{{ scope.row.version }}需要重新校准{{ scope.row.enabled ? '已启用' : '已停用' }}{{ scope.row.recalibration_required ? '重新校准' : '编辑' }}停用 + + 多边形区域方向警戒线取消发布新版本 + + +
在已验证画面上配置检测区域和通行方向,每次发布都会保留版本。