feat: face gallery management page with person CRUD

This commit is contained in:
tian 2026-05-08 09:42:32 +08:00
parent 0c89dced36
commit c77391e5c5
5 changed files with 224 additions and 1 deletions

View 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
}

View File

@ -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)
}

View 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}}

View File

@ -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>

View File

@ -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)
}