feat: add versioned Sense areas and directional lines (#27)

This commit is contained in:
QiuSW
2026-08-12 17:29:09 +08:00
parent 8509699fff
commit e2e18b0380
17 changed files with 593 additions and 0 deletions
@@ -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)
}
+77
View File
@@ -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)
}
})
}
}
+89
View File
@@ -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)
}
+3
View File
@@ -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);`
+117
View File
@@ -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)
}
}
+120
View File
@@ -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(&current)
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()
}
+32
View File
@@ -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"`
}
+20
View File
@@ -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);
+1
View File
@@ -0,0 +1 @@
{"kind":"polygon","points":[{"x":0.1,"y":0.1},{"x":0.9,"y":0.1},{"x":0.5,"y":0.8}]}
+7
View File
@@ -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.
+7
View File
@@ -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' } }
+19
View File
@@ -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>