Загрузить файлы в «/»
This commit is contained in:
25
api.js
Normal file
25
api.js
Normal file
@@ -0,0 +1,25 @@
|
||||
const API = {
|
||||
async request(path, options = {}) {
|
||||
const res = await fetch(path, {
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json', ...options.headers },
|
||||
...options,
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
const detail = data.detail;
|
||||
const msg = Array.isArray(detail)
|
||||
? detail.map((d) => d.msg).join(', ')
|
||||
: detail || 'Ошибка запроса';
|
||||
throw new Error(msg);
|
||||
}
|
||||
return data;
|
||||
},
|
||||
me: () => API.request('/api/auth/me'),
|
||||
logout: () => API.request('/api/auth/logout', { method: 'POST' }),
|
||||
getProfile: () => API.request('/api/profile'),
|
||||
updateProfile: (body) => API.request('/api/profile', { method: 'PUT', body: JSON.stringify(body) }),
|
||||
getRobots: () => API.request('/api/robots'),
|
||||
saveRobots: (body) => API.request('/api/robots', { method: 'PUT', body: JSON.stringify(body) }),
|
||||
getSessions: () => API.request('/api/sessions'),
|
||||
};
|
||||
374
app.js
Normal file
374
app.js
Normal file
@@ -0,0 +1,374 @@
|
||||
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);
|
||||
23
auth.js
Normal file
23
auth.js
Normal file
@@ -0,0 +1,23 @@
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const form = document.getElementById('auth-form');
|
||||
const errorEl = document.getElementById('auth-error');
|
||||
const isRegister = document.body.dataset.page === 'register';
|
||||
|
||||
form.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
errorEl.textContent = '';
|
||||
const email = document.getElementById('email').value.trim();
|
||||
const password = document.getElementById('password').value;
|
||||
const endpoint = isRegister ? '/api/auth/register' : '/api/auth/login';
|
||||
|
||||
try {
|
||||
await API.request(endpoint, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ email, password }),
|
||||
});
|
||||
window.location.href = '/';
|
||||
} catch (err) {
|
||||
errorEl.textContent = err.message;
|
||||
}
|
||||
});
|
||||
});
|
||||
171
index.html
Normal file
171
index.html
Normal file
@@ -0,0 +1,171 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>ROBO-OPS | Панель оператора</title>
|
||||
<link rel="stylesheet" href="/static/css/styles.css">
|
||||
</head>
|
||||
<body>
|
||||
<header class="header">
|
||||
<div class="header__brand">
|
||||
<span class="logo">ROBO-OPS</span>
|
||||
<span class="header__user" id="header-user"></span>
|
||||
</div>
|
||||
<nav class="nav">
|
||||
<button class="nav__btn nav__btn--active" data-page="dashboard">Главная</button>
|
||||
<button class="nav__btn" data-page="profile">Личный кабинет</button>
|
||||
<button class="btn btn--ghost" id="btn-logout">Выйти</button>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main class="main">
|
||||
<section id="page-dashboard" class="page page--active">
|
||||
<div class="page__header">
|
||||
<h1 class="page__title">Панель управления роботом</h1>
|
||||
<p class="page__subtitle">Выберите робота и настройте параметры работы</p>
|
||||
</div>
|
||||
|
||||
<div class="dashboard-grid">
|
||||
<aside class="panel">
|
||||
<div class="panel__head">
|
||||
<h2 class="panel__title">Реестр роботов</h2>
|
||||
<span class="badge" id="robot-count">4</span>
|
||||
</div>
|
||||
<div class="robot-list" id="robot-list"></div>
|
||||
</aside>
|
||||
|
||||
<div class="panel panel--wide">
|
||||
<div class="panel__head">
|
||||
<h2 class="panel__title">Настройки: <span id="selected-robot-name">—</span></h2>
|
||||
<span class="badge badge--status" id="robot-status-badge">—</span>
|
||||
</div>
|
||||
|
||||
<div class="robot-visual" id="robot-visual-frame">
|
||||
<div class="robot-visual__id" id="robot-visual-id">№ —</div>
|
||||
<div class="robot-visual__mode" id="robot-mode-label">—</div>
|
||||
</div>
|
||||
|
||||
<div class="stats-grid" id="stats-grid"></div>
|
||||
|
||||
<div class="controls">
|
||||
<h3 class="controls__title">Параметры управления</h3>
|
||||
<div class="control-group">
|
||||
<label class="control-label">Режим работы
|
||||
<select id="ctrl-mode" class="control-select">
|
||||
<option value="auto">Автономный</option>
|
||||
<option value="manual">Ручной</option>
|
||||
<option value="assist">Ассистированный</option>
|
||||
<option value="standby">Ожидание</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div class="control-group">
|
||||
<label class="control-label">Скорость: <strong id="speed-value">50</strong>%
|
||||
<input type="range" id="ctrl-speed" min="0" max="100" value="50" class="control-range">
|
||||
</label>
|
||||
</div>
|
||||
<div class="control-group">
|
||||
<label class="control-label">Чувствительность: <strong id="sensitivity-value">70</strong>%
|
||||
<input type="range" id="ctrl-sensitivity" min="0" max="100" value="70" class="control-range">
|
||||
</label>
|
||||
</div>
|
||||
<div class="control-group control-group--row">
|
||||
<label class="checkbox-label"><input type="checkbox" id="ctrl-emergency"> Аварийная остановка</label>
|
||||
<label class="checkbox-label"><input type="checkbox" id="ctrl-night"> Ночной режим</label>
|
||||
</div>
|
||||
<div class="control-actions">
|
||||
<button class="btn btn--primary" id="btn-apply">Применить</button>
|
||||
<button class="btn btn--secondary" id="btn-reset">Сбросить</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<aside class="panel">
|
||||
<div class="panel__head">
|
||||
<h2 class="panel__title">Журнал событий</h2>
|
||||
</div>
|
||||
<div class="log" id="event-log"></div>
|
||||
</aside>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="page-profile" class="page">
|
||||
<div class="page__header">
|
||||
<h1 class="page__title">Личный кабинет оператора</h1>
|
||||
<p class="page__subtitle">Профиль, рейтинг и активные сессии</p>
|
||||
</div>
|
||||
|
||||
<div class="profile-layout">
|
||||
<div class="profile-card panel">
|
||||
<h2 class="profile-card__name" id="profile-fullname">—</h2>
|
||||
<p class="profile-card__email" id="profile-email">—</p>
|
||||
<div class="profile-stats">
|
||||
<div class="profile-stat">
|
||||
<span class="profile-stat__label">Рейтинг</span>
|
||||
<span class="profile-stat__value" id="profile-rating">0</span>
|
||||
</div>
|
||||
<div class="profile-stat">
|
||||
<span class="profile-stat__label">Баллы</span>
|
||||
<span class="profile-stat__value" id="profile-points">0</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form class="panel" id="profile-form">
|
||||
<div class="panel__head">
|
||||
<h2 class="panel__title">Данные оператора</h2>
|
||||
</div>
|
||||
<div class="form-grid">
|
||||
<div class="form-field">
|
||||
<label for="field-email">Электронная почта</label>
|
||||
<input type="email" id="field-email" readonly>
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<label for="field-phone">Номер телефона</label>
|
||||
<input type="tel" id="field-phone" placeholder="+7 (___) ___-__-__">
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<label for="field-firstname">Имя</label>
|
||||
<input type="text" id="field-firstname">
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<label for="field-lastname">Фамилия</label>
|
||||
<input type="text" id="field-lastname">
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<label for="field-patronymic">Отчество</label>
|
||||
<input type="text" id="field-patronymic">
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<label for="field-age">Возраст</label>
|
||||
<input type="number" id="field-age" min="18" max="120">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn--primary">Сохранить</button>
|
||||
<button type="button" class="btn btn--secondary" id="btn-cancel-profile">Отмена</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="panel profile-sessions">
|
||||
<div class="panel__head">
|
||||
<h2 class="panel__title">Активные сессии</h2>
|
||||
</div>
|
||||
<div id="sessions-list" class="sessions-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<footer class="footer">
|
||||
<span>ROBO-OPS — Личный кабинет оператора робота</span>
|
||||
<span id="footer-time"></span>
|
||||
</footer>
|
||||
|
||||
<div class="toast" id="toast"></div>
|
||||
|
||||
<script src="/static/js/api.js"></script>
|
||||
<script src="/static/js/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
322
styles.css
Normal file
322
styles.css
Normal file
@@ -0,0 +1,322 @@
|
||||
:root {
|
||||
--bg: #f5f5f5;
|
||||
--surface: #fff;
|
||||
--border: #ddd;
|
||||
--text: #222;
|
||||
--text-muted: #666;
|
||||
--accent: #2563eb;
|
||||
--accent-hover: #1d4ed8;
|
||||
--danger: #dc2626;
|
||||
--success: #16a34a;
|
||||
--warning: #ca8a04;
|
||||
--radius: 6px;
|
||||
}
|
||||
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 24px;
|
||||
background: var(--surface);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.logo { font-weight: 700; font-size: 16px; }
|
||||
.header__user { margin-left: 16px; color: var(--text-muted); font-size: 13px; }
|
||||
|
||||
.nav { display: flex; gap: 8px; align-items: center; }
|
||||
|
||||
.nav__btn {
|
||||
padding: 6px 14px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
border-radius: var(--radius);
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.nav__btn:hover { border-color: #aaa; }
|
||||
.nav__btn--active { border-color: var(--accent); color: var(--accent); background: #eff6ff; }
|
||||
|
||||
/* Main */
|
||||
.main { flex: 1; padding: 20px 24px; max-width: 1400px; margin: 0 auto; width: 100%; }
|
||||
|
||||
.page { display: none; }
|
||||
.page--active { display: block; }
|
||||
|
||||
.page__header { margin-bottom: 20px; }
|
||||
.page__title { font-size: 20px; font-weight: 600; margin-bottom: 4px; }
|
||||
.page__subtitle { color: var(--text-muted); }
|
||||
|
||||
/* Panel */
|
||||
.panel {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.panel__head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.panel__title { font-size: 13px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; color: var(--text-muted); }
|
||||
|
||||
.badge {
|
||||
font-size: 11px;
|
||||
padding: 2px 8px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.badge--status { color: var(--success); border-color: #bbf7d0; background: #f0fdf4; }
|
||||
.badge--status.offline { color: var(--danger); border-color: #fecaca; background: #fef2f2; }
|
||||
.badge--status.emergency { color: var(--danger); border-color: #fecaca; background: #fef2f2; }
|
||||
.badge--status.idle { color: var(--warning); border-color: #fef08a; background: #fefce8; }
|
||||
|
||||
/* Dashboard */
|
||||
.dashboard-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 240px 1fr 260px;
|
||||
gap: 16px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.robot-list { display: flex; flex-direction: column; gap: 6px; }
|
||||
|
||||
.robot-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.robot-item:hover { border-color: #aaa; }
|
||||
.robot-item--active { border-color: var(--accent); background: #eff6ff; }
|
||||
|
||||
.robot-item__num {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: var(--accent);
|
||||
min-width: 32px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.robot-item__name { font-weight: 600; font-size: 13px; }
|
||||
.robot-item__type, .robot-item__mode { font-size: 11px; color: var(--text-muted); }
|
||||
|
||||
.robot-item__indicator {
|
||||
width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0;
|
||||
}
|
||||
.robot-item__indicator--online { background: var(--success); }
|
||||
.robot-item__indicator--offline, .robot-item__indicator--emergency { background: var(--danger); }
|
||||
.robot-item__indicator--idle { background: var(--warning); }
|
||||
|
||||
.robot-visual {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 20px;
|
||||
margin-bottom: 16px;
|
||||
text-align: center;
|
||||
min-height: 80px;
|
||||
position: relative;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.robot-visual--night { background: #1e1b2e; color: #c4b5fd; border-color: #7c3aed; }
|
||||
.robot-visual--emergency { background: #fef2f2; border-color: var(--danger); }
|
||||
.robot-visual--standby { background: #fefce8; border-color: var(--warning); }
|
||||
|
||||
.robot-visual__id { font-size: 12px; color: var(--text-muted); }
|
||||
.robot-visual__mode { font-size: 11px; color: var(--text-muted); margin-top: 4px; }
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 10px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stat-card__label { font-size: 10px; color: var(--text-muted); text-transform: uppercase; }
|
||||
.stat-card__value { font-size: 16px; font-weight: 600; margin-top: 2px; }
|
||||
|
||||
.stat-card__bar {
|
||||
height: 3px;
|
||||
background: #eee;
|
||||
border-radius: 2px;
|
||||
margin-top: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.stat-card__bar-fill {
|
||||
height: 100%;
|
||||
background: var(--accent);
|
||||
transition: width 0.3s;
|
||||
}
|
||||
|
||||
.controls__title { font-size: 12px; color: var(--text-muted); margin-bottom: 12px; text-transform: uppercase; }
|
||||
.control-group { margin-bottom: 12px; }
|
||||
.control-group--row { display: flex; gap: 20px; }
|
||||
|
||||
.control-label { display: flex; flex-direction: column; gap: 4px; font-size: 13px; }
|
||||
.control-select, .form-field input {
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
font-size: 14px;
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.control-select:focus, .form-field input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.control-range { width: 100%; margin-top: 4px; }
|
||||
.checkbox-label { font-size: 13px; display: flex; align-items: center; gap: 6px; cursor: pointer; }
|
||||
|
||||
.control-actions, .form-actions { display: flex; gap: 8px; margin-top: 12px; }
|
||||
|
||||
/* Buttons */
|
||||
.btn {
|
||||
padding: 8px 16px;
|
||||
border-radius: var(--radius);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.btn--primary { background: var(--accent); color: #fff; border-color: var(--accent); }
|
||||
.btn--primary:hover { background: var(--accent-hover); }
|
||||
.btn--secondary:hover { border-color: #aaa; }
|
||||
.btn--ghost { background: transparent; }
|
||||
.btn--block { width: 100%; }
|
||||
|
||||
/* Log */
|
||||
.log { max-height: 400px; overflow-y: auto; }
|
||||
.log__entry { padding: 6px 8px; border-left: 2px solid var(--border); margin-bottom: 4px; font-size: 12px; }
|
||||
.log__entry--success { border-left-color: var(--success); }
|
||||
.log__entry--warning { border-left-color: var(--warning); }
|
||||
.log__entry--info { border-left-color: var(--accent); }
|
||||
.log__time { color: var(--text-muted); font-size: 11px; }
|
||||
|
||||
/* Profile */
|
||||
.profile-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 280px 1fr;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.profile-sessions { grid-column: 1 / -1; }
|
||||
|
||||
.profile-card__name { font-size: 18px; font-weight: 600; margin-bottom: 4px; }
|
||||
.profile-card__email { color: var(--text-muted); margin-bottom: 16px; }
|
||||
|
||||
.profile-stats { display: flex; gap: 16px; }
|
||||
.profile-stat { flex: 1; border: 1px solid var(--border); border-radius: var(--radius); padding: 12px; text-align: center; }
|
||||
.profile-stat__label { font-size: 11px; color: var(--text-muted); text-transform: uppercase; }
|
||||
.profile-stat__value { font-size: 24px; font-weight: 700; color: var(--accent); }
|
||||
|
||||
.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin-bottom: 12px; }
|
||||
.form-field { display: flex; flex-direction: column; gap: 4px; }
|
||||
.form-field label { font-size: 12px; color: var(--text-muted); }
|
||||
|
||||
.sessions-list { display: flex; flex-direction: column; gap: 6px; }
|
||||
.session-item {
|
||||
padding: 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
font-size: 12px;
|
||||
}
|
||||
.session-item__meta { color: var(--text-muted); margin-top: 4px; }
|
||||
|
||||
/* Auth */
|
||||
.auth-page {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.auth-card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 32px;
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.auth-card h1 { font-size: 22px; margin-bottom: 4px; }
|
||||
.auth-subtitle { color: var(--text-muted); margin-bottom: 20px; }
|
||||
.auth-form { display: flex; flex-direction: column; gap: 12px; }
|
||||
.auth-link { margin-top: 16px; text-align: center; font-size: 13px; color: var(--text-muted); }
|
||||
.auth-link a { color: var(--accent); }
|
||||
.form-error { color: var(--danger); font-size: 13px; min-height: 18px; }
|
||||
|
||||
/* Footer */
|
||||
.footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 12px 24px;
|
||||
border-top: 1px solid var(--border);
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
/* Toast */
|
||||
.toast {
|
||||
position: fixed;
|
||||
bottom: 20px;
|
||||
right: 20px;
|
||||
padding: 10px 16px;
|
||||
background: var(--text);
|
||||
color: #fff;
|
||||
border-radius: var(--radius);
|
||||
font-size: 13px;
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
transition: all 0.3s;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.toast--visible { opacity: 1; transform: translateY(0); }
|
||||
|
||||
@media (max-width: 1000px) {
|
||||
.dashboard-grid, .profile-layout, .form-grid { grid-template-columns: 1fr; }
|
||||
.header { flex-direction: column; gap: 10px; align-items: flex-start; }
|
||||
}
|
||||
Reference in New Issue
Block a user