diff --git a/Sense/server/app/sense/device/crypto.go b/Sense/server/app/sense/device/crypto.go new file mode 100644 index 0000000..d26779a --- /dev/null +++ b/Sense/server/app/sense/device/crypto.go @@ -0,0 +1,53 @@ +package device + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "encoding/base64" + "encoding/json" + "fmt" + "io" +) + +type CredentialVault struct{ key []byte } + +func NewCredentialVault(encodedKey string, allowRandom bool) (*CredentialVault, error) { + var key []byte + if encodedKey != "" { + decoded, err := base64.StdEncoding.DecodeString(encodedKey) + if err != nil { + return nil, fmt.Errorf("SENSE_CREDENTIAL_KEY must be base64 encoded") + } + key = decoded + } else if allowRandom { + key = make([]byte, 32) + if _, err := rand.Read(key); err != nil { + return nil, err + } + } + if len(key) != 32 { + return nil, fmt.Errorf("SENSE_CREDENTIAL_KEY must decode to 32 bytes") + } + return &CredentialVault{key: key}, nil +} + +func (v *CredentialVault) Encrypt(username, password string) ([]byte, error) { + plaintext, err := json.Marshal(map[string]string{"username": username, "password": password}) + if err != nil { + return nil, err + } + block, err := aes.NewCipher(v.key) + if err != nil { + return nil, err + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return nil, err + } + nonce := make([]byte, gcm.NonceSize()) + if _, err := io.ReadFull(rand.Reader, nonce); err != nil { + return nil, err + } + return gcm.Seal(nonce, nonce, plaintext, []byte("sense-device-credential-v1")), nil +} diff --git a/Sense/server/app/sense/device/http.go b/Sense/server/app/sense/device/http.go new file mode 100644 index 0000000..ec08ab6 --- /dev/null +++ b/Sense/server/app/sense/device/http.go @@ -0,0 +1,123 @@ +package device + +import ( + "net/http" + "strconv" + + "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) { + app.Handle("GET /api/v1/devices", identity.Require(identity.PermissionDeviceRead, http.HandlerFunc(m.list))) + app.Handle("POST /api/v1/devices", identity.Require(identity.PermissionDeviceWrite, http.HandlerFunc(m.create))) + app.Handle("GET /api/v1/devices/{id}", identity.Require(identity.PermissionDeviceRead, http.HandlerFunc(m.get))) + app.Handle("PATCH /api/v1/devices/{id}", identity.Require(identity.PermissionDeviceWrite, http.HandlerFunc(m.update))) + app.Handle("POST /api/v1/devices/{id}/credentials", identity.Require(identity.PermissionDeviceWrite, http.HandlerFunc(m.credential))) + app.Handle("POST /api/v1/devices/{id}/disable", identity.Require(identity.PermissionDeviceWrite, http.HandlerFunc(m.disable))) +} +func (m *Module) list(w http.ResponseWriter, r *http.Request) { + page, _ := strconv.Atoi(r.URL.Query().Get("page")) + size, _ := strconv.Atoi(r.URL.Query().Get("page_size")) + result, err := m.service.List(r.Context(), ListFilter{Keyword: r.URL.Query().Get("keyword"), Page: page, PageSize: size}) + if err != nil { + platform.WriteError(w, err) + return + } + platform.WriteJSON(w, http.StatusOK, result) +} +func (m *Module) get(w http.ResponseWriter, r *http.Request) { + item, err := m.service.Get(r.Context(), r.PathValue("id")) + if err != nil { + writeDeviceError(w, err) + return + } + platform.WriteJSON(w, http.StatusOK, item) +} +func (m *Module) create(w http.ResponseWriter, r *http.Request) { + var req struct { + Name string `json:"name"` + Location string `json:"location"` + Modality string `json:"modality"` + Capabilities []string `json:"capabilities"` + } + 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.Name, req.Location, req.Modality, req.Capabilities) + if err != nil { + writeDeviceError(w, err) + return + } + platform.WriteJSON(w, http.StatusCreated, item) +} +func (m *Module) update(w http.ResponseWriter, r *http.Request) { + var req struct { + Name string `json:"name"` + Location string `json:"location"` + Capabilities []string `json:"capabilities"` + 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.Update(r.Context(), actor, r.PathValue("id"), req.Name, req.Location, req.Capabilities, req.Version) + if err != nil { + writeDeviceError(w, err) + return + } + platform.WriteJSON(w, http.StatusOK, item) +} +func (m *Module) credential(w http.ResponseWriter, r *http.Request) { + var req struct { + Username string `json:"username"` + Password string `json:"password"` + } + if err := platform.DecodeJSON(r, &req); err != nil { + platform.WriteError(w, err) + return + } + actor, _ := identity.PrincipalFromContext(r.Context()) + item, err := m.service.SetCredential(r.Context(), actor, r.PathValue("id"), req.Username, req.Password) + if err != nil { + writeDeviceError(w, err) + return + } + platform.WriteJSON(w, http.StatusOK, item) +} +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) + if err != nil { + writeDeviceError(w, err) + return + } + platform.WriteJSON(w, http.StatusOK, item) +} +func writeDeviceError(w http.ResponseWriter, err error) { + status := http.StatusBadRequest + code := "device_invalid" + if err == ErrNotFound { + status = http.StatusNotFound + code = "device_not_found" + } + if err == ErrConflict { + status = http.StatusConflict + code = "version_conflict" + } + platform.WriteError(w, &platform.APIError{Status: status, Code: code, Message: err.Error()}) +} diff --git a/Sense/server/app/sense/device/migration.go b/Sense/server/app/sense/device/migration.go new file mode 100644 index 0000000..aab11fe --- /dev/null +++ b/Sense/server/app/sense/device/migration.go @@ -0,0 +1,3 @@ +package device + +const MigrationSQL = `CREATE TABLE IF NOT EXISTS sense_devices(id TEXT PRIMARY KEY,name TEXT NOT NULL,location TEXT NOT NULL DEFAULT '',modality TEXT NOT NULL,capabilities TEXT NOT NULL DEFAULT '',status TEXT NOT NULL,adapter_status TEXT NOT NULL,credential_ciphertext BYTEA NULL,version BIGINT NOT NULL,created_at TIMESTAMPTZ NOT NULL,updated_at TIMESTAMPTZ NOT NULL);CREATE INDEX IF NOT EXISTS sense_devices_created_idx ON sense_devices(created_at DESC);` diff --git a/Sense/server/app/sense/device/service.go b/Sense/server/app/sense/device/service.go new file mode 100644 index 0000000..0f7da54 --- /dev/null +++ b/Sense/server/app/sense/device/service.go @@ -0,0 +1,126 @@ +package device + +import ( + "context" + "crypto/rand" + "encoding/hex" + "fmt" + "strings" + "time" + + "yovision.local/sense/app/sense/identity" +) + +type Service struct { + store Store + vault *CredentialVault + now func() time.Time +} + +func NewService(store Store, vault *CredentialVault) *Service { + return &Service{store: store, vault: vault, now: time.Now} +} + +func (s *Service) Create(ctx context.Context, actor identity.Principal, name, location, modality string, capabilities []string) (Device, error) { + name = strings.TrimSpace(name) + if name == "" { + return Device{}, fmt.Errorf("设备名称不能为空") + } + if modality == "" { + modality = ModalityVideo + } + adapter := AdapterNotReady + if modality == ModalityVideo { + adapter = AdapterReady + } + now := s.now().UTC() + item := Device{ID: newID(), Name: name, Location: strings.TrimSpace(location), Modality: modality, Capabilities: capabilities, Status: StatusPending, AdapterStatus: adapter, Version: 1, CreatedAt: now, UpdatedAt: now} + if err := s.store.Create(ctx, item); err != nil { + return Device{}, err + } + identity.RecordAudit(ctx, actor.UserID, "device.create", item.ID, "success", map[string]any{"modality": modality}) + return item, nil +} +func (s *Service) Get(ctx context.Context, id string) (Device, error) { + item, err := s.store.Get(ctx, id) + if err == nil { + item.CredentialConfigured = len(item.CredentialCiphertext) > 0 + item.CredentialCiphertext = nil + } + return item, err +} +func (s *Service) List(ctx context.Context, filter ListFilter) (Page, error) { + page, err := s.store.List(ctx, filter) + for i := range page.Items { + page.Items[i].CredentialCiphertext = nil + } + return page, err +} +func (s *Service) Update(ctx context.Context, actor identity.Principal, id, name, location string, capabilities []string, expected int64) (Device, error) { + item, err := s.store.Get(ctx, id) + if err != nil { + return Device{}, err + } + item.Name = strings.TrimSpace(name) + item.Location = strings.TrimSpace(location) + item.Capabilities = capabilities + item.Version++ + item.UpdatedAt = s.now().UTC() + if item.Name == "" { + return Device{}, fmt.Errorf("设备名称不能为空") + } + if err := s.store.Update(ctx, item, expected); err != nil { + return Device{}, err + } + identity.RecordAudit(ctx, actor.UserID, "device.update", id, "success", map[string]any{"version": item.Version}) + item.CredentialConfigured = len(item.CredentialCiphertext) > 0 + item.CredentialCiphertext = nil + return item, nil +} +func (s *Service) SetCredential(ctx context.Context, actor identity.Principal, id, username, password string) (Device, error) { + if strings.TrimSpace(username) == "" || password == "" { + return Device{}, fmt.Errorf("用户名和密码不能为空") + } + item, err := s.store.Get(ctx, id) + if err != nil { + return Device{}, err + } + ciphertext, err := s.vault.Encrypt(username, password) + if err != nil { + return Device{}, err + } + expected := item.Version + item.CredentialCiphertext = ciphertext + item.CredentialConfigured = true + item.Version++ + item.UpdatedAt = s.now().UTC() + if err := s.store.Update(ctx, item, expected); err != nil { + return Device{}, err + } + identity.RecordAudit(ctx, actor.UserID, "device.credential.update", id, "success", map[string]any{"configured": true}) + item.CredentialCiphertext = nil + return item, nil +} +func (s *Service) Disable(ctx context.Context, actor identity.Principal, id string, expected int64) (Device, error) { + item, err := s.store.Get(ctx, id) + if err != nil { + return Device{}, err + } + item.Status = StatusDisabled + item.Version++ + item.UpdatedAt = s.now().UTC() + if err := s.store.Update(ctx, item, expected); err != nil { + return Device{}, err + } + identity.RecordAudit(ctx, actor.UserID, "device.disable", id, "success", nil) + item.CredentialConfigured = len(item.CredentialCiphertext) > 0 + item.CredentialCiphertext = nil + return item, nil +} +func newID() string { + b := make([]byte, 16) + if _, err := rand.Read(b); err != nil { + panic(err) + } + return "dev_" + hex.EncodeToString(b) +} diff --git a/Sense/server/app/sense/device/service_test.go b/Sense/server/app/sense/device/service_test.go new file mode 100644 index 0000000..64521dc --- /dev/null +++ b/Sense/server/app/sense/device/service_test.go @@ -0,0 +1,58 @@ +package device + +import ( + "context" + "encoding/base64" + "strings" + "testing" + "yovision.local/sense/app/sense/identity" +) + +func testService(t *testing.T) *Service { + t.Helper() + vault, err := NewCredentialVault(base64.StdEncoding.EncodeToString([]byte("0123456789abcdef0123456789abcdef")), false) + if err != nil { + t.Fatal(err) + } + return NewService(NewMemoryStore(), vault) +} +func TestDeviceLifecycleAndCredentialNeverReturned(t *testing.T) { + svc := testService(t) + ctx := context.Background() + actor := identity.Principal{UserID: "test"} + item, err := svc.Create(ctx, actor, "东门摄像机", "东门", ModalityVideo, []string{"video"}) + if err != nil { + t.Fatal(err) + } + if item.AdapterStatus != AdapterReady { + t.Fatalf("adapter=%s", item.AdapterStatus) + } + item, err = svc.SetCredential(ctx, actor, item.ID, "camera-user", "camera-password") + if err != nil { + t.Fatal(err) + } + if !item.CredentialConfigured || len(item.CredentialCiphertext) != 0 { + t.Fatal("credential leaked or not configured") + } + page, err := svc.List(ctx, ListFilter{Page: 1, PageSize: 20}) + if err != nil || page.Total != 1 || len(page.Items[0].CredentialCiphertext) != 0 { + t.Fatalf("page=%#v err=%v", page, err) + } +} +func TestNonVideoAdapterNotReadyAndPaginationNotCappedAt16(t *testing.T) { + svc := testService(t) + ctx := context.Background() + for i := 0; i < 20; i++ { + item, err := svc.Create(ctx, identity.Principal{}, strings.Repeat("x", i+1), "", "radar", nil) + if err != nil { + t.Fatal(err) + } + if item.AdapterStatus != AdapterNotReady { + t.Fatal("unsupported adapter shown ready") + } + } + page, err := svc.List(ctx, ListFilter{Page: 1, PageSize: 20}) + if err != nil || len(page.Items) != 20 { + t.Fatalf("items=%d err=%v", len(page.Items), err) + } +} diff --git a/Sense/server/app/sense/device/store.go b/Sense/server/app/sense/device/store.go new file mode 100644 index 0000000..a0a32f1 --- /dev/null +++ b/Sense/server/app/sense/device/store.go @@ -0,0 +1,160 @@ +package device + +import ( + "context" + "database/sql" + "errors" + "sort" + "strings" + "sync" + "time" +) + +var ( + ErrNotFound = errors.New("device not found") + ErrConflict = errors.New("device version conflict") +) + +type Store interface { + Create(context.Context, Device) error + Get(context.Context, string) (Device, error) + Update(context.Context, Device, int64) error + List(context.Context, ListFilter) (Page, error) +} + +type MemoryStore struct { + mu sync.RWMutex + devices map[string]Device +} + +func NewMemoryStore() *MemoryStore { return &MemoryStore{devices: map[string]Device{}} } + +func (s *MemoryStore) Create(_ context.Context, item Device) error { + s.mu.Lock() + defer s.mu.Unlock() + s.devices[item.ID] = item + return nil +} + +func (s *MemoryStore) Get(_ context.Context, id string) (Device, error) { + s.mu.RLock() + defer s.mu.RUnlock() + item, ok := s.devices[id] + if !ok { + return Device{}, ErrNotFound + } + return item, nil +} + +func (s *MemoryStore) Update(_ context.Context, item Device, expected int64) error { + s.mu.Lock() + defer s.mu.Unlock() + current, ok := s.devices[item.ID] + if !ok { + return ErrNotFound + } + if current.Version != expected { + return ErrConflict + } + s.devices[item.ID] = item + return nil +} + +func (s *MemoryStore) List(_ context.Context, filter ListFilter) (Page, error) { + s.mu.RLock() + defer s.mu.RUnlock() + items := make([]Device, 0, len(s.devices)) + keyword := strings.ToLower(strings.TrimSpace(filter.Keyword)) + for _, item := range s.devices { + if keyword == "" || strings.Contains(strings.ToLower(item.Name+" "+item.Location), keyword) { + items = append(items, item) + } + } + sort.Slice(items, func(i, j int) bool { return items[i].CreatedAt.After(items[j].CreatedAt) }) + page, size := normalizePage(filter.Page, filter.PageSize) + total := len(items) + start := (page - 1) * size + if start > total { + start = total + } + end := start + size + if end > total { + end = total + } + return Page{Items: append([]Device(nil), items[start:end]...), Total: total, Page: page, PageSize: size}, nil +} + +type PostgresStore struct{ database *sql.DB } + +func NewPostgresStore(database *sql.DB) *PostgresStore { return &PostgresStore{database: database} } + +func (s *PostgresStore) Create(ctx context.Context, item Device) error { + _, err := s.database.ExecContext(ctx, `INSERT INTO sense_devices(id,name,location,modality,capabilities,status,adapter_status,credential_ciphertext,version,created_at,updated_at) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)`, item.ID, item.Name, item.Location, item.Modality, strings.Join(item.Capabilities, ","), item.Status, item.AdapterStatus, item.CredentialCiphertext, item.Version, item.CreatedAt, item.UpdatedAt) + return err +} +func (s *PostgresStore) Get(ctx context.Context, id string) (Device, error) { + var item Device + var capabilities string + err := s.database.QueryRowContext(ctx, `SELECT id,name,location,modality,capabilities,status,adapter_status,credential_ciphertext,version,created_at,updated_at FROM sense_devices WHERE id=$1`, id).Scan(&item.ID, &item.Name, &item.Location, &item.Modality, &capabilities, &item.Status, &item.AdapterStatus, &item.CredentialCiphertext, &item.Version, &item.CreatedAt, &item.UpdatedAt) + if errors.Is(err, sql.ErrNoRows) { + return Device{}, ErrNotFound + } + item.Capabilities = splitCapabilities(capabilities) + item.CredentialConfigured = len(item.CredentialCiphertext) > 0 + return item, err +} +func (s *PostgresStore) Update(ctx context.Context, item Device, expected int64) error { + result, err := s.database.ExecContext(ctx, `UPDATE sense_devices SET name=$2,location=$3,modality=$4,capabilities=$5,status=$6,adapter_status=$7,credential_ciphertext=$8,version=$9,updated_at=$10 WHERE id=$1 AND version=$11`, item.ID, item.Name, item.Location, item.Modality, strings.Join(item.Capabilities, ","), item.Status, item.AdapterStatus, item.CredentialCiphertext, item.Version, item.UpdatedAt, expected) + if err != nil { + return err + } + rows, _ := result.RowsAffected() + if rows == 0 { + return ErrConflict + } + return nil +} +func (s *PostgresStore) List(ctx context.Context, filter ListFilter) (Page, error) { + page, size := normalizePage(filter.Page, filter.PageSize) + keyword := "%" + strings.TrimSpace(filter.Keyword) + "%" + var total int + if err := s.database.QueryRowContext(ctx, `SELECT count(*) FROM sense_devices WHERE name ILIKE $1 OR location ILIKE $1`, keyword).Scan(&total); err != nil { + return Page{}, err + } + rows, err := s.database.QueryContext(ctx, `SELECT id,name,location,modality,capabilities,status,adapter_status,(credential_ciphertext IS NOT NULL),version,created_at,updated_at FROM sense_devices WHERE name ILIKE $1 OR location ILIKE $1 ORDER BY created_at DESC LIMIT $2 OFFSET $3`, keyword, size, (page-1)*size) + if err != nil { + return Page{}, err + } + defer rows.Close() + items := []Device{} + for rows.Next() { + var item Device + var caps string + if err := rows.Scan(&item.ID, &item.Name, &item.Location, &item.Modality, &caps, &item.Status, &item.AdapterStatus, &item.CredentialConfigured, &item.Version, &item.CreatedAt, &item.UpdatedAt); err != nil { + return Page{}, err + } + item.Capabilities = splitCapabilities(caps) + items = append(items, item) + } + return Page{Items: items, Total: total, Page: page, PageSize: size}, rows.Err() +} +func normalizePage(page, size int) (int, int) { + if page < 1 { + page = 1 + } + if size < 1 { + size = 20 + } + if size > 100 { + size = 100 + } + return page, size +} +func splitCapabilities(value string) []string { + if value == "" { + return []string{} + } + return strings.Split(value, ",") +} + +var _ = time.Time{} diff --git a/Sense/server/app/sense/device/types.go b/Sense/server/app/sense/device/types.go new file mode 100644 index 0000000..6dc773d --- /dev/null +++ b/Sense/server/app/sense/device/types.go @@ -0,0 +1,41 @@ +package device + +import "time" + +const ( + ModalityVideo = "video" + StatusPending = "pending" + StatusActive = "active" + StatusOffline = "offline" + StatusDisabled = "disabled" + AdapterReady = "ready" + AdapterNotReady = "adapter_not_ready" +) + +type Device struct { + ID string `json:"id"` + Name string `json:"name"` + Location string `json:"location"` + Modality string `json:"modality"` + Capabilities []string `json:"capabilities"` + Status string `json:"status"` + AdapterStatus string `json:"adapter_status"` + CredentialConfigured bool `json:"credential_configured"` + CredentialCiphertext []byte `json:"-"` + Version int64 `json:"version"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type ListFilter struct { + Keyword string + Page int + PageSize int +} + +type Page struct { + Items []Device `json:"items"` + Total int `json:"total"` + Page int `json:"page"` + PageSize int `json:"page_size"` +} diff --git a/Sense/server/app/sense/identity/audit_port.go b/Sense/server/app/sense/identity/audit_port.go new file mode 100644 index 0000000..732ec1e --- /dev/null +++ b/Sense/server/app/sense/identity/audit_port.go @@ -0,0 +1,13 @@ +package identity + +import "context" + +// RecordAudit is the narrow project-internal audit port used by Sense business +// modules. It never accepts secrets and does not expose identity persistence. +func RecordAudit(ctx context.Context, actor, action, target, outcome string, metadata map[string]any) { + module := activeModule.Load() + if module == nil { + return + } + module.service.audit(ctx, actor, action, target, outcome, metadata) +} diff --git a/Sense/server/cmd/sense/modules_device.go b/Sense/server/cmd/sense/modules_device.go new file mode 100644 index 0000000..b29169a --- /dev/null +++ b/Sense/server/cmd/sense/modules_device.go @@ -0,0 +1,26 @@ +package sense + +import ( + "os" + "yovision.local/sense/app/sense/device" + "yovision.local/sense/internal/platform" +) + +func init() { + registerModule(func(app *platform.App) error { + memory := app.Config().DatabaseMode == platform.DatabaseModeMemory + vault, err := device.NewCredentialVault(os.Getenv("SENSE_CREDENTIAL_KEY"), memory) + if err != nil { + return err + } + var store device.Store + if memory { + store = device.NewMemoryStore() + } else { + store = device.NewPostgresStore(app.Database()) + } + app.RegisterMigration(platform.Migration{Version: 2026081202, Name: "sense_device", SQL: device.MigrationSQL}) + device.NewModule(device.NewService(store, vault)).Register(app) + return nil + }) +} diff --git a/Sense/server/database/migrations/sense_device/2026081202_device.sql b/Sense/server/database/migrations/sense_device/2026081202_device.sql new file mode 100644 index 0000000..b968803 --- /dev/null +++ b/Sense/server/database/migrations/sense_device/2026081202_device.sql @@ -0,0 +1,3 @@ +CREATE TABLE IF NOT EXISTS sense_devices(id TEXT PRIMARY KEY,name TEXT NOT NULL,location TEXT NOT NULL DEFAULT '',modality TEXT NOT NULL,capabilities TEXT NOT NULL DEFAULT '',status TEXT NOT NULL,adapter_status TEXT NOT NULL,credential_ciphertext BYTEA NULL,version BIGINT NOT NULL,created_at TIMESTAMPTZ NOT NULL,updated_at TIMESTAMPTZ NOT NULL); +CREATE INDEX IF NOT EXISTS sense_devices_created_idx ON sense_devices(created_at DESC); + diff --git a/Sense/tests/device/README.md b/Sense/tests/device/README.md new file mode 100644 index 0000000..27d7201 --- /dev/null +++ b/Sense/tests/device/README.md @@ -0,0 +1,6 @@ +# Device acceptance + +Package tests verify CRUD state, pagination beyond 16 items, unsupported +adapter status, optimistic versions and that encrypted camera credentials are +write-only. Fixtures use generated values only; no address or real credential +is stored in the repository. diff --git a/Sense/ui/src/api/sense/device/index.js b/Sense/ui/src/api/sense/device/index.js new file mode 100644 index 0000000..9a284c1 --- /dev/null +++ b/Sense/ui/src/api/sense/device/index.js @@ -0,0 +1,8 @@ +import request from '../../../bootstrap/request' + +export const listDevices = (params) => request.get('/devices', { params }) +export const createDevice = (data) => request.post('/devices', data) +export const updateDevice = (id, data) => request.patch(`/devices/${id}`, data) +export const updateCredential = (id, data) => request.post(`/devices/${id}/credentials`, data) +export const disableDevice = (id, version) => request.post(`/devices/${id}/disable`, { version }) + diff --git a/Sense/ui/src/router/modules/sense-device.js b/Sense/ui/src/router/modules/sense-device.js new file mode 100644 index 0000000..4637470 --- /dev/null +++ b/Sense/ui/src/router/modules/sense-device.js @@ -0,0 +1,9 @@ +import Devices from '../../views/sense/device/Devices.vue' + +export default { + path: 'devices', + name: 'sense-devices', + component: Devices, + meta: { title: '设备管理', icon: 'VideoCamera', order: 10, permission: 'device.read' } +} + diff --git a/Sense/ui/src/views/sense/device/Devices.vue b/Sense/ui/src/views/sense/device/Devices.vue new file mode 100644 index 0000000..9c27d89 --- /dev/null +++ b/Sense/ui/src/views/sense/device/Devices.vue @@ -0,0 +1,75 @@ + + + 设备管理登记设备和安装位置;摄像机密码只可更新,不能查看。 + + + + 查询重置 + + + + 设备列表添加设备 + + + + {{ scope.row.modality === 'video' ? '视频设备' : scope.row.modality }} + {{ scope.row.adapter_status === 'ready' ? '可接入' : '适配器未就绪' }} + {{ scope.row.credential_configured ? '已配置' : '未配置' }} + {{ statusLabel(scope.row.status) }} + 编辑更新凭据停用 + + + + + + + + + + + 取消保存 + + + + + + + + + 取消安全保存 + + + + + + +
登记设备和安装位置;摄像机密码只可更新,不能查看。