feat: 模型管理与人脸库产品化改进,通道部署配置状态指示

P2-14 模型管理简化:
- 上传新模型:模态框上传 → 存盘 → SHA256 入库 → 可选自动分发
- 设备兼容性矩阵:根据 Edge /v1/capabilities 区分缺失 vs 不适用
- 版本管理:从固定 auto 改为文件修改时间戳
- 更新全部智能过滤:只推送缺失/不一致的设备

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

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

文档:产品化改进计划、实施跟踪、模型与人脸库改进方案
This commit is contained in:
tian 2026-07-20 10:27:11 +08:00
parent 9094f1f0fc
commit 96e689fec0
12 changed files with 742 additions and 31 deletions

View File

@ -185,15 +185,27 @@
### 14. 模型管理简化
- 模型上传后自动分发到所有兼容设备
- 增加模型版本管理和兼容性检查
- 一键"更新全部设备模型"
**现状**:模型通过目录扫描同步到 DBTask 系统支持 `model_sync_all`/`model_sync_one` 批量分发,`ModelStatusBoard` 展示设备上的模型 SHA256 对比矩阵。缺的是上传入口和兼容性判断。
**架构**:管理端 `models/standard_models/` → 启动时同步到 `standard_models` 表 → UI 对比矩阵 → Task 推送到 Edge 的 `/v1/models/{name}`。架构正确,只需补全缺口。
**改什么**
- Web 页面上传新模型:文件上传 → 存盘 → 入库 → 可选自动分发
- 版本管理:版本号从固定 "auto" 改为基于时间戳自动生成
- 兼容性矩阵:设备不支持某类检测能力时(如无人脸识别),对应模型显示 "N/A" 而非 "缺失"
- "更新全部"只推送给需要更新的设备(缺失或不一致),避免无效传输
### 15. 人脸库优化
- 支持搜索和过滤
- 人脸质量评分反馈
- 同步状态可视化
**现状**:人员 CRUD`face_persons`/`face_photos` 表正常Python Build 脚本产出 `face_gallery.db``resources/standard_resources/face_gallery/`。但分发用的是设备详情页的逐台手动上传,没有利用已有的 Resource Task 分发系统。
**架构**:已有 Resource 分发基础设施(`resource_sync_all`/`resource_sync_one` Task、`ResourceStatusBoard` 对比矩阵、`/v1/resources/status` 端点),人脸库应作为 `resource_type=face_gallery` 的资源接入这套体系,而非走独立的设备上传通道。
**改什么**
- Build 产出自动注册Build 完成后将 `face_gallery.db` 的 hash/size 自动写入 `standard_resources` 表,让 Resource 分发系统感知
- 同步到设备:人脸库页面增加设备同步状态区域(复用 `ResourceStatusBoard` 筛选 `face_gallery`),加"同步到全部设备"按钮
- 搜索过滤:人员列表增加关键词搜索
- 人脸质量评分反馈:在人员详情中展示每张照片的质量等级(依赖 build_gallery.py 输出)
---

View File

