375 lines
13 KiB
JavaScript
375 lines
13 KiB
JavaScript
const MODE_LABELS = {
|
||
auto: 'Автономный',
|
||
manual: 'Ручной',
|
||
assist: 'Ассистированный',
|
||
standby: 'Ожидание',
|
||
};
|
||
|
||
const STATUS_LABELS = {
|
||
online: 'ONLINE',
|
||
idle: 'IDLE',
|
||
offline: 'OFFLINE',
|
||
emergency: 'E-STOP',
|
||
};
|
||
|
||
let currentUser = null;
|
||
let ROBOTS = [];
|
||
let selectedRobotId = null;
|
||
let saveTimer = null;
|
||
|
||
const $ = (sel) => document.querySelector(sel);
|
||
const $$ = (sel) => document.querySelectorAll(sel);
|
||
|
||
async function init() {
|
||
try {
|
||
currentUser = await API.me();
|
||
} catch {
|
||
window.location.href = '/login';
|
||
return;
|
||
}
|
||
|
||
$('#header-user').textContent = currentUser.email;
|
||
initNavigation();
|
||
initLogout();
|
||
await loadRobots();
|
||
initControls();
|
||
initProfile();
|
||
initFooterClock();
|
||
addLog('Система инициализирована', 'success');
|
||
}
|
||
|
||
async function loadRobots() {
|
||
const data = await API.getRobots();
|
||
ROBOTS = data.robots;
|
||
selectedRobotId = data.selected_robot_id || ROBOTS[0]?.id;
|
||
ROBOTS.forEach(applySettingsToRobot);
|
||
initRobotList();
|
||
selectRobot(selectedRobotId, { skipSave: true });
|
||
$('#robot-count').textContent = ROBOTS.length;
|
||
}
|
||
|
||
function applySettingsToRobot(robot) {
|
||
if (robot.emergency) {
|
||
robot.speed = 0;
|
||
robot.status = 'emergency';
|
||
robot.temperature = Math.round((robot.baseTemp || 30) + 5);
|
||
robot.sensorAccuracy = 0;
|
||
return;
|
||
}
|
||
if (robot.mode === 'standby') {
|
||
robot.speed = 0;
|
||
robot.status = 'idle';
|
||
robot.temperature = Math.round(robot.baseTemp || 30);
|
||
robot.sensorAccuracy = Math.round(robot.sensitivity * 0.5);
|
||
return;
|
||
}
|
||
const maxSpeed = robot.maxSpeed || 2;
|
||
robot.speed = +(maxSpeed * robot.speedSetting / 100).toFixed(1);
|
||
robot.sensorAccuracy = robot.sensitivity;
|
||
robot.temperature = Math.round(
|
||
(robot.baseTemp || 30) + robot.speedSetting * 0.12 + robot.sensitivity * 0.06
|
||
);
|
||
if (robot.battery < 15) {
|
||
robot.status = 'offline';
|
||
robot.speed = 0;
|
||
} else {
|
||
robot.status = 'online';
|
||
}
|
||
}
|
||
|
||
function readControlsToRobot(robot) {
|
||
robot.mode = $('#ctrl-mode').value;
|
||
robot.speedSetting = parseInt($('#ctrl-speed').value, 10);
|
||
robot.sensitivity = parseInt($('#ctrl-sensitivity').value, 10);
|
||
robot.emergency = $('#ctrl-emergency').checked;
|
||
robot.nightMode = $('#ctrl-night').checked;
|
||
}
|
||
|
||
async function persistRobots() {
|
||
await API.saveRobots({
|
||
robots: ROBOTS,
|
||
selected_robot_id: selectedRobotId,
|
||
});
|
||
}
|
||
|
||
function scheduleSave() {
|
||
clearTimeout(saveTimer);
|
||
saveTimer = setTimeout(async () => {
|
||
try {
|
||
await persistRobots();
|
||
} catch (err) {
|
||
showToast(err.message);
|
||
}
|
||
}, 500);
|
||
}
|
||
|
||
async function saveCurrentRobotSettings({ silent = false } = {}) {
|
||
const robot = ROBOTS.find((r) => r.id === selectedRobotId);
|
||
if (!robot) return;
|
||
readControlsToRobot(robot);
|
||
applySettingsToRobot(robot);
|
||
refreshRobotListItem(robot);
|
||
syncRobotUI(robot);
|
||
try {
|
||
await persistRobots();
|
||
if (!silent) {
|
||
addLog(`Настройки применены: ${robot.name}`, 'success');
|
||
showToast('Настройки сохранены');
|
||
}
|
||
} catch (err) {
|
||
showToast(err.message);
|
||
}
|
||
}
|
||
|
||
function initNavigation() {
|
||
$$('.nav__btn').forEach((btn) => {
|
||
btn.addEventListener('click', async () => {
|
||
const page = btn.dataset.page;
|
||
const activePage = $('.page--active')?.id;
|
||
|
||
if (activePage === 'page-dashboard') {
|
||
await saveCurrentRobotSettings({ silent: true });
|
||
}
|
||
if (activePage === 'page-profile') {
|
||
await loadProfile();
|
||
}
|
||
|
||
$$('.nav__btn').forEach((b) => b.classList.remove('nav__btn--active'));
|
||
btn.classList.add('nav__btn--active');
|
||
$$('.page').forEach((p) => p.classList.remove('page--active'));
|
||
$(`#page-${page}`).classList.add('page--active');
|
||
|
||
if (page === 'profile') await loadProfile();
|
||
if (page === 'dashboard') {
|
||
const robot = ROBOTS.find((r) => r.id === selectedRobotId);
|
||
if (robot) syncRobotUI(robot);
|
||
}
|
||
});
|
||
});
|
||
}
|
||
|
||
function initLogout() {
|
||
$('#btn-logout').addEventListener('click', async () => {
|
||
await API.logout();
|
||
window.location.href = '/login';
|
||
});
|
||
}
|
||
|
||
function initRobotList() {
|
||
const list = $('#robot-list');
|
||
list.innerHTML = ROBOTS.map((r) => renderRobotListItem(r)).join('');
|
||
list.querySelectorAll('.robot-item').forEach((item) => {
|
||
item.addEventListener('click', async () => {
|
||
if (item.dataset.id !== selectedRobotId) {
|
||
await saveCurrentRobotSettings({ silent: true });
|
||
}
|
||
selectRobot(item.dataset.id, { skipSave: true });
|
||
});
|
||
});
|
||
}
|
||
|
||
function renderRobotListItem(robot) {
|
||
const statusClass = robot.status === 'emergency' ? 'emergency' : robot.status;
|
||
return `
|
||
<div class="robot-item ${robot.id === selectedRobotId ? 'robot-item--active' : ''}" data-id="${robot.id}">
|
||
<span class="robot-item__num">${String(robot.number).padStart(3, '0')}</span>
|
||
<div class="robot-item__info">
|
||
<div class="robot-item__name">${robot.name}</div>
|
||
<div class="robot-item__type">${robot.type}</div>
|
||
<div class="robot-item__mode">${MODE_LABELS[robot.mode]} · ${robot.speed} м/с</div>
|
||
</div>
|
||
<span class="robot-item__indicator robot-item__indicator--${statusClass}"></span>
|
||
</div>`;
|
||
}
|
||
|
||
function refreshRobotListItem(robot) {
|
||
const item = $(`.robot-item[data-id="${robot.id}"]`);
|
||
if (!item) return;
|
||
const wrapper = document.createElement('div');
|
||
wrapper.innerHTML = renderRobotListItem(robot);
|
||
const newItem = wrapper.firstElementChild;
|
||
item.replaceWith(newItem);
|
||
newItem.addEventListener('click', async () => {
|
||
if (newItem.dataset.id !== selectedRobotId) {
|
||
await saveCurrentRobotSettings({ silent: true });
|
||
}
|
||
selectRobot(newItem.dataset.id, { skipSave: true });
|
||
});
|
||
}
|
||
|
||
function selectRobot(id, { skipSave = false } = {}) {
|
||
selectedRobotId = id;
|
||
$$('.robot-item').forEach((item) => {
|
||
item.classList.toggle('robot-item--active', item.dataset.id === id);
|
||
});
|
||
const robot = ROBOTS.find((r) => r.id === id);
|
||
if (!robot) return;
|
||
loadControls(robot);
|
||
syncRobotUI(robot);
|
||
if (!skipSave) scheduleSave();
|
||
addLog(`Выбран робот ${robot.name}`, 'info');
|
||
}
|
||
|
||
function syncRobotUI(robot) {
|
||
$('#selected-robot-name').textContent = robot.name;
|
||
$('#robot-visual-id').textContent = `№ ${String(robot.number).padStart(3, '0')}`;
|
||
$('#robot-mode-label').textContent = MODE_LABELS[robot.mode];
|
||
|
||
const badge = $('#robot-status-badge');
|
||
badge.textContent = STATUS_LABELS[robot.status] || robot.status.toUpperCase();
|
||
badge.className = 'badge badge--status';
|
||
if (['offline', 'emergency', 'idle'].includes(robot.status)) {
|
||
badge.classList.add(robot.status);
|
||
}
|
||
|
||
const frame = $('#robot-visual-frame');
|
||
frame.className = 'robot-visual';
|
||
if (robot.nightMode) frame.classList.add('robot-visual--night');
|
||
if (robot.emergency) frame.classList.add('robot-visual--emergency');
|
||
if (robot.mode === 'standby' && !robot.emergency) frame.classList.add('robot-visual--standby');
|
||
|
||
renderStats(robot);
|
||
}
|
||
|
||
function renderStats(robot) {
|
||
const maxSpeed = robot.maxSpeed || 2;
|
||
const stats = [
|
||
{ label: 'АКБ', value: `${robot.battery}%`, percent: robot.battery },
|
||
{ label: 'Скорость', value: `${robot.speed} м/с`, percent: robot.speed / maxSpeed * 100 },
|
||
{ label: 'Нагрузка', value: `${robot.payload} кг`, percent: robot.payload / 60 * 100 },
|
||
{ label: 'Температура', value: `${robot.temperature}°C`, percent: robot.temperature / 80 * 100 },
|
||
{ label: 'Датчики', value: `${robot.sensorAccuracy ?? robot.sensitivity}%`, percent: robot.sensorAccuracy ?? robot.sensitivity },
|
||
{ label: 'Режим', value: MODE_LABELS[robot.mode], percent: robot.speedSetting },
|
||
];
|
||
$('#stats-grid').innerHTML = stats.map((s) => `
|
||
<div class="stat-card">
|
||
<div class="stat-card__label">${s.label}</div>
|
||
<div class="stat-card__value">${s.value}</div>
|
||
<div class="stat-card__bar"><div class="stat-card__bar-fill" style="width:${Math.min(s.percent, 100)}%"></div></div>
|
||
</div>`).join('');
|
||
}
|
||
|
||
function loadControls(robot) {
|
||
$('#ctrl-mode').value = robot.mode;
|
||
$('#ctrl-speed').value = robot.speedSetting;
|
||
$('#speed-value').textContent = robot.speedSetting;
|
||
$('#ctrl-sensitivity').value = robot.sensitivity;
|
||
$('#sensitivity-value').textContent = robot.sensitivity;
|
||
$('#ctrl-emergency').checked = robot.emergency;
|
||
$('#ctrl-night').checked = robot.nightMode;
|
||
}
|
||
|
||
function initControls() {
|
||
const onChange = () => {
|
||
const robot = ROBOTS.find((r) => r.id === selectedRobotId);
|
||
if (!robot) return;
|
||
readControlsToRobot(robot);
|
||
applySettingsToRobot(robot);
|
||
syncRobotUI(robot);
|
||
refreshRobotListItem(robot);
|
||
scheduleSave();
|
||
};
|
||
|
||
$('#ctrl-mode').addEventListener('change', onChange);
|
||
$('#ctrl-emergency').addEventListener('change', onChange);
|
||
$('#ctrl-night').addEventListener('change', onChange);
|
||
$('#ctrl-speed').addEventListener('input', (e) => {
|
||
$('#speed-value').textContent = e.target.value;
|
||
onChange();
|
||
});
|
||
$('#ctrl-sensitivity').addEventListener('input', (e) => {
|
||
$('#sensitivity-value').textContent = e.target.value;
|
||
onChange();
|
||
});
|
||
$('#btn-apply').addEventListener('click', () => saveCurrentRobotSettings());
|
||
$('#btn-reset').addEventListener('click', async () => {
|
||
await loadRobots();
|
||
showToast('Настройки сброшены');
|
||
});
|
||
}
|
||
|
||
async function loadProfile() {
|
||
currentUser = await API.getProfile();
|
||
$('#field-email').value = currentUser.email;
|
||
$('#field-phone').value = currentUser.phone || '';
|
||
$('#field-firstname').value = currentUser.firstname || '';
|
||
$('#field-lastname').value = currentUser.lastname || '';
|
||
$('#field-patronymic').value = currentUser.patronymic || '';
|
||
$('#field-age').value = currentUser.age || '';
|
||
$('#profile-email').textContent = currentUser.email;
|
||
$('#profile-rating').textContent = currentUser.rating.toFixed(1);
|
||
$('#profile-points').textContent = currentUser.points;
|
||
updateProfileDisplay();
|
||
|
||
const { sessions } = await API.getSessions();
|
||
$('#sessions-list').innerHTML = sessions.length
|
||
? sessions.map((s) => `
|
||
<div class="session-item">
|
||
<div>Сессия #${s.id}</div>
|
||
<div class="session-item__meta">
|
||
Создана: ${formatDate(s.created_at)} · Истекает: ${formatDate(s.expires_at)}<br>
|
||
IP: ${s.ip_address || '—'} · ${s.user_agent?.slice(0, 60) || '—'}
|
||
</div>
|
||
</div>`).join('')
|
||
: '<p class="session-item">Нет активных сессий</p>';
|
||
}
|
||
|
||
function initProfile() {
|
||
$('#profile-form').addEventListener('submit', async (e) => {
|
||
e.preventDefault();
|
||
try {
|
||
currentUser = await API.updateProfile({
|
||
firstname: $('#field-firstname').value,
|
||
lastname: $('#field-lastname').value,
|
||
patronymic: $('#field-patronymic').value,
|
||
age: parseInt($('#field-age').value, 10) || null,
|
||
phone: $('#field-phone').value,
|
||
});
|
||
updateProfileDisplay();
|
||
$('#profile-rating').textContent = currentUser.rating.toFixed(1);
|
||
$('#profile-points').textContent = currentUser.points;
|
||
addLog('Профиль обновлён', 'success');
|
||
showToast('Профиль сохранён');
|
||
} catch (err) {
|
||
showToast(err.message);
|
||
}
|
||
});
|
||
|
||
$('#btn-cancel-profile').addEventListener('click', () => loadProfile());
|
||
}
|
||
|
||
function updateProfileDisplay() {
|
||
const parts = [currentUser.lastname, currentUser.firstname, currentUser.patronymic].filter(Boolean);
|
||
$('#profile-fullname').textContent = parts.length ? parts.join(' ') : currentUser.email;
|
||
}
|
||
|
||
function formatDate(iso) {
|
||
return new Date(iso).toLocaleString('ru-RU');
|
||
}
|
||
|
||
function addLog(message, type = 'info') {
|
||
const log = $('#event-log');
|
||
const time = new Date().toLocaleTimeString('ru-RU');
|
||
const entry = document.createElement('div');
|
||
entry.className = `log__entry log__entry--${type}`;
|
||
entry.innerHTML = `<div class="log__time">${time}</div><div>${message}</div>`;
|
||
log.prepend(entry);
|
||
while (log.children.length > 50) log.removeChild(log.lastChild);
|
||
}
|
||
|
||
function showToast(message) {
|
||
const toast = $('#toast');
|
||
toast.textContent = message;
|
||
toast.classList.add('toast--visible');
|
||
clearTimeout(showToast._t);
|
||
showToast._t = setTimeout(() => toast.classList.remove('toast--visible'), 3000);
|
||
}
|
||
|
||
function initFooterClock() {
|
||
const tick = () => { $('#footer-time').textContent = new Date().toLocaleString('ru-RU'); };
|
||
tick();
|
||
setInterval(tick, 1000);
|
||
}
|
||
|
||
document.addEventListener('DOMContentLoaded', init);
|