From c87911ee55a3d198fd5b2003516c1edcc7b28ab7 Mon Sep 17 00:00:00 2001 From: lokses Date: Wed, 7 Oct 2026 16:51:16 +0300 Subject: [PATCH] =?UTF-8?q?it=5Fmanager:=20=D0=B8=D1=81=D0=BF=D1=80=D0=B0?= =?UTF-8?q?=D0=B2=D0=B8=D1=82=D1=8C=20=D0=B7=D0=B0=D0=B3=D1=80=D1=83=D0=B7?= =?UTF-8?q?=D0=BA=D1=83=20layout=20=D0=B8=20drag=20=D0=B2=20=D1=80=D0=B5?= =?UTF-8?q?=D0=B4=D0=B0=D0=BA=D1=82=D0=BE=D1=80=D0=B5=20=D1=8D=D1=82=D0=B8?= =?UTF-8?q?=D0=BA=D0=B5=D1=82=D0=BE=D0=BA.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- apps/it_manager/tests.py | 3 + apps/it_manager/views.py | 7 +- static/assets/js/label-template-editor.js | 115 ++++++++++++------ .../pages/it-equipment-label-template.html | 3 +- 4 files changed, 88 insertions(+), 40 deletions(-) diff --git a/apps/it_manager/tests.py b/apps/it_manager/tests.py index e5c465a..24e0620 100644 --- a/apps/it_manager/tests.py +++ b/apps/it_manager/tests.py @@ -465,6 +465,9 @@ class ItManagerEquipmentTests(TestCase): r = self.client.get('/it-manager/equipment/labels/template/') self.assertEqual(r.status_code, 200) self.assertContains(r, 'Шаблон этикетки') + # json_script должен отдать объект, а не JSON-строку (двойной dumps ломал редактор) + self.assertContains(r, '"type": "qr"') + self.assertNotContains(r, '\\"type\\": \\"qr\\"') tmpl = EquipmentLabelTemplate.get_default() payload = { diff --git a/apps/it_manager/views.py b/apps/it_manager/views.py index 00a43d0..45a9a3a 100644 --- a/apps/it_manager/views.py +++ b/apps/it_manager/views.py @@ -9,6 +9,7 @@ from django.db.models import Count, Prefetch, Q, Sum from django.http import HttpResponse, HttpResponseForbidden from django.shortcuts import get_object_or_404 from django.utils import timezone +from django.views.decorators.csrf import ensure_csrf_cookie from apps.it_manager.models import ( Consumable, Equipment, EquipmentAssignment, EquipmentModel, @@ -799,6 +800,7 @@ def equipment_labels_view(request): @login_required +@ensure_csrf_cookie def equipment_label_template_view(request): """Редактор общего шаблона этикетки (GET страница / POST JSON).""" import json @@ -835,13 +837,14 @@ def equipment_label_template_view(request): template.save() return JsonResponse({'ok': True, 'updated_at': template.updated_at.isoformat()}) + # dict для json_script (не dumps — иначе двойное экранирование и пустой layout) return render(request, 'pages/it-equipment-label-template.html', { 'template': template, - 'template_json': json.dumps({ + 'template_data': { 'width_mm': template.width_mm, 'height_mm': template.height_mm, 'layout': template.layout or [], - }, ensure_ascii=False), + }, }) diff --git a/static/assets/js/label-template-editor.js b/static/assets/js/label-template-editor.js index 4cfc9f2..ec9b102 100644 --- a/static/assets/js/label-template-editor.js +++ b/static/assets/js/label-template-editor.js @@ -24,9 +24,14 @@ var dataEl = document.getElementById('label-template-data'); var state = { width_mm: 70, height_mm: 42, layout: [] }; try { - state = JSON.parse(dataEl.textContent || '{}'); + var parsed = JSON.parse(dataEl.textContent || '{}'); + // на случай старого двойного dumps → json_script + if (typeof parsed === 'string') parsed = JSON.parse(parsed); + if (parsed && typeof parsed === 'object') state = parsed; } catch (e) { /* keep defaults */ } if (!Array.isArray(state.layout)) state.layout = []; + state.width_mm = Number(state.width_mm) || 70; + state.height_mm = Number(state.height_mm) || 42; var canvas = document.getElementById('label-canvas'); var scaleInput = document.getElementById('tmpl-scale'); @@ -53,6 +58,15 @@ statusEl.className = isErr ? 'err' : ''; } + function csrfToken() { + var meta = document.querySelector('meta[name="csrf-token"]'); + if (meta && meta.content) return meta.content; + var input = document.querySelector('input[name="csrfmiddlewaretoken"]'); + if (input && input.value) return input.value; + var match = document.cookie.match(/(?:^|; )csrftoken=([^;]+)/); + return match ? decodeURIComponent(match[1]) : ''; + } + function selectedBlock() { return state.layout.find(function (b) { return b.id === selectedId; }) || null; } @@ -62,30 +76,6 @@ return DEMO[block.type] || block.type; } - function render() { - var s = scale(); - canvas.style.width = (state.width_mm * s) + 'px'; - canvas.style.height = (state.height_mm * s) + 'px'; - canvas.innerHTML = ''; - state.layout.forEach(function (block) { - var el = document.createElement('div'); - el.className = 'lbl-block' + (block.id === selectedId ? ' selected' : ''); - el.dataset.id = block.id; - el.style.left = (block.x_mm * s) + 'px'; - el.style.top = (block.y_mm * s) + 'px'; - el.style.width = (block.w_mm * s) + 'px'; - el.style.height = (block.h_mm * s) + 'px'; - el.style.fontSize = Math.max(8, block.font_pt * (s / 3.5)) + 'px'; - el.style.fontWeight = block.bold ? '600' : '400'; - el.style.textAlign = block.align || 'left'; - el.innerHTML = '
' + (TYPE_LABELS[block.type] || block.type) + '
' + - '
' + escapeHtml(previewText(block)) + '
' + - '
'; - canvas.appendChild(el); - }); - syncProps(); - } - function escapeHtml(str) { return String(str || '') .replace(/&/g, '&') @@ -93,6 +83,44 @@ .replace(/>/g, '>'); } + function applyBlockStyle(el, block, s) { + el.style.left = (block.x_mm * s) + 'px'; + el.style.top = (block.y_mm * s) + 'px'; + el.style.width = (block.w_mm * s) + 'px'; + el.style.height = (block.h_mm * s) + 'px'; + el.style.fontSize = Math.max(8, block.font_pt * (s / 3.5)) + 'px'; + el.style.fontWeight = block.bold ? '600' : '400'; + el.style.textAlign = block.align || 'left'; + } + + function render(opts) { + opts = opts || {}; + var s = scale(); + canvas.style.width = (state.width_mm * s) + 'px'; + canvas.style.height = (state.height_mm * s) + 'px'; + + // во время drag только двигаем DOM, без пересоздания (иначе ломается pointer capture) + if (opts.moveOnly && drag) { + var moving = canvas.querySelector('.lbl-block[data-id="' + drag.id + '"]'); + var block = state.layout.find(function (b) { return b.id === drag.id; }); + if (moving && block) applyBlockStyle(moving, block, s); + return; + } + + canvas.innerHTML = ''; + state.layout.forEach(function (block) { + var el = document.createElement('div'); + el.className = 'lbl-block' + (block.id === selectedId ? ' selected' : ''); + el.dataset.id = block.id; + applyBlockStyle(el, block, s); + el.innerHTML = '
' + (TYPE_LABELS[block.type] || block.type) + '
' + + '
' + escapeHtml(previewText(block)) + '
' + + '
'; + canvas.appendChild(el); + }); + if (!opts.skipProps) syncProps(); + } + function syncProps() { var block = selectedBlock(); var empty = document.getElementById('props-empty'); @@ -105,10 +133,10 @@ empty.style.display = 'none'; fields.style.display = ''; document.getElementById('prop-type-label').textContent = TYPE_LABELS[block.type] || block.type; - document.getElementById('prop-x').value = block.x_mm; - document.getElementById('prop-y').value = block.y_mm; - document.getElementById('prop-w').value = block.w_mm; - document.getElementById('prop-h').value = block.h_mm; + document.getElementById('prop-x').value = round1(block.x_mm); + document.getElementById('prop-y').value = round1(block.y_mm); + document.getElementById('prop-w').value = round1(block.w_mm); + document.getElementById('prop-h').value = round1(block.h_mm); document.getElementById('prop-font').value = block.font_pt; document.getElementById('prop-align').value = block.align || 'left'; document.getElementById('prop-bold').checked = !!block.bold; @@ -117,6 +145,10 @@ document.getElementById('prop-text').value = block.text || ''; } + function round1(n) { + return Math.round(Number(n) * 10) / 10; + } + function clampBlock(block) { block.w_mm = Math.max(2, Number(block.w_mm) || 10); block.h_mm = Math.max(2, Number(block.h_mm) || 10); @@ -179,8 +211,12 @@ scale: s, pointerId: ev.pointerId, }; - canvas.setPointerCapture(ev.pointerId); - render(); + try { canvas.setPointerCapture(ev.pointerId); } catch (e) { /* ignore */ } + // обновить выделение без сброса pointer + canvas.querySelectorAll('.lbl-block').forEach(function (el) { + el.classList.toggle('selected', el.dataset.id === selectedId); + }); + syncProps(); ev.preventDefault(); }); @@ -198,10 +234,14 @@ block.y_mm = drag.origY + dy; } clampBlock(block); - render(); + render({ moveOnly: true }); }); - function endDrag() { + function endDrag(ev) { + if (!drag) return; + if (ev && drag.pointerId != null) { + try { canvas.releasePointerCapture(drag.pointerId); } catch (e) { /* ignore */ } + } drag = null; syncProps(); } @@ -228,7 +268,7 @@ if (!block) return; apply(block, el); clampBlock(block); - render(); + render({ skipProps: true }); }); } @@ -261,7 +301,6 @@ scaleInput.addEventListener('change', render); document.getElementById('btn-save').addEventListener('click', function () { - var csrf = document.querySelector('meta[name="csrf-token"]'); var payload = { width_mm: state.width_mm, height_mm: state.height_mm, @@ -273,12 +312,12 @@ credentials: 'same-origin', headers: { 'Content-Type': 'application/json', - 'X-CSRFToken': csrf ? csrf.content : '', + 'X-CSRFToken': csrfToken(), }, body: JSON.stringify(payload), }).then(function (r) { return r.json().then(function (data) { - if (!r.ok || !data.ok) throw new Error((data && data.error) || 'Ошибка сохранения'); + if (!r.ok || !data.ok) throw new Error((data && data.error) || ('Ошибка ' + r.status)); setStatus('Сохранено'); }); }).catch(function (err) { @@ -286,5 +325,7 @@ }); }); + widthInput.value = state.width_mm; + heightInput.value = state.height_mm; render(); })(); diff --git a/templates/pages/it-equipment-label-template.html b/templates/pages/it-equipment-label-template.html index ceb6de0..158fd55 100644 --- a/templates/pages/it-equipment-label-template.html +++ b/templates/pages/it-equipment-label-template.html @@ -88,6 +88,7 @@ + {% csrf_token %}

{% trans "Шаблон этикетки оборудования" %}

@@ -157,7 +158,7 @@
- {{ template_json|json_script:"label-template-data" }} + {{ template_data|json_script:"label-template-data" }}