safesight-control/internal/web/ui/templates/face_gallery.html
tian 96e689fec0 feat: 模型管理与人脸库产品化改进,通道部署配置状态指示
P2-14 模型管理简化:
- 上传新模型:模态框上传 → 存盘 → SHA256 入库 → 可选自动分发
- 设备兼容性矩阵:根据 Edge /v1/capabilities 区分缺失 vs 不适用
- 版本管理:从固定 auto 改为文件修改时间戳
- 更新全部智能过滤:只推送缺失/不一致的设备

P2-15 人脸库优化:
- Build 产出自动注册为标准资源,接入 Resource Task 分发系统
- 人脸库页面增加设备同步状态矩阵 + 批量同步按钮
- 人员搜索过滤
- 人脸质量评分反馈(构建诊断信息 + 通过率展示)

新增:通道部署页每台设备显示配置同步状态(对比 config_versions 与 Edge config SHA256)

文档:产品化改进计划、实施跟踪、模型与人脸库改进方案
2026-07-20 10:27:11 +08:00

173 lines
9.6 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

{{define "face_gallery"}}
<div id="modal" class="modal-overlay" style="display:none">
<div class="card" style="max-width:500px;width:90%;max-height:80vh;overflow-y:auto;position:relative">
<span class="btn-icon ghost" style="position:absolute;top:8px;right:8px;font-size:14px" title="关闭" onclick="closeModal()">&times;</span>
<div id="modal-body"></div>
</div>
</div>
<div class="card">
<div class="section-title">
<div>
<h3 class="title-with-icon">{{icon "profile"}}<span>人员列表</span></h3>
<span class="muted small">{{len .FaceGalleryPersons}} 人</span>
</div>
<div class="actions compact">
<input type="search" id="person-search" class="input" placeholder="搜索人员..." style="width:180px" oninput="filterPersons()">
<button class="btn secondary" type="button" onclick="showImport()">批量导入</button>
<button class="btn" type="button" onclick="showAdd()">新增人员</button>
</div>
</div>
{{if .FaceGalleryPersons}}
<div class="person-grid" style="display:grid;grid-template-columns:repeat(auto-fill,minmax(160px,1fr));gap:16px;margin-top:12px">
{{range .FaceGalleryPersons}}
<div class="card person-card" data-name="{{.Name}}" style="padding:8px;max-width:200px">
<div class="photo-viewer" data-photos='[{{range $i,$p := .Photos}}{{if $i}},{{end}}"/ui/face-photo/{{$p}}"{{end}}]' style="width:100%;aspect-ratio:3/4;background:var(--surface-soft);border-radius:var(--radius);overflow:hidden;position:relative">
{{if .Photos}}
<img src="/ui/face-photo/{{index .Photos 0}}" style="width:100%;height:100%;object-fit:cover" id="img-{{.ID}}" />
{{if gt .PhotoCount 1}}
<span class="btn-icon ghost nav-btn" id="prev-{{.ID}}" style="position:absolute;left:2px;top:50%;transform:translateY(-50%);font-size:10px;opacity:0.7;display:none" onclick="navPhoto({{.ID}},-1)">&#9664;</span>
<span class="btn-icon ghost nav-btn" id="next-{{.ID}}" style="position:absolute;right:2px;top:50%;transform:translateY(-50%);font-size:10px;opacity:0.7" onclick="navPhoto({{.ID}},1)">&#9654;</span>
{{end}}
{{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> <span class="muted small">{{.PhotoCount}}张</span>{{$quality := index $.FaceGalleryQuality .Name}}{{if $quality}} <span class="pill ok" style="font-size:10px">{{$quality}}通过</span>{{end}}</div>
<div class="actions compact" style="gap:2px">
<span class="btn-icon ghost edit-btn" title="编辑" data-id="{{.ID}}" data-name="{{.Name}}" data-photos='[{{$person := .}}{{range $i,$p := .Photos}}{{if $i}},{{end}}{"p":"/ui/face-photo/{{$p}}","id":{{index $person.PhotoIDs $i}}}{{end}}]'>&#9998;</span>
<form method="post" action="/ui/face-gallery/delete" style="display:inline" onsubmit="return confirm('确定删除 {{.Name}}')">
<input type="hidden" name="id" value="{{.ID}}">
<span class="btn-icon ghost" style="color:var(--danger-soft-text)" title="删除" onclick="this.closest('form').requestSubmit()">&#10005;</span>
</form>
</div>
</div>
</div>
{{end}}
</div>
{{else}}
<div class="empty-state"><div class="empty-title">人脸库为空</div></div>
{{end}}
</div>
{{$board := .ResourceStatusBoard}}
{{if and $board (gt (len .StandardResources) 0)}}
<div class="card">
<div class="section-title">
<div>
<h3 class="title-with-icon">{{icon "devices"}}<span>设备同步状态</span></h3>
<div class="form-hint">将人脸库下发到 Edge 设备SHA256 比对显示同步状态。</div>
</div>
<div class="actions compact">
<form method="post" action="/ui/face-gallery/sync">
{{range .Devices}}{{if .Online}}<input type="hidden" name="device_id" value="{{.DeviceID}}">{{end}}{{end}}
<button class="btn" type="submit">同步到全部设备</button>
</form>
</div>
</div>
<div class="table-wrap">
<table class="models-status-table">
<thead>
<tr>
<th>设备</th>
{{range .StandardResources}}<th class="model-status-col" title="{{.Name}}">{{shortHash .SHA256}}</th>{{end}}
<th>操作</th>
</tr>
</thead>
<tbody>
{{range $board.Rows}}
<tr>
<td>
<div class="table-key">{{.DeviceName}}</div>
<div class="muted small mono">{{.DeviceID}}</div>
</td>
{{range .Cells}}
<td>
{{if eq .Status "ok"}}<span class="pill ok">一致</span>
{{else if eq .Status "mismatch"}}<span class="pill warn">不一致</span>
{{else}}<span class="pill bad">缺失</span>{{end}}
</td>
{{end}}
<td>
<form method="post" action="/ui/face-gallery/sync" style="display:inline">
<input type="hidden" name="device_id" value="{{.DeviceID}}">
<button class="btn ghost small" type="submit">同步</button>
</form>
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
</div>
{{end}}
<script>
var photoData={};
document.querySelectorAll('.photo-viewer').forEach(function(el){
var p=JSON.parse(el.dataset.photos);
var id=el.querySelector('img')?el.querySelector('img').id.replace('img-',''):'';
if(id)photoData[id]={photos:p,idx:0};
});
function navPhoto(id,dir){
var d=photoData[id];if(!d||d.photos.length<2)return;
d.idx=(d.idx+dir+d.photos.length)%d.photos.length;
document.getElementById('img-'+id).src=d.photos[d.idx];
document.getElementById('prev-'+id).style.display=d.idx===0?'none':'';
document.getElementById('next-'+id).style.display=d.idx===d.photos.length-1?'none':'';
}
function showImport(){
document.getElementById('modal-body').innerHTML='<h3>批量导入</h3><div class="form-hint" style="margin-bottom:12px">选择按姓名分目录的照片根目录</div><form method="post" action="/ui/face-gallery/import" enctype="multipart/form-data"><label class="full" style="margin-bottom:12px;display:block"><span>选择照片目录</span><input type="file" name="photos" webkitdirectory multiple required /></label><div class="actions"><button class="btn" type="submit">导入</button></div></form>';
document.getElementById('modal').style.display='flex';
}
function showAdd(){
document.getElementById('modal-body').innerHTML='<h3>新增人员</h3><form method="post" action="/ui/face-gallery/add" enctype="multipart/form-data"><div class="field-grid"><label><span>姓名</span><input type="text" name="name" required /></label><label><span>照片</span><input type="file" name="photo" accept="image/*" required /></label></div><div class="actions"><button class="btn" type="submit">新增</button></div></form>';
document.getElementById('modal').style.display='flex';
}
function showEdit(id,name,photos){
var h='<h3>编辑人员</h3>';
h+='<form method="post" action="/ui/face-gallery/rename" style="margin-bottom:12px;display:flex;gap:8px;align-items:flex-end">';
h+='<input type="hidden" name="id" value="'+id+'"><label style="flex:1"><span class="small muted">姓名</span><input type="text" name="name" value="'+esc(name)+'" required /></label>';
h+='<button class="btn" type="submit">保存</button></form>';
h+='<div style="display:flex;flex-wrap:wrap;gap:8px;margin-bottom:12px">';
photos.forEach(function(p){
h+='<div style="position:relative;width:80px;height:100px"><img src="'+p.p+'" style="width:100%;height:100%;object-fit:cover;border-radius:4px">';
h+='<span class="btn-icon ghost" style="position:absolute;top:2px;right:2px;font-size:10px;color:red;background:rgba(0,0,0,0.5)" onclick="delPhoto('+p.id+')">&times;</span>';
h+='</div>';
});
h+='</div>';
h+='<form method="post" action="/ui/face-gallery/add-photo" enctype="multipart/form-data" style="display:flex;gap:8px;align-items:flex-end">';
h+='<input type="hidden" name="id" value="'+id+'"><label style="flex:1"><span class="small muted">添加照片</span><input type="file" name="photo" accept="image/*" /></label>';
h+='<button class="btn ghost" type="submit">上传</button></form>';
document.getElementById('modal-body').innerHTML=h;
document.getElementById('modal').style.display='flex';
}
function delPhoto(id){
if(!confirm('确认删除这张照片?'))return;
var f=document.createElement('form');f.method='post';f.action='/ui/face-gallery/delete-photo';
var i=document.createElement('input');i.type='hidden';i.name='id';i.value=id;f.appendChild(i);
document.body.appendChild(f);f.submit();
}
function closeModal(){document.getElementById('modal').style.display='none';}
function filterPersons(){
var q=(document.getElementById('person-search').value||'').toLowerCase();
var cards=document.querySelectorAll('.person-card');
var visible=0;
cards.forEach(function(c){
var name=(c.dataset.name||'').toLowerCase();
if(!q||name.indexOf(q)!==-1){c.style.display='';visible++;}
else{c.style.display='none';}
});
var grid=document.querySelector('.person-grid');
if(grid)grid.dataset.visible=visible;
}
document.addEventListener('click',function(e){var b=e.target.closest('.edit-btn');if(b){showEdit(b.dataset.id,b.dataset.name,JSON.parse(b.dataset.photos));}});
function esc(s){return(s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');}
</script>
<style>
.modal-overlay{position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.5);z-index:1000;align-items:center;justify-content:center}
</style>
{{end}}