feat: face gallery management page with person CRUD
This commit is contained in:
parent
0c89dced36
commit
c77391e5c5
115
internal/storage/face_gallery_repo.go
Normal file
115
internal/storage/face_gallery_repo.go
Normal file
@ -0,0 +1,115 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
type PersonRecord struct {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
PhotoURL string `json:"photo_url,omitempty"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
type FaceGalleryRepo struct {
|
||||
dbPath string
|
||||
}
|
||||
|
||||
func NewFaceGalleryRepo(dbPath string) *FaceGalleryRepo {
|
||||
return &FaceGalleryRepo{dbPath: dbPath}
|
||||
}
|
||||
|
||||
func (r *FaceGalleryRepo) open() (*sql.DB, error) {
|
||||
db, err := sql.Open("sqlite", r.dbPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open face gallery: %w", err)
|
||||
}
|
||||
// Ensure schema
|
||||
_, err = db.Exec(`
|
||||
CREATE TABLE IF NOT EXISTS person (
|
||||
id INTEGER PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT '',
|
||||
extra TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS embedding (
|
||||
id INTEGER PRIMARY KEY,
|
||||
person_id INTEGER NOT NULL,
|
||||
embedding BLOB NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT '',
|
||||
FOREIGN KEY (person_id) REFERENCES person(id)
|
||||
);
|
||||
`)
|
||||
if err != nil {
|
||||
db.Close()
|
||||
return nil, fmt.Errorf("ensure schema: %w", err)
|
||||
}
|
||||
return db, nil
|
||||
}
|
||||
|
||||
func (r *FaceGalleryRepo) ListPersons() ([]PersonRecord, error) {
|
||||
db, err := r.open()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
rows, err := db.Query(`SELECT p.id, p.name, p.created_at, COUNT(e.id) AS photo_count
|
||||
FROM person p LEFT JOIN embedding e ON e.person_id = p.id
|
||||
GROUP BY p.id ORDER BY p.name`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []PersonRecord
|
||||
for rows.Next() {
|
||||
var p PersonRecord
|
||||
var photoCount int
|
||||
if err := rows.Scan(&p.ID, &p.Name, &p.CreatedAt, &photoCount); err != nil {
|
||||
continue
|
||||
}
|
||||
out = append(out, p)
|
||||
}
|
||||
if out == nil {
|
||||
out = make([]PersonRecord, 0)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (r *FaceGalleryRepo) AddPerson(name string) error {
|
||||
db, err := r.open()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer db.Close()
|
||||
now := time.Now().Format(time.RFC3339)
|
||||
_, err = db.Exec(`INSERT INTO person(name, created_at) VALUES(?, ?)`, name, now)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *FaceGalleryRepo) DeletePerson(id int) error {
|
||||
db, err := r.open()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer db.Close()
|
||||
_, err = db.Exec(`DELETE FROM embedding WHERE person_id = ?`, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = db.Exec(`DELETE FROM person WHERE id = ?`, id)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *FaceGalleryRepo) RenamePerson(id int, name string) error {
|
||||
db, err := r.open()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer db.Close()
|
||||
_, err = db.Exec(`UPDATE person SET name = ? WHERE id = ?`, name, id)
|
||||
return err
|
||||
}
|
||||
@ -80,6 +80,7 @@ type PageData struct {
|
||||
StandardResources []storage.StandardResourceRecord
|
||||
ResourceStatusBoard *service.ResourceStatusBoard
|
||||
AlarmRecords []service.AlarmRecord
|
||||
FaceGalleryPersons []storage.PersonRecord
|
||||
Templates []service.Template
|
||||
Template *service.Template
|
||||
AssetTab string
|
||||
@ -661,6 +662,10 @@ func (u *UI) Routes() (chi.Router, error) {
|
||||
r.Post("/resources/sync", u.actionResourceSync)
|
||||
r.Get("/diagnostics", u.pageDiagnostics)
|
||||
r.Get("/alarms", u.pageAlarms)
|
||||
r.Get("/face-gallery", u.pageFaceGallery)
|
||||
r.Post("/face-gallery/add", u.actionFaceGalleryAdd)
|
||||
r.Post("/face-gallery/delete", u.actionFaceGalleryDelete)
|
||||
r.Post("/face-gallery/rename", u.actionFaceGalleryRename)
|
||||
r.Get("/monitor", u.pageMonitor)
|
||||
r.Get("/hls/*", u.proxyHLS)
|
||||
r.Get("/api/monitor/channels", u.apiMonitorChannels)
|
||||
@ -3925,3 +3930,48 @@ func (u *UI) proxyHLS(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(code)
|
||||
w.Write(body)
|
||||
}
|
||||
|
||||
func (u *UI) pageFaceGallery(w http.ResponseWriter, r *http.Request) {
|
||||
data := PageData{Title: "人脸库管理"}
|
||||
if repo := storage.NewFaceGalleryRepo(filepath.Join("resources", "standard_resources", "face_gallery", "face_gallery.db")); repo != nil {
|
||||
if persons, err := repo.ListPersons(); err == nil {
|
||||
data.FaceGalleryPersons = persons
|
||||
}
|
||||
}
|
||||
u.render(w, r, "face_gallery", data)
|
||||
}
|
||||
func (u *UI) actionFaceGalleryAdd(w http.ResponseWriter, r *http.Request) {
|
||||
name := strings.TrimSpace(r.FormValue("name"))
|
||||
if name == "" {
|
||||
http.Error(w, "name required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
repo := storage.NewFaceGalleryRepo(filepath.Join("resources", "standard_resources", "face_gallery", "face_gallery.db"))
|
||||
if err := repo.AddPerson(name); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/ui/face-gallery", http.StatusFound)
|
||||
}
|
||||
func (u *UI) actionFaceGalleryDelete(w http.ResponseWriter, r *http.Request) {
|
||||
id, _ := strconv.Atoi(r.FormValue("id"))
|
||||
if id <= 0 {
|
||||
http.Error(w, "invalid id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
repo := storage.NewFaceGalleryRepo(filepath.Join("resources", "standard_resources", "face_gallery", "face_gallery.db"))
|
||||
repo.DeletePerson(id)
|
||||
http.Redirect(w, r, "/ui/face-gallery", http.StatusFound)
|
||||
}
|
||||
func (u *UI) actionFaceGalleryRename(w http.ResponseWriter, r *http.Request) {
|
||||
id, _ := strconv.Atoi(r.FormValue("id"))
|
||||
name := strings.TrimSpace(r.FormValue("name"))
|
||||
if id <= 0 || name == "" {
|
||||
http.Error(w, "invalid", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
repo := storage.NewFaceGalleryRepo(filepath.Join("resources", "standard_resources", "face_gallery", "face_gallery.db"))
|
||||
repo.RenamePerson(id, name)
|
||||
http.Redirect(w, r, "/ui/face-gallery", http.StatusFound)
|
||||
}
|
||||
|
||||
|
||||
57
internal/web/ui/templates/face_gallery.html
Normal file
57
internal/web/ui/templates/face_gallery.html
Normal file
@ -0,0 +1,57 @@
|
||||
{{define "face_gallery"}}
|
||||
<div class="card">
|
||||
<div class="section-title">
|
||||
<div>
|
||||
<h2 class="title-with-icon">{{icon "profile"}}<span>人脸库管理</span></h2>
|
||||
<div class="form-hint">管理人员信息,所属设备统一通过资源同步下发。</div>
|
||||
</div>
|
||||
<div class="actions compact">
|
||||
<form method="post" action="/ui/face-gallery/add" style="display:inline">
|
||||
<input type="text" name="name" placeholder="姓名" required style="width:120px;margin-right:8px">
|
||||
<button class="btn" type="submit">新增人员</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="table-wrap">
|
||||
{{if .FaceGalleryPersons}}
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>ID</th><th>姓名</th><th>注册时间</th><th>操作</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .FaceGalleryPersons}}
|
||||
<tr>
|
||||
<td class="mono">{{.ID}}</td>
|
||||
<td>
|
||||
<span style="font-weight:500">{{.Name}}</span>
|
||||
</td>
|
||||
<td class="small">{{.CreatedAt}}</td>
|
||||
<td>
|
||||
<div class="actions compact">
|
||||
<form method="post" action="/ui/face-gallery/rename" style="display:inline">
|
||||
<input type="hidden" name="id" value="{{.ID}}">
|
||||
<input type="text" name="name" placeholder="新姓名" style="width:80px">
|
||||
<button class="btn ghost" type="submit">改名</button>
|
||||
</form>
|
||||
<form method="post" action="/ui/face-gallery/delete" style="display:inline" onsubmit="return confirm('确定删除 {{.Name}}?')">
|
||||
<input type="hidden" name="id" value="{{.ID}}">
|
||||
<button class="btn ghost" type="submit" style="color:var(--danger-soft-text)">删除</button>
|
||||
</form>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
{{else}}
|
||||
<div class="empty-state">
|
||||
<div class="empty-title">人脸库为空</div>
|
||||
<div class="muted">请先添加人员。</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
@ -35,6 +35,7 @@
|
||||
<div class="nav-group-items">
|
||||
<a class="nav-subitem" href="/ui/models"><span class="nav-icon nav-subicon">{{icon "assets"}}</span><span>模型管理</span></a>
|
||||
<a class="nav-subitem" href="/ui/resources"><span class="nav-icon nav-subicon">{{icon "template"}}</span><span>资源管理</span></a>
|
||||
<a class="nav-subitem" href="/ui/face-gallery"><span class="nav-icon nav-subicon">{{icon "profile"}}</span><span>人脸库</span></a>
|
||||
<a class="nav-subitem" href="/ui/alarms"><span class="nav-icon nav-subicon">{{icon "bell"}}</span><span>告警中心</span></a>
|
||||
<a class="nav-subitem" href="/ui/monitor"><span class="nav-icon nav-subicon">{{icon "devices"}}</span><span>视频监控</span></a>
|
||||
<a class="nav-subitem" href="/ui/diagnostics"><span class="nav-icon nav-subicon">{{icon "logs"}}</span><span>日志审计</span></a>
|
||||
|
||||
@ -1913,7 +1913,7 @@ func TestUI_ModelsPageShowsStandardModelsAndDeviceStatus(t *testing.T) {
|
||||
t.Fatalf("expected model management HTML to contain %q", want)
|
||||
}
|
||||
}
|
||||
for _, forbidden := range []string{"统一模型目录", "人脸库", "查看设备模型"} {
|
||||
for _, forbidden := range []string{"统一模型目录", "查看设备模型"} {
|
||||
if strings.Contains(body, forbidden) {
|
||||
t.Fatalf("model management page should not contain legacy text %q", forbidden)
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user