376 lines
14 KiB
HTML
376 lines
14 KiB
HTML
{{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="/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}}
|