safesight-control/internal/web/ui/templates/device_assignments.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

376 lines
14 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 "device_assignments"}}
<div class="card assignment-board-page">
<div class="section-title">
<div>
<h2 class="title-with-icon">{{icon "devices"}}<span>通道部署</span></h2>
</div>
</div>
{{if .DeviceAssignmentBoard}}
<div class="assignment-kpis">
<div class="assignment-kpi">
<span>检测通道</span>
<strong>{{.DeviceAssignmentBoard.Stats.TotalUnits}}</strong>
</div>
<div class="assignment-kpi">
<span>设备</span>
<strong>{{.DeviceAssignmentBoard.Stats.TotalDevices}}</strong>
</div>
<div class="assignment-kpi">
<span>已分配</span>
<strong>{{.DeviceAssignmentBoard.Stats.AssignedUnits}}</strong>
</div>
<div class="assignment-kpi">
<span>未分配</span>
<strong>{{.DeviceAssignmentBoard.Stats.UnassignedUnits}}</strong>
</div>
<div class="assignment-kpi">
<span>平均负载</span>
<strong>{{printf "%.1f" .DeviceAssignmentBoard.Stats.AverageLoad}}</strong>
</div>
<div class="assignment-kpi">
<span>满载设备</span>
<strong>{{.DeviceAssignmentBoard.Stats.OverloadedDevices}}</strong>
</div>
</div>
<form method="post" action="/ui/device-assignments" id="assignment-board-form">
<input type="hidden" name="board_state_json" id="assignment-board-state" value="" />
<div class="assignment-action-bar">
<label class="assignment-slider">
<span>每台最多</span>
<input type="range" min="1" max="8" step="1" value="{{.MaxUnitsPerDevice}}" id="max-units-range" name="max_units_per_device" />
<strong id="max-units-value">{{.MaxUnitsPerDevice}} 路/台</strong>
</label>
<div class="actions compact">
<button type="button" class="btn secondary" id="auto-assign-btn">自动平均分配</button>
<button type="button" class="btn secondary" id="clear-assign-btn">清空分配</button>
<button type="submit">保存通道部署</button>
</div>
</div>
<div id="assignment-feedback" class="form-hint"></div>
<div class="assignment-board-grid" id="assignment-cards">
{{range .DeviceAssignmentBoard.Cards}}
<section class="assignment-device-card state-{{.Status}}" data-device-card="{{.DeviceID}}">
<div class="assignment-device-head">
<div>
<h3>{{.DeviceName}}</h3>
<span class="mono">{{.DeviceID}}</span>
</div>
<div class="assignment-device-metrics">
<strong>{{.AssignedCount}} / {{.MaxUnits}}</strong>
<span class="pill">{{.Status}}</span>
</div>
</div>
<div class="assignment-chip-list" data-assigned-list="{{.DeviceID}}">
{{range .Units}}
<span class="assignment-chip" data-unit-ref="{{.Ref}}">
<span class="assignment-chip-text">{{if .DisplayName}}{{.DisplayName}}{{else}}{{.Name}}{{end}}</span>
<button type="button" class="assignment-chip-remove" data-remove-unit="{{.Ref}}">×</button>
</span>
{{end}}
</div>
{{if lt .AssignedCount .MaxUnits}}
<div class="assignment-device-add">
<select data-add-select="{{.DeviceID}}">
<option value="">添加检测通道</option>
</select>
<button type="button" class="btn secondary" data-add-button="{{.DeviceID}}">加入</button>
</div>
{{end}}
</section>
{{end}}
</div>
<section class="assignment-unassigned">
<div class="section-title compact">
<div>
<h3>未部署检测通道</h3>
</div>
</div>
<div class="assignment-chip-list" id="assignment-unassigned-list">
{{range .DeviceAssignmentBoard.Unassigned}}
<span class="assignment-chip assignment-chip-unassigned" data-unit-ref="{{.Ref}}">
<span class="assignment-chip-text">{{if .DisplayName}}{{.DisplayName}}{{else}}{{.Name}}{{end}}</span>
</span>
{{else}}
<div class="empty-state compact"><div class="empty-title">已全部分配</div></div>
{{end}}
</div>
</section>
</form>
<script type="application/json" id="assignment-board-seed">{{.DeviceAssignmentBoardJSON}}</script>
<script>
(() => {
const seedEl = document.getElementById('assignment-board-seed');
if (!seedEl) return;
const seed = JSON.parse(seedEl.textContent || '{}');
const state = {
max: Number(seed.max_units_per_device || {{.MaxUnitsPerDevice}}),
units: {},
devices: {},
meta: {}
};
(seed.cards || []).forEach(card => {
state.meta[card.device_id] = {
device_id: card.device_id,
device_name: card.device_name,
max_units: card.max_units || state.max
};
state.devices[card.device_id] = (card.units || []).map(unit => {
state.units[unit.ref] = unit;
return unit.ref;
});
});
(seed.unassigned || []).forEach(unit => {
state.units[unit.ref] = unit;
});
const maxRange = document.getElementById('max-units-range');
const maxValue = document.getElementById('max-units-value');
const feedback = document.getElementById('assignment-feedback');
const hiddenState = document.getElementById('assignment-board-state');
const cardsContainer = document.getElementById('assignment-cards');
const unassignedContainer = document.getElementById('assignment-unassigned-list');
const form = document.getElementById('assignment-board-form');
function unitLabel(unit) {
return unit.display_name || unit.name;
}
function statusFor(count) {
if (count <= 0) return 'idle';
if (count >= state.max) return 'full';
if (count * 2 >= state.max) return 'busy';
return 'low';
}
function statusRank(status) {
return { full: 0, busy: 1, low: 2, idle: 3 }[status] ?? 4;
}
function allRefs() {
return Object.keys(state.units).sort((a, b) => {
const ua = state.units[a];
const ub = state.units[b];
if ((ua.scene_template_name || '') !== (ub.scene_template_name || '')) {
return (ua.scene_template_name || '').localeCompare(ub.scene_template_name || '');
}
return (ua.name || '').localeCompare(ub.name || '');
});
}
function assignedRefSet() {
const seen = new Set();
Object.values(state.devices).forEach(refs => refs.forEach(ref => seen.add(ref)));
return seen;
}
function unassignedRefs() {
const assigned = assignedRefSet();
return allRefs().filter(ref => !assigned.has(ref));
}
function currentProfile(deviceID) {
const refs = state.devices[deviceID] || [];
if (!refs.length) return '';
const unit = state.units[refs[0]];
return unit ? (unit.scene_template_name || '') : '';
}
function moveToUnassigned(ref) {
Object.keys(state.devices).forEach(deviceID => {
state.devices[deviceID] = (state.devices[deviceID] || []).filter(item => item !== ref);
});
}
function addToDevice(deviceID, ref) {
if (!deviceID || !ref) return;
moveToUnassigned(ref);
if (!state.devices[deviceID]) state.devices[deviceID] = [];
state.devices[deviceID].push(ref);
}
function clearAssignments() {
Object.keys(state.devices).forEach(deviceID => {
state.devices[deviceID] = [];
});
}
function autoAssign() {
clearAssignments();
const deviceIDs = Object.keys(state.devices).sort();
const units = allRefs().map(ref => state.units[ref]);
units.forEach(unit => {
let target = null;
deviceIDs.forEach(deviceID => {
const refs = state.devices[deviceID] || [];
const profile = currentProfile(deviceID);
if (profile && profile !== unit.scene_template_name) return;
if (refs.length >= state.max) return;
if (!target || refs.length < (state.devices[target] || []).length) {
target = deviceID;
}
});
if (target) {
state.devices[target].push(unit.ref);
}
});
const remaining = unassignedRefs().length;
feedback.textContent = `已按每台最多 ${state.max} 路完成自动分配${remaining > 0 ? `,剩余 ${remaining} 路未分配。` : '。'}`;
render();
}
function syncHiddenState() {
hiddenState.value = JSON.stringify({ devices: state.devices });
}
function renderStats() {
const assigned = assignedRefSet();
const totalUnits = allRefs().length;
const totalDevices = Object.keys(state.devices).length;
const average = totalDevices ? (assigned.size / totalDevices).toFixed(1) : '0.0';
const full = Object.keys(state.devices).filter(deviceID => statusFor((state.devices[deviceID] || []).length) === 'full').length;
const values = document.querySelectorAll('.assignment-kpi strong');
if (values.length >= 6) {
values[0].textContent = totalUnits;
values[1].textContent = totalDevices;
values[2].textContent = assigned.size;
values[3].textContent = totalUnits - assigned.size;
values[4].textContent = average;
values[5].textContent = full;
}
}
function renderCards() {
const cards = Object.keys(state.devices).map(deviceID => {
const refs = state.devices[deviceID] || [];
return {
deviceID,
deviceName: (state.meta[deviceID] && state.meta[deviceID].device_name) || deviceID,
refs,
status: statusFor(refs.length)
};
}).sort((a, b) => {
const rankDiff = statusRank(a.status) - statusRank(b.status);
if (rankDiff !== 0) return rankDiff;
if (b.refs.length !== a.refs.length) return b.refs.length - a.refs.length;
return a.deviceID.localeCompare(b.deviceID);
});
cardsContainer.innerHTML = '';
cards.forEach(card => {
const section = document.createElement('section');
section.className = `assignment-device-card state-${card.status}`;
const currentTemplate = currentProfile(card.deviceID);
const availableRefs = unassignedRefs().filter(ref => {
const unit = state.units[ref];
return !currentTemplate || unit.scene_template_name === currentTemplate;
});
const addControls = card.refs.length < state.max ? `
<div class="assignment-device-add">
<select data-add-select="${card.deviceID}">
<option value="">添加检测通道</option>
${availableRefs.map(ref => `<option value="${ref}">${unitLabel(state.units[ref])}</option>`).join('')}
</select>
<button type="button" class="btn secondary" data-add-button="${card.deviceID}">加入</button>
</div>
` : '';
section.innerHTML = `
<div class="assignment-device-head">
<div>
<h3>${card.deviceName}</h3>
<span class="mono">${card.deviceID}</span>
</div>
<div class="assignment-device-metrics">
<strong>${card.refs.length} / ${state.max}</strong>
<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}
`;
const chipList = section.querySelector('.assignment-chip-list');
card.refs.forEach(ref => {
const unit = state.units[ref];
const chip = document.createElement('span');
chip.className = 'assignment-chip';
chip.innerHTML = `<span class="assignment-chip-text">${unitLabel(unit)}</span><button type="button" class="assignment-chip-remove" data-remove-unit="${ref}">×</button>`;
chipList.appendChild(chip);
});
cardsContainer.appendChild(section);
});
}
function renderUnassigned() {
const refs = unassignedRefs();
if (!refs.length) {
unassignedContainer.innerHTML = '<div class="empty-state compact"><div class="empty-title">已全部分配</div></div>';
return;
}
unassignedContainer.innerHTML = '';
refs.forEach(ref => {
const unit = state.units[ref];
const chip = document.createElement('span');
chip.className = 'assignment-chip assignment-chip-unassigned';
chip.innerHTML = `<span class="assignment-chip-text">${unitLabel(unit)}</span>`;
unassignedContainer.appendChild(chip);
});
}
function render() {
maxValue.textContent = `${state.max} 路/台`;
renderStats();
renderCards();
renderUnassigned();
syncHiddenState();
}
maxRange.addEventListener('input', () => {
state.max = Number(maxRange.value || state.max);
render();
});
document.addEventListener('click', (event) => {
const remove = event.target.closest('[data-remove-unit]');
if (remove) {
moveToUnassigned(remove.getAttribute('data-remove-unit'));
feedback.textContent = '';
render();
return;
}
const add = event.target.closest('[data-add-button]');
if (add) {
const deviceID = add.getAttribute('data-add-button');
const select = document.querySelector(`[data-add-select="${deviceID}"]`);
if (select && select.value) {
addToDevice(deviceID, select.value);
feedback.textContent = '';
render();
}
return;
}
});
document.getElementById('auto-assign-btn').addEventListener('click', autoAssign);
document.getElementById('clear-assign-btn').addEventListener('click', () => {
clearAssignments();
feedback.textContent = '已清空当前页面中的通道部署。';
render();
});
form.addEventListener('submit', syncHiddenState);
render();
})();
</script>
{{else}}
<div class="empty-state compact"><div class="empty-title">暂无可用的通道部署数据</div></div>
{{end}}
</div>
{{end}}