safesight-control/internal/web/ui/templates/device_assignments.html

373 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="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}}