P2-14 模型管理简化: - 上传新模型:模态框上传 → 存盘 → SHA256 入库 → 可选自动分发 - 设备兼容性矩阵:根据 Edge /v1/capabilities 区分缺失 vs 不适用 - 版本管理:从固定 auto 改为文件修改时间戳 - 更新全部智能过滤:只推送缺失/不一致的设备 P2-15 人脸库优化: - Build 产出自动注册为标准资源,接入 Resource Task 分发系统 - 人脸库页面增加设备同步状态矩阵 + 批量同步按钮 - 人员搜索过滤 - 人脸质量评分反馈(构建诊断信息 + 通过率展示) 新增:通道部署页每台设备显示配置同步状态(对比 config_versions 与 Edge config SHA256) 文档:产品化改进计划、实施跟踪、模型与人脸库改进方案
173 lines
9.6 KiB
HTML
173 lines
9.6 KiB
HTML
{{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()">×</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)">◀</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)">▶</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}}]'>✎</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()">✕</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+')">×</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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');}
|
||
</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}}
|