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" }}