feat: add Sense device inventory and credential boundary (#23)

This commit is contained in:
QiuSW
2026-08-12 17:10:13 +08:00
parent b9416ebc08
commit 5b8f4a4c8c
14 changed files with 704 additions and 0 deletions
+53
View File
@@ -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
}
+123
View File
@@ -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()})
}
@@ -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);`
+126
View File
@@ -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)
}
@@ -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)
}
}
+160
View File
@@ -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{}
+41
View File
@@ -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"`
}
@@ -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)
}
+26
View File
@@ -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
})
}
@@ -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);
+6
View File
@@ -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.
+8
View File
@@ -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 })
@@ -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' }
}
@@ -0,0 +1,75 @@
<template>
<section class="page-container">
<div class="page-heading"><div><h1>设备管理</h1><p>登记设备和安装位置;摄像机密码只可更新,不能查看。</p></div></div>
<div class="search-toolbar">
<el-form :inline="true" @submit.prevent="load">
<el-form-item label="名称/位置"><el-input v-model="query.keyword" clearable placeholder="输入设备名称或位置" /></el-form-item>
<el-form-item><el-button type="primary" :icon="Search" @click="load">查询</el-button><el-button @click="reset">重置</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="page.items" border>
<el-table-column prop="name" label="设备名称" min-width="160" />
<el-table-column prop="location" label="安装位置" min-width="150" />
<el-table-column label="类型" width="110"><template #default="scope">{{ scope.row.modality === 'video' ? '视频设备' : scope.row.modality }}</template></el-table-column>
<el-table-column label="接入能力" width="140"><template #default="scope"><el-tag :type="scope.row.adapter_status === 'ready' ? 'success' : 'warning'">{{ scope.row.adapter_status === 'ready' ? '可接入' : '适配器未就绪' }}</el-tag></template></el-table-column>
<el-table-column label="凭据" width="110"><template #default="scope"><el-tag :type="scope.row.credential_configured ? 'success' : 'info'">{{ scope.row.credential_configured ? '已配置' : '未配置' }}</el-tag></template></el-table-column>
<el-table-column label="状态" width="100"><template #default="scope"><el-tag :type="scope.row.status === 'disabled' ? 'info' : 'primary'">{{ statusLabel(scope.row.status) }}</el-tag></template></el-table-column>
<el-table-column v-if="canWrite" label="操作" width="210" fixed="right"><template #default="scope"><el-button link type="primary" @click="openEdit(scope.row)">编辑</el-button><el-button link type="primary" @click="openCredential(scope.row)">更新凭据</el-button><el-button v-if="scope.row.status !== 'disabled'" link type="danger" @click="disable(scope.row)">停用</el-button></template></el-table-column>
</el-table>
<div class="pagination"><el-pagination v-model:current-page="query.page" v-model:page-size="query.page_size" background layout="total, sizes, prev, pager, next" :total="page.total" :page-sizes="[20, 50, 100]" @change="load" /></div>
</div>
<el-dialog v-model="deviceDialog" :title="editing ? '编辑设备' : '添加设备'" width="560px" destroy-on-close>
<el-form ref="deviceFormRef" :model="deviceForm" :rules="deviceRules" label-width="92px">
<el-form-item label="设备名称" prop="name"><el-input v-model="deviceForm.name" /></el-form-item>
<el-form-item label="安装位置" prop="location"><el-input v-model="deviceForm.location" /></el-form-item>
<el-form-item label="设备类型" prop="modality"><el-select v-model="deviceForm.modality" :disabled="Boolean(editing)" style="width: 100%"><el-option label="视频设备" value="video" /><el-option label="雷达(适配器未就绪)" value="radar" /><el-option label="按钮(适配器未就绪)" value="button" /></el-select></el-form-item>
</el-form>
<template #footer><el-button @click="deviceDialog = false">取消</el-button><el-button type="primary" :loading="saving" @click="saveDevice">保存</el-button></template>
</el-dialog>
<el-dialog v-model="credentialDialog" title="更新设备凭据" width="520px" destroy-on-close>
<el-alert title="凭据保存后不能查看,只能再次更新。请勿把密码写入设备地址。" type="warning" show-icon :closable="false" />
<el-form ref="credentialFormRef" :model="credentialForm" :rules="credentialRules" label-width="82px" class="credential-form">
<el-form-item label="用户名" prop="username"><el-input v-model="credentialForm.username" autocomplete="off" /></el-form-item>
<el-form-item label="密码" prop="password"><el-input v-model="credentialForm.password" type="password" show-password autocomplete="new-password" /></el-form-item>
</el-form>
<template #footer><el-button @click="credentialDialog = false">取消</el-button><el-button type="primary" :loading="saving" @click="saveCredential">安全保存</el-button></template>
</el-dialog>
</section>
</template>
<script setup>
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, Search } from '@element-plus/icons-vue'
import { useStore } from 'vuex'
import { createDevice, disableDevice, listDevices, updateCredential, updateDevice } from '../../../api/sense/device'
const store = useStore()
const loading = ref(false), saving = ref(false), deviceDialog = ref(false), credentialDialog = ref(false)
const editing = ref(null), credentialTarget = ref(null), deviceFormRef = ref(), credentialFormRef = ref()
const query = reactive({ keyword: '', page: 1, page_size: 20 })
const page = reactive({ items: [], total: 0 })
const deviceForm = reactive({ name: '', location: '', modality: 'video', capabilities: ['video'] })
const credentialForm = reactive({ username: '', password: '' })
const deviceRules = { name: [{ required: true, message: '请输入设备名称', trigger: 'blur' }], modality: [{ required: true, message: '请选择类型', trigger: 'change' }] }
const credentialRules = { username: [{ required: true, message: '请输入用户名', trigger: 'blur' }], password: [{ required: true, message: '请输入密码', trigger: 'blur' }] }
const canWrite = computed(() => store.getters['sense-identity/hasPermission']?.('device.write'))
function statusLabel(value) { return ({ pending: '待接入', active: '正常', offline: '离线', disabled: '已停用' })[value] || value }
async function load() { loading.value = true; try { Object.assign(page, await listDevices(query)) } finally { loading.value = false } }
function reset() { Object.assign(query, { keyword: '', page: 1, page_size: 20 }); load() }
function openCreate() { editing.value = null; Object.assign(deviceForm, { name: '', location: '', modality: 'video', capabilities: ['video'] }); deviceDialog.value = true }
function openEdit(row) { editing.value = row; Object.assign(deviceForm, { name: row.name, location: row.location, modality: row.modality, capabilities: row.capabilities }); deviceDialog.value = true }
function openCredential(row) { credentialTarget.value = row; Object.assign(credentialForm, { username: '', password: '' }); credentialDialog.value = true }
async function saveDevice() { const valid = await deviceFormRef.value?.validate().catch(() => false); if (!valid) return; saving.value = true; try { if (editing.value) await updateDevice(editing.value.id, { ...deviceForm, version: editing.value.version }); else await createDevice(deviceForm); ElMessage.success('设备已保存'); deviceDialog.value = false; await load() } catch (error) { ElMessage.error(error.message || '保存失败') } finally { saving.value = false } }
async function saveCredential() { const valid = await credentialFormRef.value?.validate().catch(() => false); if (!valid) return; saving.value = true; try { await updateCredential(credentialTarget.value.id, credentialForm); Object.assign(credentialForm, { username: '', password: '' }); ElMessage.success('凭据已安全更新'); credentialDialog.value = false; await load() } catch (error) { ElMessage.error(error.message || '更新失败') } finally { saving.value = false } }
async function disable(row) { await ElMessageBox.confirm(`停用“${row.name}”后将停止后续接入,设备记录和审计仍保留。`, '确认停用', { type: 'warning' }); await disableDevice(row.id, row.version); ElMessage.success('设备已停用'); await load() }
onMounted(load)
</script>
<style scoped>
.credential-form { margin-top: 20px; }
</style>