feat: card layout with photo carousel for face gallery
This commit is contained in:
parent
1f6d64ce0b
commit
f3ae1ba6f4
@ -7,10 +7,11 @@ import (
|
||||
)
|
||||
|
||||
type PersonRecord struct {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
PhotoCount int `json:"photo_count"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
PhotoCount int `json:"photo_count"`
|
||||
Photos []string `json:"photos,omitempty"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
type FaceGalleryRepo struct {
|
||||
@ -54,9 +55,7 @@ func (r *FaceGalleryRepo) ListPersons() ([]PersonRecord, error) {
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
rows, err := db.Query(`SELECT p.id, p.name, p.created_at, COUNT(ph.id) AS photo_count
|
||||
FROM face_persons p LEFT JOIN face_photos ph ON ph.person_id = p.id
|
||||
GROUP BY p.id ORDER BY p.name`)
|
||||
rows, err := db.Query(`SELECT id, name, created_at FROM face_persons ORDER BY name`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@ -65,11 +64,26 @@ GROUP BY p.id ORDER BY p.name`)
|
||||
var out []PersonRecord
|
||||
for rows.Next() {
|
||||
var p PersonRecord
|
||||
if err := rows.Scan(&p.ID, &p.Name, &p.CreatedAt, &p.PhotoCount); err != nil {
|
||||
if err := rows.Scan(&p.ID, &p.Name, &p.CreatedAt); err != nil {
|
||||
continue
|
||||
}
|
||||
out = append(out, p)
|
||||
}
|
||||
// Load photos for each person
|
||||
for i := range out {
|
||||
photoRows, err := db.Query(`SELECT photo_path FROM face_photos WHERE person_id = ? ORDER BY id`, out[i].ID)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for photoRows.Next() {
|
||||
var path string
|
||||
if photoRows.Scan(&path) == nil {
|
||||
out[i].Photos = append(out[i].Photos, path)
|
||||
}
|
||||
}
|
||||
photoRows.Close()
|
||||
out[i].PhotoCount = len(out[i].Photos)
|
||||
}
|
||||
if out == nil {
|
||||
out = make([]PersonRecord, 0)
|
||||
}
|
||||
|
||||
@ -665,6 +665,7 @@ func (u *UI) Routes() (chi.Router, error) {
|
||||
r.Get("/diagnostics", u.pageDiagnostics)
|
||||
r.Get("/alarms", u.pageAlarms)
|
||||
r.Get("/face-gallery", u.pageFaceGallery)
|
||||
r.Get("/face-photo/*", u.serveFacePhoto)
|
||||
r.Post("/face-gallery/import", u.actionFaceGalleryImport)
|
||||
r.Post("/face-gallery/build", u.actionFaceGalleryBuild)
|
||||
r.Post("/face-gallery/add", u.actionFaceGalleryAdd)
|
||||
@ -4122,3 +4123,13 @@ func personNameFromPath(path string) string {
|
||||
}
|
||||
return strings.TrimSpace(parts[len(parts)-2])
|
||||
}
|
||||
|
||||
func (u *UI) serveFacePhoto(w http.ResponseWriter, r *http.Request) {
|
||||
path := chi.URLParam(r, "*")
|
||||
if path == "" || strings.Contains(path, "..") {
|
||||
http.Error(w, "invalid", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
fullPath := filepath.Join("dataset", path)
|
||||
http.ServeFile(w, r, fullPath)
|
||||
}
|
||||
|
||||
@ -3,19 +3,19 @@
|
||||
<div class="section-title">
|
||||
<div>
|
||||
<h2 class="title-with-icon">{{icon "profile"}}<span>人脸库管理</span></h2>
|
||||
<div class="form-hint">批量导入或逐个添加人员照片,然后重新生成人脸库并下发到设备。</div>
|
||||
<div class="form-hint">批量导入或新增人员照片,修改后需在资源管理页面同步到设备。</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{if .Message}}
|
||||
<div class="card"><pre class="muted small" style="white-space:pre-wrap;margin:0">{{.Message}}</pre></div>
|
||||
<div class="card"><div class="muted small" style="margin:0">{{.Message}}</div></div>
|
||||
{{end}}
|
||||
|
||||
<div class="detail-grid">
|
||||
<div class="card">
|
||||
<h3 class="title-with-icon">{{icon "overview"}}<span>批量导入</span></h3>
|
||||
<div class="form-hint" style="margin-bottom:12px">选择按姓名分目录的照片根目录,系统自动识别子目录名作为人员姓名。</div>
|
||||
<div class="form-hint" style="margin-bottom:12px">选择按姓名分目录的照片根目录。</div>
|
||||
<form method="post" action="/ui/face-gallery/import" enctype="multipart/form-data">
|
||||
<div class="field-grid">
|
||||
<label class="full"><span>选择照片目录</span><input type="file" name="photos" webkitdirectory multiple required /></label>
|
||||
@ -25,10 +25,9 @@
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3 class="title-with-icon">{{icon "edit"}}<span>新增人员</span></h3>
|
||||
<div class="form-hint" style="margin-bottom:12px">添加单个人员并上传照片。</div>
|
||||
<div class="form-hint" style="margin-bottom:12px">添加单个人员。</div>
|
||||
<form method="post" action="/ui/face-gallery/add" enctype="multipart/form-data">
|
||||
<div class="field-grid">
|
||||
<label><span>姓名</span><input type="text" name="name" placeholder="例如:张三" required /></label>
|
||||
@ -45,37 +44,58 @@
|
||||
<div class="section-title">
|
||||
<div>
|
||||
<h3 class="title-with-icon">{{icon "profile"}}<span>人员列表</span></h3>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<span class="muted small">{{len .FaceGalleryPersons}} 人</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
{{if .FaceGalleryPersons}}
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>ID</th><th>姓名</th><th>操作</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .FaceGalleryPersons}}
|
||||
<tr>
|
||||
<td class="mono">{{.ID}}</td>
|
||||
<td style="font-weight:500">{{.Name}}</td>
|
||||
<td>
|
||||
<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>
|
||||
</td>
|
||||
</tr>
|
||||
{{if .FaceGalleryPersons}}
|
||||
<div style="display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:16px;margin-top:12px">
|
||||
{{range .FaceGalleryPersons}}
|
||||
<div class="card" style="padding:8px;position:relative">
|
||||
<div class="photo-viewer" data-photos='[{{range $i,$p := .Photos}}{{if $i}},{{end}}"/face-photo/{{$p}}"{{end}}]' style="width:100%;height:180px;background:var(--surface-soft);border-radius:var(--radius);overflow:hidden;position:relative">
|
||||
{{if .Photos}}
|
||||
<img src="/face-photo/{{index .Photos 0}}" style="width:100%;height:100%;object-fit:cover" id="img-{{.ID}}" />
|
||||
{{if gt .PhotoCount 1}}
|
||||
<button class="btn ghost" style="position:absolute;left:4px;top:50%;transform:translateY(-50%);padding:2px 6px;font-size:10px;opacity:0.8" onclick="prevPhoto({{.ID}})"><</button>
|
||||
<button class="btn ghost" style="position:absolute;right:4px;top:50%;transform:translateY(-50%);padding:2px 6px;font-size:10px;opacity:0.8" onclick="nextPhoto({{.ID}})">></button>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
{{else}}
|
||||
<div class="empty-state">
|
||||
<div class="empty-title">人脸库为空</div>
|
||||
<div class="muted">请先批量导入或新增人员,然后点击"重新生成人脸库"。</div>
|
||||
{{else}}
|
||||
<div class="muted" style="display:flex;align-items:center;justify-content:center;height:100%">无照片</div>
|
||||
{{end}}
|
||||
</div>
|
||||
<div style="margin-top:6px;display:flex;justify-content:space-between;align-items:center">
|
||||
<div>
|
||||
<span style="font-weight:500;font-size:13px">{{.Name}}</span>
|
||||
<div class="muted small">{{.PhotoCount}} 张照片</div>
|
||||
</div>
|
||||
<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);font-size:11px;padding:2px 6px">删除</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="empty-state"><div class="empty-title">人脸库为空</div></div>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
<script>
|
||||
var photoData = {};
|
||||
document.querySelectorAll('.photo-viewer').forEach(function(el) {
|
||||
var photos = JSON.parse(el.dataset.photos);
|
||||
var id = el.querySelector('img') ? el.querySelector('img').id.replace('img-','') : '';
|
||||
if (id) photoData[id] = {photos: photos, idx: 0};
|
||||
});
|
||||
function prevPhoto(id) {
|
||||
var d = photoData[id]; if (!d || d.photos.length<2) return;
|
||||
d.idx = (d.idx - 1 + d.photos.length) % d.photos.length;
|
||||
document.getElementById('img-'+id).src = d.photos[d.idx];
|
||||
}
|
||||
function nextPhoto(id) {
|
||||
var d = photoData[id]; if (!d || d.photos.length<2) return;
|
||||
d.idx = (d.idx + 1) % d.photos.length;
|
||||
document.getElementById('img-'+id).src = d.photos[d.idx];
|
||||
}
|
||||
</script>
|
||||
{{end}}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user