diff --git a/server/static/app.css b/server/static/app.css index 5c8fa3d..3cea5c2 100644 --- a/server/static/app.css +++ b/server/static/app.css @@ -105,14 +105,53 @@ aside { #controlPanel input, #controlPanel select { width: 100%; padding: 4px; margin-top: 2px; border-radius: 4px; border: none; font-size: 0.8rem; } #controlPanel .cmd-row { display: flex; gap: 4px; margin-top: 6px; flex-wrap: wrap; } #controlPanel .cmd-row button { padding: 4px 8px; font-size: 0.75rem; border: none; border-radius: 4px; cursor: pointer; background: #16213e; color: #eee; } -#macroPresetPanel input, #macroPresetPanel select, #referencePointPanel select { +#referencePointPanel select { width: 100%; padding: 4px; margin-top: 4px; border-radius: 4px; border: none; font-size: 0.8rem; } -#macroPresetPanel .cmd-row { display: flex; gap: 4px; flex-wrap: wrap; } -#macroPresetPanel .cmd-row button, #referencePointPanel button { +#referencePointPanel button { flex: 1; padding: 5px 8px; font-size: 0.75rem; border: none; border-radius: 4px; cursor: pointer; background: #16213e; color: #eee; } +.preset-list { display: flex; flex-direction: column; gap: 4px; max-height: 160px; overflow: auto; } +.preset-row { + display: grid; grid-template-columns: 18px 1fr; gap: 6px; align-items: center; + padding: 6px; background: #0a0a14; border: 1px solid #333; border-radius: 4px; + cursor: pointer; font-size: 0.8rem; +} +.preset-row:hover, .preset-row.selected { border-color: #4fc3f7; background: #16213e; } +.preset-row.applied .preset-check { color: #00ff88; } +.preset-title { font-weight: 600; } +.preset-desc { color: #aaa; font-size: 0.7rem; margin-top: 2px; } +.preset-actions { display: flex; gap: 4px; margin-top: 6px; flex-wrap: wrap; } +.preset-actions button, .preset-modal-actions button { + flex: 1; padding: 5px 8px; font-size: 0.75rem; border: none; border-radius: 4px; + cursor: pointer; background: #16213e; color: #eee; +} +#btnMacroPresetApply { background: #00ff88; color: #111; font-weight: 600; } +#macroPresetModal { + display: none; position: fixed; inset: 0; z-index: 2500; + background: rgba(0, 0, 0, 0.55); align-items: center; justify-content: center; + padding: 14px; +} +#macroPresetModal.open { display: flex; } +#macroPresetModalCard { + width: min(520px, 100%); max-height: 90vh; overflow: auto; + background: #0f3460; border: 1px solid #444; border-radius: 8px; + box-shadow: 0 8px 32px rgba(0,0,0,0.5); +} +#macroPresetModalHeader { + padding: 10px 12px; background: #16213e; display: flex; + justify-content: space-between; align-items: center; font-weight: 600; +} +#btnMacroPresetClose { background: none; border: none; color: #eee; font-size: 1.2rem; cursor: pointer; } +#macroPresetModalBody { padding: 12px; } +#macroPresetModalBody label { display: block; color: #aaa; font-size: 0.75rem; margin-top: 8px; } +#macroPresetModalBody input, #macroPresetModalBody select { + width: 100%; padding: 6px; margin-top: 2px; border-radius: 4px; border: none; font-size: 0.85rem; +} +.preset-edit-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 6px; } +.preset-modal-actions { display: flex; gap: 6px; margin-top: 12px; } +#btnMacroPresetDelete { background: #4a1f2a; } #referencePointSummary { margin-top: 6px; line-height: 1.45; } #referencePointChart { width: 100%; height: 140px; display: block; margin-top: 6px; background: #0a0a14; border-radius: 4px; } .metric-card { background: #0a0a14; border-radius: 4px; padding: 6px; margin-top: 6px; } diff --git a/server/static/app.js b/server/static/app.js index 4c7efb6..4c5d2e5 100644 --- a/server/static/app.js +++ b/server/static/app.js @@ -65,6 +65,8 @@ let dualTracksActive = false; let singleTrackActive = false; let lastDevices = []; let macroPresets = []; +let selectedMacroPresetId = ''; +let appliedMacroPresetId = ''; let referencePointSessions = []; let referencePointMarker = null; const deviceLabelCache = {}; @@ -669,75 +671,178 @@ function paramsFromMacroLines(lines) { return params; } -function selectedMacroPreset() { - const id = document.getElementById('macroPresetSelect')?.value; - return macroPresets.find(p => p.id === id) || null; +function macroLinesFromParams(params) { + const lines = ['S']; + if (params.frequency_hz != null) lines.push(`AT+FQ=${Math.round(Number(params.frequency_hz))}`); + if (params.power_dbm != null) lines.push(`AT+PW=${params.power_dbm}`); + if (params.sf != null) lines.push(`AT+SF=${params.sf}`); + if (params.bw_khz) lines.push(`AT+BW=${params.bw_khz}`); + if (params.code_rate) lines.push(`AT+CR=${params.code_rate}`); + if (params.preamble != null) lines.push(`AT+PL=${params.preamble}`); + if (params.send_timeout_ms != null) lines.push(`AT+TM=${params.send_timeout_ms}`); + if (params.role === 'TX') lines.push('AT+TX'); + if (params.role === 'RX') lines.push('AT+RX'); + return lines; } -function updateMacroPresetSelect(preserveId) { - const sel = document.getElementById('macroPresetSelect'); - if (!sel) return; - const prev = preserveId ?? sel.value; - sel.innerHTML = ''; - macroPresets.forEach(p => { - const opt = document.createElement('option'); - opt.value = p.id; - opt.textContent = p.name || p.id; - sel.appendChild(opt); - }); - if (prev) sel.value = prev; +function numOrNull(id) { + const value = document.getElementById(id)?.value; + if (value == null || String(value).trim() === '') return null; + const n = Number(value); + return Number.isFinite(n) ? n : null; +} + +function buildPresetEditParams() { + const params = {}; + const fq = numOrNull('presetEditFq'); + if (fq != null) params.frequency_hz = Math.round(fq * 1e6); + const pw = numOrNull('presetEditPw'); + if (pw != null) params.power_dbm = pw; + const sf = numOrNull('presetEditSf'); + if (sf != null) params.sf = sf; + const pl = numOrNull('presetEditPl'); + if (pl != null) params.preamble = pl; + const tm = numOrNull('presetEditTm'); + if (tm != null) params.send_timeout_ms = tm; + const bw = document.getElementById('presetEditBw')?.value; + if (bw) params.bw_khz = bw; + const cr = document.getElementById('presetEditCr')?.value; + if (cr) params.code_rate = cr; + const role = document.getElementById('presetEditRole')?.value; + if (role) params.role = role; + return params; +} + +function fillPresetModal(preset) { + const params = macroPresetParams(preset); + document.getElementById('presetEditId').value = preset?.id || ''; + document.getElementById('presetEditName').value = preset?.name || ''; + document.getElementById('presetEditDescription').value = preset?.description || ''; + document.getElementById('presetEditFq').value = + params.frequency_hz != null ? (Number(params.frequency_hz) / 1e6).toFixed(3) : ''; + document.getElementById('presetEditPw').value = params.power_dbm ?? ''; + document.getElementById('presetEditSf').value = params.sf ?? ''; + document.getElementById('presetEditPl').value = params.preamble ?? ''; + document.getElementById('presetEditTm').value = params.send_timeout_ms ?? ''; + document.getElementById('presetEditBw').value = params.bw_khz ?? ''; + document.getElementById('presetEditCr').value = params.code_rate ?? ''; + document.getElementById('presetEditRole').value = params.role ?? ''; + document.getElementById('btnMacroPresetDelete').style.display = preset ? '' : 'none'; + document.getElementById('macroPresetModalTitle').textContent = + preset ? 'Редактировать пресет' : 'Создать пресет'; +} + +function setMacroPresetModalOpen(open) { + document.getElementById('macroPresetModal')?.classList.toggle('open', open); +} + +function openMacroPresetCreate() { + fillPresetModal(null); + setMacroPresetModalOpen(true); + document.getElementById('presetEditName')?.focus(); +} + +function openMacroPresetEdit() { + const preset = selectedMacroPreset(); + if (!preset) { + const status = document.getElementById('macroPresetStatus'); + if (status) status.textContent = 'Выберите пресет для редактирования'; + return; + } + fillPresetModal(preset); + setMacroPresetModalOpen(true); + document.getElementById('presetEditName')?.focus(); } async function loadMacroPresets() { const res = await fetch('/api/macro-presets?include_disabled=true', { cache: 'no-store' }); if (!res.ok) throw new Error('macro presets ' + res.status); macroPresets = await res.json(); - updateMacroPresetSelect(); + if (selectedMacroPresetId && !macroPresets.some(p => p.id === selectedMacroPresetId)) { + selectedMacroPresetId = ''; + } + renderMacroPresetList(); const status = document.getElementById('macroPresetStatus'); if (status) status.textContent = `${macroPresets.length} пресет(ов)`; } -function fillMacroPresetEditor(preset) { - document.getElementById('macroPresetName').value = preset?.name || ''; - document.getElementById('macroPresetDescription').value = preset?.description || ''; +function selectedMacroPreset() { + return macroPresets.find(p => p.id === selectedMacroPresetId) || null; } -function applySelectedMacroPresetToCommandForm() { +function renderMacroPresetList() { + const list = document.getElementById('macroPresetList'); + if (!list) return; + if (!macroPresets.length) { + list.innerHTML = '
Пресетов пока нет
'; + return; + } + list.innerHTML = ''; + macroPresets.forEach(preset => { + const row = document.createElement('button'); + row.type = 'button'; + row.className = 'preset-row'; + if (preset.id === selectedMacroPresetId) row.classList.add('selected'); + if (preset.id === appliedMacroPresetId) row.classList.add('applied'); + row.innerHTML = `${preset.id === appliedMacroPresetId ? '✓' : ''} + ${escapeHtml(preset.name || preset.id)} + ${escapeHtml(preset.description || preset.id)}`; + row.addEventListener('click', () => { + selectedMacroPresetId = preset.id; + renderMacroPresetList(); + }); + list.appendChild(row); + }); +} + +function macroPresetParams(preset) { + if (!preset) return {}; + return Object.keys(preset.params || {}).length + ? preset.params + : paramsFromMacroLines(preset.lines); +} + +function macroPresetLines(preset) { + if (!preset) return []; + if (preset.lines?.length) return preset.lines; + return macroLinesFromParams(macroPresetParams(preset)); +} + +async function applySelectedMacroPreset() { const preset = selectedMacroPreset(); const status = document.getElementById('macroPresetStatus'); if (!preset) { if (status) status.textContent = 'Выберите пресет'; return; } - const params = Object.keys(preset.params || {}).length - ? preset.params - : paramsFromMacroLines(preset.lines); - applyMacroParams(params); - fillMacroPresetEditor(preset); - const panel = document.getElementById('controlPanel'); - panel?.scrollIntoView({ behavior: 'smooth', block: 'start' }); - document.getElementById('cmdTargetSelect')?.focus(); - if (status) status.textContent = `Пресет "${preset.name || preset.id}" перенесён в форму`; + const ok = await postCommand(document.getElementById('cmdTargetSelect').value, 'at', { + lines: macroPresetLines(preset) + }); + if (!ok) return; + appliedMacroPresetId = preset.id; + renderMacroPresetList(); + if (status) status.textContent = `Применён: ${preset.name || preset.id}`; } async function saveMacroPreset() { - const selected = selectedMacroPreset(); - const name = document.getElementById('macroPresetName').value.trim(); + const presetId = document.getElementById('presetEditId').value.trim(); + const name = document.getElementById('presetEditName').value.trim(); if (!name) { alert('Введите название пресета'); return; } + const params = buildPresetEditParams(); const body = { - id: selected?.id, + id: presetId || undefined, name, - description: document.getElementById('macroPresetDescription').value.trim(), - params: buildMacroParams(), - lines: buildMacroLines(), + description: document.getElementById('presetEditDescription').value.trim(), + params, + lines: macroLinesFromParams(params), enabled: true }; - const url = selected ? `/api/macro-presets/${encodeURIComponent(selected.id)}` : '/api/macro-presets'; + const url = presetId ? `/api/macro-presets/${encodeURIComponent(presetId)}` : '/api/macro-presets'; const res = await fetch(url, { - method: selected ? 'PATCH' : 'POST', + method: presetId ? 'PATCH' : 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); @@ -747,19 +852,23 @@ async function saveMacroPreset() { return; } await loadMacroPresets(); - updateMacroPresetSelect(data.preset?.id); + selectedMacroPresetId = data.preset?.id || selectedMacroPresetId; + renderMacroPresetList(); + setMacroPresetModalOpen(false); } async function deleteMacroPreset() { - const preset = selectedMacroPreset(); - if (!preset) return; - const res = await fetch(`/api/macro-presets/${encodeURIComponent(preset.id)}`, { method: 'DELETE' }); + const presetId = document.getElementById('presetEditId').value.trim(); + if (!presetId) return; + const res = await fetch(`/api/macro-presets/${encodeURIComponent(presetId)}`, { method: 'DELETE' }); if (!res.ok) { alert('Не удалось удалить пресет'); return; } + if (selectedMacroPresetId === presetId) selectedMacroPresetId = ''; + if (appliedMacroPresetId === presetId) appliedMacroPresetId = ''; await loadMacroPresets(); - fillMacroPresetEditor(null); + setMacroPresetModalOpen(false); } function fmtMetric(v, digits = 1) { @@ -3943,7 +4052,7 @@ document.getElementById('btnModeDual').onclick = () => { async function postCommand(toDeviceId, kind, payload) { if (!toDeviceId) { alert('Выберите устройство'); - return; + return false; } const res = await fetch('/api/commands', { method: 'POST', @@ -3953,20 +4062,22 @@ async function postCommand(toDeviceId, kind, payload) { if (!res.ok) { const err = await res.json().catch(() => ({})); alert(err.error || 'Ошибка команды'); + return false; } + return true; } document.getElementById('btnCmdApply').onclick = () => { - const preset = selectedMacroPreset(); - const lines = preset && !cmdFormDirty && preset.lines?.length ? preset.lines : buildMacroLines(); - postCommand(document.getElementById('cmdTargetSelect').value, 'at', { lines }); + postCommand(document.getElementById('cmdTargetSelect').value, 'at', { lines: buildMacroLines() }); setCmdFormDirty(false); }; -document.getElementById('macroPresetSelect')?.addEventListener('change', () => { - fillMacroPresetEditor(selectedMacroPreset()); -}); document.getElementById('btnMacroPresetApply')?.addEventListener('click', () => { - applySelectedMacroPresetToCommandForm(); + applySelectedMacroPreset().catch(e => alert(e.message || String(e))); +}); +document.getElementById('btnMacroPresetCreate')?.addEventListener('click', openMacroPresetCreate); +document.getElementById('btnMacroPresetEdit')?.addEventListener('click', openMacroPresetEdit); +document.getElementById('btnMacroPresetClose')?.addEventListener('click', () => { + setMacroPresetModalOpen(false); }); document.getElementById('btnMacroPresetSave')?.addEventListener('click', () => { saveMacroPreset().catch(e => alert(e.message || String(e))); @@ -3974,6 +4085,9 @@ document.getElementById('btnMacroPresetSave')?.addEventListener('click', () => { document.getElementById('btnMacroPresetDelete')?.addEventListener('click', () => { deleteMacroPreset().catch(e => alert(e.message || String(e))); }); +document.getElementById('macroPresetModal')?.addEventListener('click', e => { + if (e.target?.id === 'macroPresetModal') setMacroPresetModalOpen(false); +}); document.getElementById('btnReferenceRefresh')?.addEventListener('click', () => { loadReferencePoints().catch(e => { document.getElementById('referencePointSummary').textContent = e.message || String(e); diff --git a/server/static/index.html b/server/static/index.html index e30cfc7..aefd6a6 100644 --- a/server/static/index.html +++ b/server/static/index.html @@ -190,16 +190,14 @@

Макро-пресеты

- -
- - - +
+
Загрузка…
+
+
+ + +
- - - -
—
@@ -279,6 +277,46 @@
+
+
+
+ Пресет + +
+
+ + + +
+ + + + + + + + +
+
+ + +
+
+
+