feat: viewer 3D + parser 3MF par plaque + échecs filament + portail client
Deploy via Portainer / deploy (push) Successful in 0s
Deploy via Portainer / deploy (push) Successful in 0s
This commit is contained in:
+183
-13
@@ -50,9 +50,16 @@
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label fw-semibold">Fichier source</label>
|
||||
<input type="text" name="source_file" id="sourceFile" class="form-control"
|
||||
placeholder="nom_fichier.3mf"
|
||||
value="{{ prefill.source_file if prefill else '' }}">
|
||||
<div class="input-group">
|
||||
<input type="text" name="source_file" id="sourceFile" class="form-control"
|
||||
placeholder="nom_fichier.3mf"
|
||||
value="{{ prefill.source_file if prefill else '' }}">
|
||||
<button type="button" class="btn btn-outline-secondary" id="ncBrowseBtn"
|
||||
data-bs-toggle="modal" data-bs-target="#ncPickerModal"
|
||||
title="Parcourir Nextcloud">
|
||||
<i class="bi bi-cloud-arrow-down"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row g-3 mb-3">
|
||||
@@ -282,35 +289,109 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modale Nextcloud File Picker -->
|
||||
<div class="modal fade" id="ncPickerModal" tabindex="-1">
|
||||
<div class="modal-dialog modal-lg">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<div class="flex-fill">
|
||||
<h5 class="modal-title mb-1"><i class="bi bi-cloud me-2"></i>Nextcloud — Choisir un fichier</h5>
|
||||
<nav aria-label="breadcrumb">
|
||||
<ol class="breadcrumb mb-0 small" id="ncBreadcrumb"></ol>
|
||||
</nav>
|
||||
</div>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body p-0" id="ncPickerBody" style="max-height:60vh;overflow-y:auto">
|
||||
<div class="text-center text-muted py-5">
|
||||
<i class="bi bi-cloud fs-1 d-block mb-2"></i>
|
||||
Cliquez sur le bouton <i class="bi bi-cloud-arrow-down"></i> pour ouvrir.
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer py-2">
|
||||
<small class="text-muted me-auto">Cliquez sur un fichier pour le sélectionner.</small>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" data-bs-dismiss="modal">Annuler</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
// ── 3MF parser ────────────────────────────────────────────────────────────────
|
||||
// ── 3MF parser avec sélecteur de plaque ──────────────────────────────────────
|
||||
let _3mfPlates = [];
|
||||
|
||||
function parse3mf() {
|
||||
const file = document.getElementById('file3mf').files[0];
|
||||
if (!file) { alert('Selectionnez un fichier .3mf'); return; }
|
||||
const status = document.getElementById('parseStatus');
|
||||
status.textContent = 'Analyse...';
|
||||
status.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Analyse…';
|
||||
const fd = new FormData();
|
||||
fd.append('file', file);
|
||||
fetch('/api/parse-3mf', {method:'POST', body:fd})
|
||||
.then(r => r.json())
|
||||
.then(d => {
|
||||
let filled = [];
|
||||
if (d.weight_g) { document.getElementById('weight_g').value = d.weight_g; filled.push('poids'); }
|
||||
if (d.hours != null) { document.getElementById('hours').value = d.hours; filled.push('heures'); }
|
||||
if (d.minutes != null){ document.getElementById('minutes').value = d.minutes; }
|
||||
if (d.filename) {
|
||||
document.getElementById('sourceFile').value = d.filename;
|
||||
if (!document.getElementById('jobName').value)
|
||||
document.getElementById('jobName').value = d.filename;
|
||||
}
|
||||
status.textContent = filled.length ? 'Extrait: ' + filled.join(', ') : 'Donnees non trouvees';
|
||||
status.style.color = filled.length ? '#22c55e' : '#f59e0b';
|
||||
recalc();
|
||||
// ── Cas Bambu avec plaques détectées ──
|
||||
if (d.plates && d.plates.length > 1) {
|
||||
_3mfPlates = d.plates;
|
||||
renderPlateSelector(d.plates);
|
||||
status.innerHTML = `<span class="text-success">${d.plates.length} plateau(x) détecté(s) — choisissez ci-dessous</span>`;
|
||||
return;
|
||||
}
|
||||
// ── Plaque unique ou fallback ──
|
||||
applyPlateData(d.weight_g, d.hours, d.minutes);
|
||||
const ok = d.weight_g || d.hours != null;
|
||||
status.innerHTML = ok
|
||||
? '<span class="text-success"><i class="bi bi-check-circle me-1"></i>Données extraites</span>'
|
||||
: '<span class="text-warning"><i class="bi bi-exclamation-triangle me-1"></i>Données non trouvées</span>';
|
||||
})
|
||||
.catch(() => { status.textContent = 'Erreur de lecture'; status.style.color = '#ef4444'; });
|
||||
.catch(() => { status.innerHTML = '<span class="text-danger">Erreur de lecture</span>'; });
|
||||
}
|
||||
|
||||
function applyPlateData(weight_g, hours, minutes) {
|
||||
if (weight_g != null) document.getElementById('weight_g').value = weight_g;
|
||||
if (hours != null) document.getElementById('hours').value = hours;
|
||||
if (minutes != null) document.getElementById('minutes').value = minutes;
|
||||
recalc();
|
||||
}
|
||||
|
||||
function renderPlateSelector(plates) {
|
||||
// Supprimer un éventuel sélecteur précédent
|
||||
document.getElementById('plateSelectorWrap')?.remove();
|
||||
const wrap = document.createElement('div');
|
||||
wrap.id = 'plateSelectorWrap';
|
||||
wrap.className = 'mt-3 border rounded p-3 bg-light';
|
||||
wrap.innerHTML = '<div class="fw-semibold small mb-2"><i class="bi bi-layers me-1"></i>Choisir le plateau à imprimer :</div>';
|
||||
const grid = document.createElement('div');
|
||||
grid.className = 'd-flex flex-wrap gap-2';
|
||||
plates.forEach(p => {
|
||||
const btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.className = 'btn btn-sm btn-outline-warning';
|
||||
// Pastilles filaments
|
||||
const dots = p.filaments.map(f =>
|
||||
`<span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:${f.color || '#ccc'};border:1px solid #aaa;margin-right:2px"></span>`
|
||||
).join('');
|
||||
btn.innerHTML = `${dots}<strong>Plateau ${p.index}</strong> — ${p.weight_g} g — ${Math.floor(p.print_time_s/3600)}h${String(Math.floor((p.print_time_s%3600)/60)).padStart(2,'0')}m`;
|
||||
btn.addEventListener('click', () => {
|
||||
document.querySelectorAll('#plateSelectorWrap button').forEach(b => b.classList.replace('btn-warning','btn-outline-warning'));
|
||||
btn.classList.replace('btn-outline-warning','btn-warning');
|
||||
applyPlateData(p.weight_g, Math.floor(p.print_time_s/3600), Math.floor((p.print_time_s%3600)/60));
|
||||
document.getElementById('parseStatus').innerHTML =
|
||||
`<span class="text-success"><i class="bi bi-check-circle me-1"></i>Plateau ${p.index} appliqué</span>`;
|
||||
});
|
||||
grid.appendChild(btn);
|
||||
});
|
||||
wrap.appendChild(grid);
|
||||
// Insérer après le card d'import
|
||||
document.querySelector('.card.border-warning').insertAdjacentElement('afterend', wrap);
|
||||
}
|
||||
|
||||
// ── Coefficient design ────────────────────────────────────────────────────────
|
||||
@@ -595,5 +676,94 @@ const _initMargin = parseFloat(document.getElementById('gross_margin_pct').value
|
||||
updateDMLabel(_initDM);
|
||||
updateMarginDisplay(_initMargin);
|
||||
if (document.getElementById('weight_g').value) recalc();
|
||||
|
||||
// ── Nextcloud File Picker ─────────────────────────────────────────────────────
|
||||
(function () {
|
||||
const modal = document.getElementById('ncPickerModal');
|
||||
const body = document.getElementById('ncPickerBody');
|
||||
const bread = document.getElementById('ncBreadcrumb');
|
||||
const sfInput = document.getElementById('sourceFile');
|
||||
|
||||
if (!modal) return;
|
||||
|
||||
let currentPath = '';
|
||||
|
||||
async function browse(path) {
|
||||
body.innerHTML = '<div class="text-center py-4"><div class="spinner-border spinner-border-sm"></div> Chargement…</div>';
|
||||
try {
|
||||
const r = await fetch('/api/nextcloud/browse?path=' + encodeURIComponent(path));
|
||||
const d = await r.json();
|
||||
if (d.error) {
|
||||
body.innerHTML = `<div class="alert alert-danger m-3">${d.error}</div>`;
|
||||
return;
|
||||
}
|
||||
currentPath = d.path;
|
||||
renderBreadcrumb(d.breadcrumb);
|
||||
renderItems(d.items);
|
||||
} catch (e) {
|
||||
body.innerHTML = `<div class="alert alert-danger m-3">${e.message}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderBreadcrumb(crumbs) {
|
||||
bread.innerHTML = crumbs.map((c, i) =>
|
||||
i < crumbs.length - 1
|
||||
? `<li class="breadcrumb-item"><a href="#" class="nc-crumb" data-path="${c.path}">${c.name}</a></li>`
|
||||
: `<li class="breadcrumb-item active">${c.name}</li>`
|
||||
).join('');
|
||||
bread.querySelectorAll('.nc-crumb').forEach(a =>
|
||||
a.addEventListener('click', e => { e.preventDefault(); browse(a.dataset.path); }));
|
||||
}
|
||||
|
||||
const EXT_ICONS = {
|
||||
'3mf': 'bi-box-seam text-warning',
|
||||
'stl': 'bi-triangle text-info',
|
||||
'step': 'bi-bezier2 text-success',
|
||||
'stp': 'bi-bezier2 text-success',
|
||||
'obj': 'bi-grid-3x3 text-secondary',
|
||||
'gcode':'bi-code-square text-danger',
|
||||
'dir': 'bi-folder-fill text-warning',
|
||||
};
|
||||
|
||||
function renderItems(items) {
|
||||
if (!items.length) {
|
||||
body.innerHTML = '<div class="text-center text-muted py-4">Dossier vide.</div>';
|
||||
return;
|
||||
}
|
||||
body.innerHTML = '';
|
||||
const list = document.createElement('div');
|
||||
list.className = 'list-group list-group-flush';
|
||||
items.forEach(item => {
|
||||
const icon = EXT_ICONS[item.ext] || 'bi-file text-muted';
|
||||
const size = item.is_dir ? '' : ` <small class="text-muted">(${(item.size/1024/1024).toFixed(1)} Mo)</small>`;
|
||||
const el = document.createElement('button');
|
||||
el.type = 'button';
|
||||
el.className = 'list-group-item list-group-item-action d-flex align-items-center gap-2 py-2';
|
||||
el.innerHTML = `<i class="bi ${icon} fs-5" style="flex-shrink:0"></i>
|
||||
<span class="flex-fill text-start">${item.name}${size}</span>
|
||||
${item.is_dir ? '<i class="bi bi-chevron-right text-muted"></i>' : '<i class="bi bi-check2-circle text-success"></i>'}`;
|
||||
if (item.is_dir) {
|
||||
el.addEventListener('click', () => browse(item.path));
|
||||
} else {
|
||||
el.addEventListener('click', () => selectFile(item));
|
||||
}
|
||||
list.appendChild(el);
|
||||
});
|
||||
body.appendChild(list);
|
||||
}
|
||||
|
||||
function selectFile(item) {
|
||||
sfInput.value = item.path;
|
||||
// Auto-remplir le nom de commande si vide
|
||||
const nameField = document.getElementById('jobName');
|
||||
if (!nameField.value) {
|
||||
nameField.value = item.name.replace(/\.[^.]+$/, '');
|
||||
}
|
||||
bootstrap.Modal.getInstance(modal).hide();
|
||||
}
|
||||
|
||||
// Ouvrir = charger le dossier racine
|
||||
modal.addEventListener('show.bs.modal', () => { if (!currentPath) browse(''); });
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
Reference in New Issue
Block a user