feat: add versioned Sense areas and directional lines (#27)
This commit is contained in:
@@ -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)
|
||||
}
|
||||
@@ -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) }
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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);`
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
{"kind":"polygon","points":[{"x":0.1,"y":0.1},{"x":0.9,"y":0.1},{"x":0.5,"y":0.8}]}
|
||||
@@ -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.
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
<template>
|
||||
<div class="canvas-panel">
|
||||
<div class="canvas-toolbar"><span>在画面上单击添加{{ kind === 'polygon' ? '多边形顶点' : '警戒线端点' }}</span><div><el-button size="small" :disabled="!points.length" @click="undo">撤销一点</el-button><el-button size="small" :disabled="!points.length" @click="clear">清空</el-button></div></div>
|
||||
<svg ref="canvasRef" viewBox="0 0 1000 562.5" role="img" aria-label="区域绘制画布" @click="addPoint">
|
||||
<defs><pattern id="grid" width="50" height="50" patternUnits="userSpaceOnUse"><path d="M 50 0 L 0 0 0 50" fill="none" stroke="#303844" stroke-width="1" /></pattern><marker id="arrow" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto"><path d="M0,0 L8,4 L0,8 z" fill="#f7ba2a" /></marker></defs>
|
||||
<rect width="1000" height="562.5" fill="#151b23" /><rect width="1000" height="562.5" fill="url(#grid)" /><text x="500" y="280" text-anchor="middle" fill="#6b7785" font-size="24">视频画面区域</text>
|
||||
<polygon v-if="kind === 'polygon' && points.length" :points="svgPoints" fill="rgba(24,144,255,.24)" stroke="#1890ff" stroke-width="4" />
|
||||
<line v-if="kind === 'line' && points.length === 2" :x1="points[0].x*1000" :y1="points[0].y*562.5" :x2="points[1].x*1000" :y2="points[1].y*562.5" stroke="#f7ba2a" stroke-width="5" marker-end="url(#arrow)" />
|
||||
<circle v-for="(point,index) in points" :key="index" :cx="point.x*1000" :cy="point.y*562.5" r="8" fill="#fff" stroke="#1890ff" stroke-width="4" />
|
||||
</svg>
|
||||
<div class="canvas-hint">坐标按当前 Profile 分辨率归一化保存;切换分辨率后必须重新校准。</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import{computed,ref}from'vue'
|
||||
const props=defineProps({modelValue:{type:Array,default:()=>[]},kind:{type:String,default:'polygon'}}),emit=defineEmits(['update:modelValue']),canvasRef=ref();const points=computed(()=>props.modelValue);const svgPoints=computed(()=>points.value.map(point=>`${point.x*1000},${point.y*562.5}`).join(' '))
|
||||
function addPoint(event){if(props.kind==='line'&&points.value.length>=2)return;const rect=canvasRef.value.getBoundingClientRect(),x=Math.max(0,Math.min(1,(event.clientX-rect.left)/rect.width)),y=Math.max(0,Math.min(1,(event.clientY-rect.top)/rect.height));emit('update:modelValue',[...points.value,{x:Number(x.toFixed(6)),y:Number(y.toFixed(6))}])}
|
||||
function undo(){emit('update:modelValue',points.value.slice(0,-1))}function clear(){emit('update:modelValue',[])}
|
||||
</script>
|
||||
<style scoped>.canvas-panel{border:1px solid #e5e6eb;border-radius:4px;overflow:hidden}.canvas-toolbar{display:flex;align-items:center;justify-content:space-between;padding:10px 12px;background:#f7f8fa;color:#4e5969;font-size:13px}.canvas-panel svg{display:block;width:100%;cursor:crosshair}.canvas-hint{padding:9px 12px;background:#fff8e6;color:#86909c;font-size:12px}</style>
|
||||
|
||||
@@ -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' } }
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
<template>
|
||||
<section class="page-container">
|
||||
<div class="page-heading"><div><h1>区域与警戒线</h1><p>在已验证画面上配置检测区域和通行方向,每次发布都会保留版本。</p></div></div>
|
||||
<div class="search-toolbar"><el-form :inline="true"><el-form-item label="设备"><el-select v-model="deviceFilter" clearable filterable placeholder="全部设备" style="width:260px" @change="load"><el-option v-for="item in deviceOptions" :key="item.device_id" :label="item.device_id" :value="item.device_id" /></el-select></el-form-item><el-form-item><el-button :icon="Refresh" @click="load">刷新</el-button></el-form-item></el-form></div>
|
||||
<div class="table-container"><div class="table-actions"><strong>配置列表</strong><el-button v-if="canWrite" type="primary" :icon="Plus" @click="openCreate">新增配置</el-button></div><el-table v-loading="loading" :data="areas" border>
|
||||
<el-table-column prop="name" label="名称" min-width="150" /><el-table-column prop="device_id" label="设备" min-width="180" /><el-table-column prop="profile_token" label="Profile" min-width="110" /><el-table-column label="类型" width="120"><template #default="scope">{{ scope.row.kind === 'polygon' ? '多边形区域' : '方向警戒线' }}</template></el-table-column><el-table-column label="分辨率" width="130"><template #default="scope">{{ scope.row.width }} × {{ scope.row.height }}</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="130"><template #default="scope"><el-tag v-if="scope.row.recalibration_required" type="warning">需要重新校准</el-tag><el-tag v-else :type="scope.row.enabled ? 'success' : 'info'">{{ scope.row.enabled ? '已启用' : '已停用' }}</el-tag></template></el-table-column><el-table-column v-if="canWrite" label="操作" width="180" fixed="right"><template #default="scope"><el-button link type="primary" @click="openEdit(scope.row)">{{ scope.row.recalibration_required ? '重新校准' : '编辑' }}</el-button><el-button v-if="scope.row.enabled" link type="danger" @click="disable(scope.row)">停用</el-button></template></el-table-column>
|
||||
</el-table></div>
|
||||
<el-dialog v-model="dialog" :title="editing ? (editing.recalibration_required ? '重新校准配置' : '编辑配置') : '新增配置'" width="900px" destroy-on-close><el-form ref="formRef" :model="form" :rules="rules" label-width="92px"><el-row :gutter="16"><el-col :span="12"><el-form-item label="设备/Profile" prop="route_id"><el-select v-model="form.route_id" :disabled="Boolean(editing)" style="width:100%"><el-option v-for="item in routes" :key="item.id" :label="`${item.device_id} · ${item.profile_token}`" :value="item.id" /></el-select></el-form-item></el-col><el-col :span="12"><el-form-item label="名称" prop="name"><el-input v-model="form.name" /></el-form-item></el-col><el-col :span="12"><el-form-item label="类型" prop="kind"><el-radio-group v-model="form.kind" :disabled="Boolean(editing)" @change="form.points=[]"><el-radio-button value="polygon">多边形区域</el-radio-button><el-radio-button value="line">方向警戒线</el-radio-button></el-radio-group></el-form-item></el-col><el-col v-if="form.kind==='line'" :span="12"><el-form-item label="方向" prop="direction"><el-select v-model="form.direction" style="width:100%"><el-option label="A → B" value="a_to_b" /><el-option label="B → A" value="b_to_a" /><el-option label="双向" value="both" /></el-select></el-form-item></el-col></el-row><el-form-item label="画面坐标" prop="points"><AreaCanvas v-model="form.points" :kind="form.kind" /></el-form-item><el-alert v-if="editing?.recalibration_required" title="Profile 分辨率已变化,请按新画面重新绘制后发布。" type="warning" show-icon :closable="false" /></el-form><template #footer><el-button @click="dialog=false">取消</el-button><el-button type="primary" :loading="saving" @click="save">发布新版本</el-button></template></el-dialog>
|
||||
</section>
|
||||
</template>
|
||||
<script setup>
|
||||
import{computed,onMounted,reactive,ref}from'vue';import{ElMessage,ElMessageBox}from'element-plus';import{Plus,Refresh}from'@element-plus/icons-vue';import{useStore}from'vuex';import{createArea,disableArea,listAreas,recalibrateArea,updateArea}from'../../../api/sense/area';import{playbackRoutes}from'../../../api/sense/liveview';import AreaCanvas from'../../../components/sense/area/AreaCanvas.vue'
|
||||
const store=useStore(),loading=ref(false),saving=ref(false),dialog=ref(false),editing=ref(null),formRef=ref(),areas=ref([]),routes=ref([]),deviceFilter=ref('');const form=reactive({route_id:'',name:'',kind:'polygon',direction:'',points:[]});const rules={route_id:[{required:true,message:'请选择设备和 Profile',trigger:'change'}],name:[{required:true,message:'请输入名称',trigger:'blur'}],kind:[{required:true,message:'请选择类型',trigger:'change'}],points:[{validator:(_r,value,done)=>value.length<(form.kind==='polygon'?3:2)?done(new Error(form.kind==='polygon'?'至少绘制 3 个点':'请绘制 2 个端点')):done(),trigger:'change'}]};const canWrite=computed(()=>store.getters['sense-identity/hasPermission']?.('area.write'));const deviceOptions=computed(()=>{const map=new Map();routes.value.forEach(item=>map.set(item.device_id,item));return[...map.values()]})
|
||||
async function load(){loading.value=true;try{const[result,routeResult]=await Promise.all([listAreas(deviceFilter.value),playbackRoutes()]);areas.value=result.items||[];routes.value=routeResult.items||[]}finally{loading.value=false}}
|
||||
function resetForm(){Object.assign(form,{route_id:'',name:'',kind:'polygon',direction:'',points:[]})}function openCreate(){editing.value=null;resetForm();dialog.value=true}function openEdit(item){editing.value=item;Object.assign(form,{route_id:`${item.device_id}:${item.profile_token}`,name:item.name,kind:item.kind,direction:item.direction,points:item.points.map(point=>({...point}))});dialog.value=true}
|
||||
async function save(){const valid=await formRef.value?.validate().catch(()=>false);if(!valid)return;const route=routes.value.find(item=>item.id===form.route_id)||{device_id:editing.value?.device_id,profile_token:editing.value?.profile_token};const payload={device_id:route.device_id,profile_token:route.profile_token,name:form.name,kind:form.kind,direction:form.kind==='line'?form.direction:'',points:form.points,version:editing.value?.version||0};saving.value=true;try{if(!editing.value)await createArea(payload);else if(editing.value.recalibration_required)await recalibrateArea(editing.value.id,payload);else await updateArea(editing.value.id,payload);ElMessage.success('新版本已发布');dialog.value=false;await load()}catch(error){ElMessage.error(error.message||'发布失败')}finally{saving.value=false}}
|
||||
async function disable(item){await ElMessageBox.confirm(`停用“${item.name}”将发布一个停用版本,历史版本仍保留。`,'确认停用',{type:'warning'});await disableArea(item.id,item.version);ElMessage.success('已停用');await load()}
|
||||
onMounted(load)
|
||||
</script>
|
||||
Reference in New Issue
Block a user