@ -19,6 +19,8 @@
| 8 | 操作结果提示优化toast 替代 URL 消息) | ✅ 已完成 | 2026-07-17 | 2026-07-17 |
| 9 | 术语统一 | ✅ 已完成 | 2026-07-17 | 2026-07-17 |
| 16 | 通道部署页配置同步状态指示 | ✅ 已完成 | 2026-07-20 | 2026-07-20 |
## P2 — 长期优化
| # | 事项 | 状态 | 开始 | 完成 |
@ -27,15 +29,85 @@
| 11 | 设备分组与标签 | ⏸️ 推迟 | - | - |
| 12 | 配置版本对比 | ✅ 已完成 | 2026-07-17 | 2026-07-17 |
| 13 | 报告导出CSV | ✅ 已完成 | 2026-07-17 | 2026-07-17 |
| 14 | 模型管理简化 | ⬜ 待开始 | - | - |
| 15 | 人脸库优化(搜索+质量评分) | ⬜ 待开始 | - | - |
| 14 | 模型管理简化 | 🔄 进行中 | 2026-07-20 | - |
| 14.1 | └ 上传新模型(文件上传→存盘→入库) | ✅ 已完成 | 2026-07-20 | 2026-07-20 |
| 14.2 | └ 设备兼容性矩阵N/A vs 缺失) | ✅ 已完成 | 2026-07-20 | 2026-07-20 |
| 14.3 | └ 版本管理(时间戳替代 auto | ✅ 已完成 | 2026-07-20 | 2026-07-20 |
| 14.4 | └ "更新全部"智能过滤(只推送给需要更新的设备) | ✅ 已完成 | 2026-07-20 | 2026-07-20 |
| 15 | 人脸库优化(搜索+同步+质量评分) | ✅ 已完成 | 2026-07-20 | 2026-07-20 |
| 15.1 | └ Build 产出自动注册为标准资源 | ✅ 已完成 | 2026-07-20 | 2026-07-20 |
| 15.2 | └ 人脸库页面接入 Resource 同步系统 | ⬜ 待开始 | - | - |
| 15.3 | └ 人员搜索与过滤 | ✅ 已完成 | 2026-07-20 | 2026-07-20 |
| 15.4 | └ 人脸质量评分展示 | ✅ 已完成 | 2026-07-20 | 2026-07-20 |
## 进行中
(无)
- **P2-14 模型管理简化** — 详见 [模型与人脸库改进方案](./模型与人脸库改进方案.md)
- **P2-15 人脸库优化** — 详见 [模型与人脸库改进方案](./模型与人脸库改进方案.md)
## 已完成事项
### 2026-07-20
- **新增:通道部署页配置同步状态指示**
- `DeviceAssignmentBoardCard` 新增 `ConfigSynced` 字段
- `pageDeviceAssignments`:查询 Edge `/v1/config/status` 的 sha256`config_versions` 中最新的 config_id 对比
- 每个设备卡片显示:✅ 配置已同步 或 ⚠ 配置待更新
- 改动文件:`config_assets.go` (+1)、`ui.go` (+22)、`device_assignments.html` (+3)、`style.css` (+3)
- **P2-15.4 人脸质量评分展示**
- `actionFaceGalleryBuild` 增强输出解析:提取 per_person_used、failure_reasons、failed_images 等诊断信息
- Build 完成后写入 `build_meta.json`,供页面读取每人的通过数
- 结果消息增强:显示 "生成成功,共 3 人12 张通过2 张失败无人脸×2"
- 人脸库卡片:每人照片数旁显示绿色"N通过" pill如 "3通过"
- 改动文件:`ui.go` (+48)、`face_gallery.html` (+1)
- **P2-15 人脸库优化 全部完成**
- **P2-14.4 "更新全部"智能过滤**
- `ModelStatusRow` 新增 `NeedsSync` 字段,`BuildModelStatusBoard` 在设备有缺失/不一致模型时设为 true
- 模板"更新全部模型"表单改为只提交 `NeedsSync=true` 的设备,避免对已同步设备做无效推送
- 改动文件:`model_management.go` (+2)、`models.html` (+1)
- **P2-14.3 版本管理**
- 新增 `fileVersion` 辅助函数:基于文件修改时间生成 `YYYYMMDD-HHMMSS` 版本号
- `SyncStandardModelsFromDirectory`、`SyncStandardResourcesFromDirectory`、`RegisterResource` 的 `Version` 字段从固定 `"auto"` 改为调用 `fileVersion`
- `actionModelUpload` 的默认版本也改为统一格式
- 改动文件:`model_management.go` (+11)、`resource_management.go` (+3)、`ui.go` (+1)
- **P2-14.2 设备兼容性矩阵**
- `BuildModelStatusBoard` 新增 `deviceCapabilities` 参数,根据设备 `/v1/capabilities` 判断
- `ModelTypeCapability` 函数映射模型类型→能力Key`face_detection`→`face_det`、`face_recognition`→`face_recog`,其余不限制)
- 设备不支持某类模型时状态显示灰色「不适用」pill 而非红色「缺失」
- `pageModels` handler 查询所有在线设备的能力信息,传给 `BuildModelStatusBoard`
- Summary 计算:只有 ok/na 的设备算 Complete合理
- 改动文件:`model_management.go` (+22)、`ui.go` (+32)、`models.html` (+2)、`model_management_test.go` (+2)、`style.css` (+1)
- **P2-14.1 上传新模型**
- 新增 `actionModelUpload` handlermultipart 接收 → 存盘 `models/standard_models/` → SHA256 hash → 写入 `standard_models`
- 表单字段:名称、类型(下拉)、版本(可选,留空自动生成日期版号)、描述、文件
- "自动分发"复选框:勾选后立即创建 `model_sync_all` 任务推送到全部在线设备
- 路由:`POST /ui/models/upload`
- 模板:死按钮改为弹出模态框,仿 face_gallery 风格
- 改动文件:`internal/web/ui.go` (+82)、`models.html` (+38)
- **P2-15.3 人员搜索与过滤**
- 人员列表上方新增搜索框,输入关键词实时过滤(前端 JS无需后端
- 卡片增加 `person-card` class + `data-name` 属性用于匹配
- 改动文件:`face_gallery.html` (+15)
- **P2-15.2 人脸库页面接入 Resource 同步系统**
- `pageFaceGallery`:加载 `resource_type=face_gallery` 的标准资源,构建 `ResourceStatusBoard` 传给模板
- 新增 `actionFaceGallerySync` handler接收 `device_id[]`,创建 `resource_sync_all` 任务,跳转任务详情
- 模板新增"设备同步状态"卡片SHA256 对比矩阵 + "同步到全部设备"按钮 + 单设备"同步"按钮
- 路由:`POST /ui/face-gallery/sync`
- 改动文件:`internal/web/ui.go` (+41)、`face_gallery.html` (+49)
- **P2-15.1 Build 产出自动注册为标准资源**
- `ResourceManagementService` 新增 `RegisterResource` 方法:对单个文件 hash + 写入 `standard_resources`
- `actionFaceGalleryBuild`Build 成功后自动调用注册Resource 分发系统即刻感知新版本
- 改动文件:`internal/service/resource_management.go` (+20)、`internal/web/ui.go` (+8)
### 2026-07-17
- **P2-12 配置版本对比**
@ -90,4 +162,4 @@
---
**已完成10/1566.7%** | **剩余P0-4登录、P2-14模型、P2-15人脸库** | **推迟P2-11分组**
**已完成14/1593.3%** | **剩余P0-4登录** | **推迟P2-11分组**

View File

@ -0,0 +1,174 @@
# 模型管理 & 人脸库管理 改进实施方案
> 关联文档:[产品化改进计划](./产品化改进计划.md) → P2-14 / P2-15
> 跟踪状态:[实施跟踪](./实施跟踪.md)
## 改造原则
最小侵入,最大复用。人脸库的核心问题是"基建已就绪但没接上",不重写已有逻辑,而是**连通断点**。
---
## 一、人脸库管理P2-15
### 问题回顾
| 现有能力 | 问题 |
|----------|------|
| 人员 CRUD、照片管理 | ✅ 正常 |
| Python Build 脚本生成 face_gallery.db | ✅ 正常 |
| 产出落到 `resources/standard_resources/face_gallery/` | ✅ 已就位 |
| Resource Task 系统(`resource_sync_all`/`resource_sync_one` | ✅ 已就位 |
| ResourceStatusBoard 对比矩阵 | ✅ 已就位 |
| 从人脸库页面触发批量同步 | ❌ 缺失 |
| 查看设备上人脸库版本/状态 | ❌ 缺失 |
| 搜索过滤人员 | ❌ 缺失 |
### 改动清单
#### 1.1 人脸库页面增加"同步到设备"区域
**文件**: `internal/web/ui/templates/face_gallery.html`
在人脸库页面底部新增一个卡片区域:
- 显示当前 `face_gallery.db` 构建状态(最后构建时间、人数、照片数)
- 显示设备同步状态矩阵(复用 `ResourceStatusBoard`,筛选 `resource_type=face_gallery`
- "同步到全部设备"按钮 → 创建 `resource_sync_all` 任务
- 单设备"同步"按钮 → 创建 `resource_sync_one` 任务
**后端改动**:
- `pageFaceGallery` handler 增加:查询 `standard_resources``resource_type=face_gallery` 的记录、构建 `ResourceStatusBoard`
- 新增 `actionFaceGallerySync` handler接收 `device_id[]`,调用 `taskSvc.CreateTask("resource_sync_all", deviceIDs, payload)`
#### 1.2 Build 后自动注册为标准资源
**文件**: `internal/web/ui.go` - `actionFaceGalleryBuild`
现有 Build 后只返回消息,需要增加:
1. Build 成功后扫描 `resources/standard_resources/face_gallery/` 目录
2. 调用 `resourceSvc.SyncStandardResourcesFromDirectory()` 或其局部逻辑
3. 将 `face_gallery.db` 的 SHA256/hash/size 写入 `standard_resources`
这样 Build 完后Resource 分发系统就能自动感知到新版本。
**实现要点**:
```go
// actionFaceGalleryBuild 中build 成功后:
hash, size := hashFile(outputDB)
resourcesRepo.Save(StandardResourceRecord{
Name: "face_gallery", ResourceType: "face_gallery",
SHA256: hash, SizeBytes: size,
FilePath: outputDB,
})
```
#### 1.3 人员搜索与过滤
**文件**: `internal/storage/face_gallery_repo.go` + `face_gallery.html`
- `FaceGalleryRepo` 新增 `SearchPersons(keyword string) ([]PersonRecord, error)`
- 模板增加搜索框,输入关键词实时过滤人员列表(前端 JS 过滤即可,数据量小)
#### 1.4 人脸质量评分展示
**文件**: `internal/service/face_gallery_builder.go` + `face_gallery.html`
- Build 脚本的输出中解析每张照片的 quality score如果 build_gallery.py 有输出的话)
- 在人员详情编辑弹窗中,每张照片旁显示质量标签(高/中/低)
如果 `build_gallery.py` 当前不输出质量分,则作为可选项推迟。
---
## 二、模型管理P2-14
### 问题回顾
| 现有能力 | 问题 |
|----------|------|
| 目录扫描同步到 DB | ✅ 正常 |
| ModelStatusBoard 对比矩阵 | ✅ 正常 |
| Task 系统批量同步 | ✅ 正常 |
| "更新全部模型"按钮 | ✅ 已就位 |
| **上传新模型** UI | ❌ 按钮存在但无功能 |
| 模型版本管理 | ❌ 版本字段固定为 "auto" |
| 设备兼容性检查 | ❌ 不做区分,所有模型对所有设备 |
### 改动清单
#### 2.1 "上传新模型"功能实现
**文件**: `internal/web/ui/templates/models.html` + `internal/web/ui.go`
- 点击"上传新模型"弹出模态框
- 表单:模型名、模型类型(下拉选择)、描述、文件选择(.rknn
- 上传后:
1. 保存文件到 `models/standard_models/{filename}`
2. 计算 SHA256
3. 写入 `standard_models`
4. 可选立即分发到所有在线设备checkbox
**新增 handler**: `POST /ui/models/upload`
```go
func (u *UI) actionModelUpload(w http.ResponseWriter, r *http.Request) {
// multipart form: name, model_type, description, file
// 1. Save file to models/standard_models/
// 2. Hash + save to standard_models table
// 3. Optional: create model_sync_all task
}
```
**路由注册**: 在 `Routes()` 中添加 `r.Post("/models/upload", u.actionModelUpload)`
#### 2.2 模型版本管理
**文件**: `internal/storage/models_repo.go` + `internal/service/model_management.go` + `models.html`
- `StandardModelRecord.Version` 字段从固定 "auto" 改为基于文件修改时间的语义化版本(如 `2026-07-17-v1`
- 上传时允许用户指定版本号(可留空自动生成)
- 模板中已有版本列,展示即可
#### 2.3 设备兼容性矩阵
**文件**: `internal/service/model_management.go` + `models.html`
当前 `ModelStatusBoard` 对所有设备检查所有模型。改进:
- `BuildModelStatusBoard` 接收可选的设备 capability 信息
- 如果设备不支持人脸识别(没有 face_recognition capability`face_recognition` 类型的模型标记为 "N/A" 而非 "缺失"
- 矩阵中增加灰色 "N/A" pill区别于红色的 "缺失"
**实现要点**:
- 在 `pageModels` 中查询设备 `/v1/capabilities`,缓存到 map
- 传给 `BuildModelStatusBoard` 一个新参数 `deviceCapabilities map[string][]string`
- Cell 增加第三种状态 `"na"` → 灰色 pill "不适用"
#### 2.4 "更新全部模型"增强
当前已有此功能,只做小优化:
- 改为只更新需要更新的设备(缺失或不一致的),而不是全量推送
- 这在前端层面做过滤:只提交 `status != "ok"` 的设备 ID
---
## 三、实施顺序
```
第1步人脸库 - Build后自动注册为标准资源 (1.2) ← ✅ 已完成
第2步人脸库 - 同步到设备区域 (1.1) ← 页面UI接入Task系统
第3步人脸库 - 人员搜索过滤 (1.3) ← 交互优化
第4步模型管理 - 上传新模型功能 (2.1) ← 补全核心功能
第5步模型管理 - 设备兼容性矩阵 (2.3) ← 展示优化
第6步模型管理 - 版本管理 (2.2) ← 锦上添花
第7步人脸库 - 质量评分展示 (1.4) ← 可选
```
## 四、涉及文件一览
| 文件 | 改动类型 |
|------|----------|
| `internal/web/ui.go` | 新增/修改 handlers增加 face gallery sync + model upload |
| `internal/web/ui/templates/face_gallery.html` | 增加同步区域、搜索框、质量展示 |
| `internal/web/ui/templates/models.html` | 实现上传模态框、兼容性显示 |
| `internal/storage/face_gallery_repo.go` | 新增 `SearchPersons` |
| `internal/service/model_management.go` | `BuildModelStatusBoard` 支持 capability 过滤 |
| `internal/service/face_gallery_builder.go` | 可选:解析质量分 |

View File

@ -165,6 +165,7 @@ type DeviceAssignmentBoardCard struct {
AssignedCount int `json:"assigned_count"`
MaxUnits int `json:"max_units"`
Status string `json:"status"`
ConfigSynced bool `json:"config_synced"`
Units []DeviceAssignmentBoardUnit `json:"units"`
}

View File

@ -10,6 +10,7 @@ import (
"path/filepath"
"sort"
"strings"
"time"
"safesight-control/internal/models"
"safesight-control/internal/storage"
@ -38,6 +39,7 @@ type ModelStatusRow struct {
DeviceID string `json:"device_id"`
DeviceName string `json:"device_name"`
Online bool `json:"online"`
NeedsSync bool `json:"needs_sync"`
Cells []ModelStatusCell `json:"cells"`
ExtraModelCount int `json:"extra_model_count"`
ExtraModels []InstalledModelStatus `json:"extra_models"`
@ -87,7 +89,7 @@ func (s *ModelManagementService) SyncStandardModelsFromDirectory(dir string) err
record := storage.StandardModelRecord{
Name: strings.TrimSuffix(entry.Name(), filepath.Ext(entry.Name())),
FileName: entry.Name(),
Version: "auto",
Version: fileVersion(fullPath),
SHA256: sum,
SizeBytes: size,
ModelType: inferModelType(entry.Name()),
@ -132,7 +134,25 @@ func inferModelType(fileName string) string {
}
}
func BuildModelStatusBoard(standardModels []storage.StandardModelRecord, devices []*models.Device, installed map[string][]InstalledModelStatus) ModelStatusBoard {
// ModelTypeCapability maps a model type to the device capability key it requires.
// Returns empty string if the model type has no specific capability requirement.
// Keys must match what safesight-edge agent reports via GET /v1/capabilities.
func ModelTypeCapability(modelType string) string {
switch modelType {
case "face_detection":
return "face"
case "face_recognition":
return "face"
case "shoe_detection":
return "shoe"
case "ppe_detection":
return "helmet"
default:
return ""
}
}
func BuildModelStatusBoard(standardModels []storage.StandardModelRecord, devices []*models.Device, installed map[string][]InstalledModelStatus, deviceCapabilities map[string]map[string]bool) ModelStatusBoard {
board := ModelStatusBoard{
Summary: ModelStatusSummary{
StandardModels: len(standardModels),
@ -177,7 +197,19 @@ func BuildModelStatusBoard(standardModels []storage.StandardModelRecord, devices
hasMismatch = true
}
} else {
hasMissing = true
// Check if device is incompatible with this model type
if deviceCapabilities != nil {
caps, hasCaps := deviceCapabilities[device.DeviceID]
if hasCaps {
reqCap := ModelTypeCapability(model.ModelType)
if reqCap != "" && !caps[reqCap] {
cell.Status = "na"
}
}
}
if cell.Status == "missing" {
hasMissing = true
}
}
if cell.Status == "missing" {
hasMissing = true
@ -194,6 +226,7 @@ func BuildModelStatusBoard(standardModels []storage.StandardModelRecord, devices
return row.ExtraModels[i].Name < row.ExtraModels[j].Name
})
row.ExtraModelCount = len(row.ExtraModels)
row.NeedsSync = hasMissing || hasMismatch
switch {
case hasMismatch:
board.Summary.MismatchDevices++
@ -229,3 +262,13 @@ func FetchInstalledModelStatuses(agent *AgentClient, device *models.Device) ([]I
}
return resp.Models, nil
}
// fileVersion generates a version string from the file's modification time.
// Format: YYYYMMDD-HHMMSS, e.g. "20260720-143052".
func fileVersion(path string) string {
info, err := os.Stat(path)
if err != nil {
return time.Now().Format("20060102") + "-v1"
}
return info.ModTime().Format("20060102-150405")
}

View File

@ -58,7 +58,7 @@ func TestBuildModelStatusBoardMarksMissingAndMismatch(t *testing.T) {
},
}
board := BuildModelStatusBoard(modelsList, devices, installed)
board := BuildModelStatusBoard(modelsList, devices, installed, nil)
if board.Summary.MissingDevices != 1 {
t.Fatalf("expected one device with missing models, got %#v", board.Summary)
@ -86,7 +86,7 @@ func TestBuildModelStatusBoardSeparatesExtraModels(t *testing.T) {
},
}
board := BuildModelStatusBoard(modelsList, devices, installed)
board := BuildModelStatusBoard(modelsList, devices, installed, nil)
if len(board.Rows) != 1 {
t.Fatalf("unexpected board rows: %#v", board.Rows)

View File

@ -59,6 +59,27 @@ func NewResourceManagementService(repo *storage.ResourcesRepo) *ResourceManageme
return &ResourceManagementService{repo: repo}
}
// RegisterResource hashes a single file and saves it to the standard_resources table.
// This is used when a resource is built/updated at runtime (e.g. face gallery build).
func (s *ResourceManagementService) RegisterResource(filePath, resourceType, name string) error {
if s == nil || s.repo == nil {
return fmt.Errorf("resources repo is not configured")
}
sum, size, err := hashFile(filePath)
if err != nil {
return err
}
record := storage.StandardResourceRecord{
Name: name,
ResourceType: resourceType,
Version: fileVersion(filePath),
SHA256: sum,
SizeBytes: size,
FilePath: filePath,
}
return s.repo.Save(record)
}
func (s *ResourceManagementService) SyncStandardResourcesFromDirectory(dir string) error {
if s == nil || s.repo == nil {
return fmt.Errorf("resources repo is not configured")
@ -95,7 +116,7 @@ func (s *ResourceManagementService) SyncStandardResourcesFromDirectory(dir strin
record := storage.StandardResourceRecord{
Name: strings.TrimSuffix(sub.Name(), filepath.Ext(sub.Name())),
ResourceType: resourceType,
Version: "auto",
Version: fileVersion(fullPath),
SHA256: sum,
SizeBytes: size,
FilePath: fullPath,
@ -114,7 +135,7 @@ func (s *ResourceManagementService) SyncStandardResourcesFromDirectory(dir strin
}
record := storage.StandardResourceRecord{
Name: strings.TrimSuffix(entry.Name(), filepath.Ext(entry.Name())),
Version: "auto",
Version: fileVersion(fullPath),
SHA256: sum,
SizeBytes: size,
FilePath: fullPath,

View File

@ -2,6 +2,8 @@ package web
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"html/template"
@ -9,6 +11,7 @@ import (
"mime"
"mime/multipart"
"io/fs"
"log"
"net/http"
"net/url"
"os"
@ -114,6 +117,7 @@ type PageData struct {
WizardVideoSourcesJSON template.HTML
ConsoleUnassignedUnits []service.RecognitionUnitAsset
FaceGalleryPersons []storage.PersonRecord
FaceGalleryQuality map[string]string
Templates []service.Template
Template *service.Template
AssetTab string
@ -812,6 +816,7 @@ func (u *UI) Routes() (chi.Router, error) {
r.Get("/templates", u.pageTemplates)
r.Get("/templates/{name}", u.pageTemplate)
r.Get("/models", u.pageModels)
r.Post("/models/upload", u.actionModelUpload)
r.Post("/models/sync", u.actionModelSync)
r.Post("/resources/sync", u.actionResourceSync)
r.Get("/diagnostics", u.pageDiagnostics)
@ -828,6 +833,7 @@ func (u *UI) Routes() (chi.Router, error) {
r.Post("/face-gallery/rename", u.actionFaceGalleryRename)
r.Post("/face-gallery/add-photo", u.actionFaceGalleryAddPhoto)
r.Post("/face-gallery/delete-photo", u.actionFaceGalleryDeletePhoto)
r.Post("/face-gallery/sync", u.actionFaceGallerySync)
r.Get("/monitor", u.pageMonitor)
r.Get("/hls/*", u.proxyHLS)
r.Get("/api/monitor/channels", u.apiMonitorChannels)
@ -2141,12 +2147,13 @@ func (u *UI) pageModels(w http.ResponseWriter, r *http.Request) {
}
}
board := service.ModelStatusBoard{}
var installed map[string][]service.InstalledModelStatus
if strings.TrimSpace(u.dbPath) != "" {
if store, err := storage.OpenSQLite(u.dbPath); err == nil {
modelsRepo := storage.NewModelsRepo(store.DB())
if items, err := modelsRepo.List(); err == nil {
data.StandardModels = items
installed := map[string][]service.InstalledModelStatus{}
installed = map[string][]service.InstalledModelStatus{}
for _, device := range data.Devices {
if device == nil || !device.Online {
continue
@ -2156,11 +2163,47 @@ func (u *UI) pageModels(w http.ResponseWriter, r *http.Request) {
installed[device.DeviceID] = items
}
}
board = service.BuildModelStatusBoard(items, data.Devices, installed)
board = service.BuildModelStatusBoard(items, data.Devices, installed, nil)
}
_ = store.Close()
}
}
// Query device capabilities for compatibility matrix.
// Devices without a required capability show "N/A" instead of "缺失".
if u.agent != nil && data.StandardModels != nil {
deviceCapabilities := map[string]map[string]bool{}
for _, dev := range data.Devices {
if dev == nil || !dev.Online {
continue
}
body, code, err := u.agent.Do("GET", dev.IP, dev.AgentPort, "/v1/capabilities", nil)
if err != nil || code != 200 {
continue
}
var capsResp struct {
Capabilities []struct {
Key string `json:"key"`
Available bool `json:"available"`
} `json:"capabilities"`
}
if json.Unmarshal(body, &capsResp) != nil {
continue
}
caps := map[string]bool{}
for _, c := range capsResp.Capabilities {
if c.Available {
caps[c.Key] = true
}
}
deviceCapabilities[dev.DeviceID] = caps
}
if len(deviceCapabilities) > 0 {
if installed == nil {
installed = map[string][]service.InstalledModelStatus{}
}
board = service.BuildModelStatusBoard(data.StandardModels, data.Devices, installed, deviceCapabilities)
}
}
data.ModelStatusBoard = &board
u.render(w, r, "models", data)
}
@ -2201,6 +2244,96 @@ func (u *UI) actionModelSync(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/ui/tasks/"+url.PathEscape(task.ID), http.StatusFound)
}
func (u *UI) actionModelUpload(w http.ResponseWriter, r *http.Request) {
if err := r.ParseMultipartForm(100 << 20); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
name := strings.TrimSpace(r.FormValue("name"))
modelType := strings.TrimSpace(r.FormValue("model_type"))
version := strings.TrimSpace(r.FormValue("version"))
description := strings.TrimSpace(r.FormValue("description"))
autoSync := r.FormValue("auto_sync") == "1"
if name == "" {
http.Error(w, "name is required", http.StatusBadRequest)
return
}
if modelType == "" {
modelType = "other"
}
file, hdr, err := r.FormFile("file")
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
defer file.Close()
if !strings.HasSuffix(strings.ToLower(hdr.Filename), ".rknn") {
http.Error(w, "only .rknn files are supported", http.StatusBadRequest)
return
}
modelsDir := filepath.Join("models", "standard_models")
if err := os.MkdirAll(modelsDir, 0o755); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
dstPath := filepath.Join(modelsDir, hdr.Filename)
dst, err := os.Create(dstPath)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
defer dst.Close()
hasher := sha256.New()
writer := io.MultiWriter(dst, hasher)
size, err := io.Copy(writer, file)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
sha256sum := hex.EncodeToString(hasher.Sum(nil))
if version == "" {
version = time.Now().Format("20060102-150405")
}
if strings.TrimSpace(u.dbPath) != "" {
if store, err := storage.OpenSQLite(u.dbPath); err == nil {
modelsRepo := storage.NewModelsRepo(store.DB())
modelsRepo.Save(storage.StandardModelRecord{
Name: name, FileName: hdr.Filename,
Version: version, SHA256: sha256sum,
SizeBytes: size, ModelType: modelType,
Description: description,
})
store.Close()
}
}
if autoSync {
u.ensureDevicesLoaded()
deviceIDs := make([]string, 0)
for _, dev := range u.registry.GetDevices() {
if dev != nil && dev.Online {
deviceIDs = append(deviceIDs, dev.DeviceID)
}
}
if len(deviceIDs) > 0 {
payload := map[string]any{"model_name": name}
task, _ := u.tasks.CreateTask("model_sync_all", deviceIDs, payload)
http.Redirect(w, r, "/ui/tasks/"+url.PathEscape(task.ID), http.StatusFound)
return
}
}
http.Redirect(w, r, "/ui/models?msg="+url.QueryEscape("模型 "+name+" 上传成功"), http.StatusFound)
}
func (u *UI) pageDiagnostics(w http.ResponseWriter, r *http.Request) {
u.ensureDevicesLoaded()
data := PageData{Title: "日志审计", Devices: u.registry.GetDevices()}
@ -3183,6 +3316,32 @@ func (u *UI) pageDeviceAssignments(w http.ResponseWriter, r *http.Request) {
data.MaxUnitsPerDevice = maxUnits
if u.preview != nil {
if board, err := u.preview.BuildDeviceAssignmentBoard(data.Devices, maxUnits); err == nil {
// Enrich board cards with config sync status.
if u.agent != nil && u.configVersionsRepo != nil {
for i, card := range board.Cards {
versions, _ := u.configVersionsRepo.List(card.DeviceID)
if len(versions) == 0 {
continue
}
expectedSHA := versions[0].ConfigID
for _, dev := range data.Devices {
if dev == nil || dev.DeviceID != card.DeviceID || !dev.Online {
continue
}
body, code, err := u.agent.Do("GET", dev.IP, dev.AgentPort, "/v1/config/status", nil)
if err != nil || code != 200 {
continue
}
var statusResp struct {
SHA256 string `json:"sha256"`
}
if json.Unmarshal(body, &statusResp) == nil {
board.Cards[i].ConfigSynced = strings.EqualFold(statusResp.SHA256, expectedSHA)
}
break
}
}
}
data.DeviceAssignmentBoard = board
if payload, marshalErr := json.Marshal(board); marshalErr == nil {
data.DeviceAssignmentBoardJSON = template.JS(payload)
@ -5056,24 +5215,96 @@ func (u *UI) actionFaceGalleryBuild(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/ui/face-gallery?msg="+url.QueryEscape(msg), http.StatusFound)
return
}
// Parse key stats from output for a clean message
// Parse build report for quality diagnostics.
// Output sections: per person used samples, failure reasons, persons skipped.
persons := "0"
photos := "0"
photosOK := "0"
photosFailed := "0"
failureReasons := map[string]string{}
perPerson := map[string]string{}
currentSection := ""
for _, line := range strings.Split(output, "\n") {
if strings.HasPrefix(line, "enrolled_persons:") {
line = strings.TrimSpace(line)
switch {
case strings.HasPrefix(line, "enrolled_persons:"):
persons = strings.TrimSpace(strings.TrimPrefix(line, "enrolled_persons:"))
}
if strings.HasPrefix(line, "ok_images:") {
photos = strings.TrimSpace(strings.TrimPrefix(line, "ok_images:"))
case strings.HasPrefix(line, "ok_images:"):
photosOK = strings.TrimSpace(strings.TrimPrefix(line, "ok_images:"))
case strings.HasPrefix(line, "failed_images:"):
photosFailed = strings.TrimSpace(strings.TrimPrefix(line, "failed_images:"))
case strings.HasPrefix(line, "-- per person"):
currentSection = "per_person"
case strings.HasPrefix(line, "-- failure reasons"):
currentSection = "failures"
case strings.HasPrefix(line, "-- persons skipped"):
currentSection = "skipped"
case strings.HasPrefix(line, "-- db selfcheck"):
currentSection = ""
default:
if currentSection == "per_person" && strings.Contains(line, ":") {
parts := strings.SplitN(line, ":", 2)
if len(parts) == 2 {
perPerson[strings.TrimSpace(parts[0])] = strings.TrimSpace(parts[1])
}
} else if currentSection == "failures" && strings.Contains(line, ":") {
parts := strings.SplitN(line, ":", 2)
if len(parts) == 2 {
failureReasons[strings.TrimSpace(parts[0])] = strings.TrimSpace(parts[1])
}
}
}
}
msg := fmt.Sprintf("生成成功,共 %s 人,%s 张照片", persons, photos)
// Register the built face_gallery.db as a standard resource so the
// Resource sync system can detect and distribute it to edge devices.
if u.resourcesRepo != nil {
resourceSvc := service.NewResourceManagementService(u.resourcesRepo)
if err := resourceSvc.RegisterResource(builder.OutputDB, "face_gallery", "face_gallery"); err != nil {
log.Printf("register face_gallery resource: %v", err)
}
}
// Build result message with quality diagnostics.
reasonCN := map[string]string{
"no_face": "无人脸", "small_face": "人脸太小", "align_fail": "对齐失败",
"infer_fail": "识别失败", "read_fail": "读取失败", "det_fail": "检测失败",
}
var reasonParts []string
for k, v := range failureReasons {
label := k
if cn, ok := reasonCN[k]; ok {
label = cn
}
reasonParts = append(reasonParts, fmt.Sprintf("%s×%s", label, v))
}
msg := fmt.Sprintf("生成成功,共 %s 人,%s 张通过", persons, photosOK)
if photosFailed != "0" && photosFailed != "" {
msg += fmt.Sprintf("%s 张失败", photosFailed)
if len(reasonParts) > 0 {
msg += fmt.Sprintf("%s", strings.Join(reasonParts, "、"))
}
}
// Write per-person quality stats for the face gallery page to display.
if len(perPerson) > 0 {
metaPath := filepath.Join("resources", "standard_resources", "face_gallery", "build_meta.json")
if metaJSON, err := json.Marshal(perPerson); err == nil {
os.WriteFile(metaPath, metaJSON, 0o644)
}
}
http.Redirect(w, r, "/ui/face-gallery?msg="+url.QueryEscape(msg), http.StatusFound)
}
func (u *UI) pageFaceGallery(w http.ResponseWriter, r *http.Request) {
data := PageData{Title: "人脸库"}
data.Message = r.URL.Query().Get("msg")
// Load build quality stats if available.
metaPath := filepath.Join("resources", "standard_resources", "face_gallery", "build_meta.json")
if metaBytes, err := os.ReadFile(metaPath); err == nil {
var meta map[string]string
if json.Unmarshal(metaBytes, &meta) == nil {
data.FaceGalleryQuality = meta
}
}
if strings.TrimSpace(u.dbPath) != "" {
if repo := storage.NewFaceGalleryRepo(u.dbPath); repo != nil {
if persons, err := repo.ListPersons(); err == nil {
@ -5081,9 +5312,52 @@ func (u *UI) pageFaceGallery(w http.ResponseWriter, r *http.Request) {
}
}
}
// Load face_gallery resource status board for device sync section
u.ensureDevicesLoaded()
data.Devices = u.registry.GetDevices()
if u.resourcesRepo != nil {
allResources, _ := u.resourcesRepo.List()
faceResources := make([]storage.StandardResourceRecord, 0)
for _, r := range allResources {
if r.ResourceType == "face_gallery" {
faceResources = append(faceResources, r)
}
}
if len(faceResources) > 0 {
data.StandardResources = faceResources
board := service.FetchAndBuildResourceBoard(u.agent, data.Devices, faceResources)
data.ResourceStatusBoard = &board
}
}
u.render(w, r, "face_gallery", data)
}
func (u *UI) actionFaceGallerySync(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
deviceIDs := make([]string, 0)
for _, id := range r.Form["device_id"] {
id = strings.TrimSpace(id)
if id != "" {
deviceIDs = append(deviceIDs, id)
}
}
if len(deviceIDs) == 0 {
http.Error(w, "missing device_id", http.StatusBadRequest)
return
}
payload := map[string]any{"resource_name": "face_gallery"}
task, err := u.tasks.CreateTask("resource_sync_all", deviceIDs, payload)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/ui/tasks/"+url.PathEscape(task.ID), http.StatusFound)
}
func (u *UI) actionFaceGalleryDelete(w http.ResponseWriter, r *http.Request) {
id, _ := strconv.Atoi(r.FormValue("id"))
if id <= 0 {

View File

@ -250,6 +250,7 @@ tbody tr[data-nav-row]{cursor:pointer}
.pill.bad{background:var(--danger-soft);border-color:var(--danger-soft-hover);color:var(--danger-soft-text)}
.pill.run{background:var(--surface-soft);border-color:var(--border-strong);color:var(--primary)}
.pill.warn{background:var(--surface-soft);border-color:var(--border-strong);color:var(--amber)}
.pill.muted-pill{background:var(--surface-soft);border-color:var(--border);color:var(--muted)}
.actions{display:flex;flex-wrap:wrap;gap:8px}
.actions.compact{gap:6px}
@ -410,6 +411,9 @@ pre{margin-top:12px;padding:12px;border-radius:var(--radius);border:1px solid va
.assignment-device-head .mono{display:block;margin-top:4px;font-size:11px;color:var(--muted)}
.assignment-device-metrics{display:flex;align-items:center;gap:8px}
.assignment-device-metrics strong{font-size:12px;font-weight:500;color:var(--text)}
.config-sync-indicator{font-size:11px;padding:2px 0 4px}
.config-sync-indicator.synced{color:var(--green)}
.config-sync-indicator.pending{color:var(--amber)}
.assignment-chip-list{display:flex;flex-wrap:wrap;gap:6px;min-height:24px}
.assignment-chip{display:inline-flex;align-items:center;gap:5px;padding:4px 7px;border:1px solid var(--border);border-radius:var(--radius);background:var(--surface)}
.assignment-chip-unassigned{background:var(--surface-strong)}

View File

@ -290,6 +290,9 @@
<span class="pill">${card.status}</span>
</div>
</div>
<div class="config-sync-indicator ${card.config_synced ? 'synced' : 'pending'}">
${card.config_synced ? '✅ 配置已同步' : '⚠ 配置待更新'}
</div>
<div class="assignment-chip-list"></div>
${addControls}
`;

View File

@ -13,14 +13,15 @@
<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 style="display:grid;grid-template-columns:repeat(auto-fill,minmax(160px,1fr));gap:16px;margin-top:12px">
<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" style="padding:8px;max-width:200px">
<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}}" />
@ -33,7 +34,7 @@
{{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></div>
<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}}')">
@ -50,6 +51,58 @@
{{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){
@ -97,6 +150,18 @@ function delPhoto(id){
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>

View File

@ -7,9 +7,9 @@
<div class="form-hint">统一维护标准模型目录,设备页只看当前生效状态,更新动作统一走任务。</div>
</div>
<div class="actions compact">
<button class="btn secondary" type="button">上传新模型</button>
<button class="btn secondary" type="button" onclick="showUpload()">上传新模型</button>
<form method="post" action="/ui/models/sync">
{{range .Devices}}{{if .Online}}<input type="hidden" name="device_id" value="{{.DeviceID}}">{{end}}{{end}}
{{if $board}}{{range $board.Rows}}{{if .NeedsSync}}<input type="hidden" name="device_id" value="{{.DeviceID}}">{{end}}{{end}}{{end}}
<input type="hidden" name="action" value="model_sync_all">
<button class="btn" type="submit">更新全部模型</button>
</form>
@ -93,6 +93,7 @@
<td>
{{if eq .Status "ok"}}<span class="pill ok">一致</span>
{{else if eq .Status "mismatch"}}<span class="pill warn">不一致</span>
{{else if eq .Status "na"}}<span class="pill muted-pill">不适用</span>
{{else}}<span class="pill bad">缺失</span>{{end}}
</td>
{{end}}
@ -119,4 +120,45 @@
</table>
</div>
</div>
<div id="modal" class="modal-overlay" style="display:none">
<div class="card" style="max-width:480px;width:90%;position:relative">
<span class="btn-icon ghost" style="position:absolute;top:8px;right:8px" onclick="closeModal()">&times;</span>
<div id="modal-body"></div>
</div>
</div>
<script>
function showUpload(){
document.getElementById('modal-body').innerHTML=
'<h3>上传新模型</h3>'+
'<form method="post" action="/ui/models/upload" enctype="multipart/form-data">'+
'<div class="field-grid">'+
'<label><span>模型名称</span><input type="text" name="name" required placeholder="如 face_det_v2" /></label>'+
'<label><span>模型类型</span><select name="model_type">'+
'<option value="face_detection">人脸检测</option>'+
'<option value="face_recognition">人脸识别</option>'+
'<option value="ppe_detection">PPE检测</option>'+
'<option value="shoe_detection">工鞋检测</option>'+
'<option value="object_detection">通用检测</option>'+
'<option value="other">其他</option>'+
'</select></label>'+
'<label><span>版本号 <span class="muted small">(可选,留空自动生成)</span></span><input type="text" name="version" placeholder="自动生成" /></label>'+
'<label><span>描述 <span class="muted small">(可选)</span></span><input type="text" name="description" placeholder="" /></label>'+
'<label><span>模型文件 (.rknn)</span><input type="file" name="file" accept=".rknn" required /></label>'+
'</div>'+
'<label style="display:flex;align-items:center;gap:8px;margin:12px 0">'+
'<input type="checkbox" name="auto_sync" value="1" checked />'+
'<span class="small">上传后自动分发到所有在线设备</span>'+
'</label>'+
'<div class="actions"><button class="btn" type="submit">上传</button></div>'+
'</form>';
document.getElementById('modal').style.display='flex';
}
function closeModal(){document.getElementById('modal').style.display='none';}
</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}}