From 3a949e1099c86d40ba3c7fedff973fc3f1fc4902 Mon Sep 17 00:00:00 2001 From: TraceLumen Date: Thu, 17 Sep 2026 03:50:12 +0300 Subject: [PATCH] add progress print page --- src/lib/pages/print/PrintMenu.svelte | 168 +++---- src/lib/pages/print/PrintProgress.svelte | 184 ++++++++ src/lib/pages/print/store.svelte.js | 533 +++++++++++++---------- 3 files changed, 566 insertions(+), 319 deletions(-) create mode 100644 src/lib/pages/print/PrintProgress.svelte diff --git a/src/lib/pages/print/PrintMenu.svelte b/src/lib/pages/print/PrintMenu.svelte index 92f0954..5daff6e 100644 --- a/src/lib/pages/print/PrintMenu.svelte +++ b/src/lib/pages/print/PrintMenu.svelte @@ -1,97 +1,97 @@ -{#if store.showPayment} - +{#if store.job} + + +{:else if store.showPayment} + +{:else if !store.activeFile} +
+
+ +

Печать

+
+
+ {#if store.files.length === 0} + fileInput.click()} /> + {:else} + {#each store.files as file (file.id)} + store.openPreview(f)} + onPageSelect={(f) => (f.fileType === 'pdf' ? store.openGallery(f) : store.openPreview(f))} + pagesLabel={store.pagesLabel} + /> + {/each} + + {/if} +
+ + +
{/if} -{#if !store.activeFile && !store.showPayment} -
-
- -

Печать

-
- -
- {#if store.files.length === 0} - fileInput.click()} /> - {:else} - {#each store.files as file (file.id)} - store.openPreview(f)} - onPageSelect={(f) => (f.fileType === 'pdf' ? store.openGallery(f) : store.openPreview(f))} - pagesLabel={store.pagesLabel} - /> - {/each} - - {/if} -
- - - - - -
+{#if !store.job} + {/if} - - diff --git a/src/lib/pages/print/PrintProgress.svelte b/src/lib/pages/print/PrintProgress.svelte new file mode 100644 index 0000000..fb02193 --- /dev/null +++ b/src/lib/pages/print/PrintProgress.svelte @@ -0,0 +1,184 @@ + + + +
+
+ +

Печать

+
+ +
+ {#if store.job} + {#each store.job.files as f, i (i + '-' + f.name)} + {@const b = badge(f.name)} +
+
+ {#if b}{b}{/if} + {f.name} + {STATUS_LABEL[f.status] ?? f.status} +
+ {#if f.status === 'awaiting'} +
    +
  1. Возьмите верхний листок
  2. +
  3. Нажмите далее
  4. +
+ {/if} + {#if f.error} +

{f.error}

+ {/if} +
+ {/each} + {/if} +
+ +
+ + + {#if store.jobActive} + {#if store.job?.phase === 'awaiting_pickup'} + + {:else} + + + {/if} + + +

Удерживайте для отмены печати

+ {:else} + + {/if} +
+
+ + diff --git a/src/lib/pages/print/store.svelte.js b/src/lib/pages/print/store.svelte.js index b74e134..7f32092 100644 --- a/src/lib/pages/print/store.svelte.js +++ b/src/lib/pages/print/store.svelte.js @@ -1,5 +1,4 @@ // lib/pages/print/store.svelte.js - import { onDestroy, tick } from 'svelte'; import { getPricePerPage } from '$lib/common/utils/pricing.util.js'; import { detectFileType, pagesLabel as _pagesLabel } from '$lib/common/utils/file.util.js'; @@ -14,261 +13,325 @@ import { loadPdfDocument, renderPageToImage } from '$lib/common/services/pdf.ser * dpi: string, totalPages: number, * selectedPages: Set, pdfDoc: any * }} FileEntry + * @typedef {{ name: string, status: 'queued'|'printing'|'awaiting'|'done'|'error'|'cancelled', error: (string|null) }} JobFileView + * @typedef {{ job_id: string, phase: 'waiting_start'|'printing'|'awaiting_pickup'|'finished'|'cancelled', files: JobFileView[] }} JobView */ export function createPrintStore() { - let files = $state(/** @type {FileEntry[]} */ ([])); - let activeFile = $state(/** @type {FileEntry | null} */ (null)); - let viewMode = $state(/** @type {'gallery' | 'preview'} */ ('gallery')); - let showPayment = $state(false); - let isPrinting = $state(false); - let galleryThumbnails = $state([]); - let galleryLoading = $state(false); - let previewPagesCache = $state({}); - let isPreviewRendering = $state(false); - let uid = 0; + let files = $state(/** @type {FileEntry[]} */ ([])); + let activeFile = $state(/** @type {FileEntry | null} */ (null)); + let viewMode = $state(/** @type {'gallery' | 'preview'} */ ('gallery')); + let showPayment = $state(false); + let isPrinting = $state(false); + let galleryThumbnails = $state([]); + let galleryLoading = $state(false); + let previewPagesCache = $state({}); + let isPreviewRendering = $state(false); - const totalPrice = $derived( - files.reduce((sum, f) => { - const pages = f.selectedPages?.size || 0; - return sum + pages * f.copies * getPricePerPage(pages); - }, 0) - ); + // ── Задание печати (страница прогресса) ── + let job = $state(/** @type {JobView | null} */ (null)); + let autoContinue = $state(false); + let advancing = false; - const extraCopies = $derived(files.reduce((sum, f) => sum + (f.copies - 1), 0)); + const jobActive = $derived(!!job && job.phase !== 'finished' && job.phase !== 'cancelled'); - function updateFile(id, changes) { - files = files.map((f) => (f.id === id ? { ...f, ...changes } : f)); - if (activeFile?.id === id) { - activeFile = files.find((f) => f.id === id); - } - } + let uid = 0; - async function ensurePdfLoaded(entry) { - if (entry.pdfDoc) return entry.pdfDoc; - const pdf = await loadPdfDocument(entry.file); - if (pdf) { - const numPages = pdf.numPages; - updateFile(entry.id, { - pdfDoc: pdf, - totalPages: numPages, - selectedPages: new Set(Array.from({ length: numPages }, (_, i) => i + 1)) - }); - } else { - updateFile(entry.id, { totalPages: 0 }); - } - return pdf; - } + const totalPrice = $derived( + files.reduce((sum, f) => { + const pages = f.selectedPages?.size || 0; + return sum + pages * f.copies * getPricePerPage(pages); + }, 0) + ); + const extraCopies = $derived(files.reduce((sum, f) => sum + (f.copies - 1), 0)); - async function addFiles(fileList) { - if (!fileList || fileList.length === 0) return; - const newFiles = []; + function updateFile(id, changes) { + files = files.map((f) => (f.id === id ? { ...f, ...changes } : f)); + if (activeFile?.id === id) { + activeFile = files.find((f) => f.id === id); + } + } - for (let i = 0; i < fileList.length; i++) { - const file = fileList[i]; - const fType = detectFileType(file); - const isOffice = fType === 'office'; + async function ensurePdfLoaded(entry) { + if (entry.pdfDoc) return entry.pdfDoc; + const pdf = await loadPdfDocument(entry.file); + if (pdf) { + const numPages = pdf.numPages; + updateFile(entry.id, { + pdfDoc: pdf, + totalPages: numPages, + selectedPages: new Set(Array.from({ length: numPages }, (_, i) => i + 1)) + }); + } else { + updateFile(entry.id, { totalPages: 0 }); + } + return pdf; + } - newFiles.push({ - id: ++uid, - file, - // Для офисных файлов превью не создаём - previewUrl: isOffice ? null : URL.createObjectURL(file), - fileType: fType, - expanded: false, - format: 'Авто', - colorMode: 'bw', - qualityIndex: 1, - copies: 1, - sides: 'Односторонняя', - dpi: 'Авто', - // Для офисных файлов: 1 «страница» для расчёта цены, - // реальное кол-во определит CUPS - totalPages: (fType === 'image' || isOffice) ? 1 : 0, - selectedPages: (fType === 'image' || isOffice) ? new Set([1]) : new Set(), - pdfDoc: null - }); - } + async function addFiles(fileList) { + if (!fileList || fileList.length === 0) return; + const newFiles = []; + for (let i = 0; i < fileList.length; i++) { + const file = fileList[i]; + const fType = detectFileType(file); + const isOffice = fType === 'office'; + newFiles.push({ + id: ++uid, + file, + previewUrl: isOffice ? null : URL.createObjectURL(file), + fileType: fType, + expanded: false, + format: 'Авто', + colorMode: 'bw', + qualityIndex: 1, + copies: 1, + sides: 'Односторонняя', + dpi: 'Авто', + totalPages: (fType === 'image' || isOffice) ? 1 : 0, + selectedPages: (fType === 'image' || isOffice) ? new Set([1]) : new Set(), + pdfDoc: null + }); + } + files = [...files, ...newFiles]; + for (const entry of newFiles) { + if (entry.fileType === 'pdf') ensurePdfLoaded(entry); + } + } - files = [...files, ...newFiles]; + function removeFile(id) { + const f = files.find((x) => x.id === id); + if (f?.previewUrl) URL.revokeObjectURL(f.previewUrl); + files = files.filter((x) => x.id !== id); + } + function toggleExpand(id) { + updateFile(id, { expanded: !files.find((f) => f.id === id)?.expanded }); + } - for (const entry of newFiles) { - if (entry.fileType === 'pdf') ensurePdfLoaded(entry); - // office и image — ничего дополнительно не загружаем - } - } + async function openGallery(entry) { + activeFile = entry; + viewMode = 'gallery'; + galleryThumbnails = []; + galleryLoading = true; + previewPagesCache = {}; + await tick(); + const pdf = await ensurePdfLoaded(entry); + if (!pdf || entry.totalPages === 0) { + galleryLoading = false; + return; + } + const thumbs = []; + for (let i = 1; i <= entry.totalPages; i++) { + try { + thumbs.push(await renderPageToImage(pdf, i, 0.4)); + } catch { + thumbs.push(''); + } + galleryThumbnails = [...thumbs]; + } + galleryLoading = false; + } - function removeFile(id) { - const f = files.find((x) => x.id === id); - if (f?.previewUrl) URL.revokeObjectURL(f.previewUrl); - files = files.filter((x) => x.id !== id); - } + async function openPreview(entry) { + activeFile = entry; + viewMode = 'preview'; + previewPagesCache = {}; + galleryThumbnails = []; + await tick(); + if (entry.fileType === 'pdf') { + const pdf = await ensurePdfLoaded(entry); + if (pdf && entry.totalPages > 0) { + preloadPreviewPages(entry, 1, 5); + } + } + } - function toggleExpand(id) { - updateFile(id, { expanded: !files.find((f) => f.id === id)?.expanded }); - } + async function preloadPreviewPages(entry, startPage, count) { + const currentFile = files.find((f) => f.id === entry.id); + const pdf = currentFile?.pdfDoc || entry.pdfDoc; + if (!pdf || isPreviewRendering) return; + isPreviewRendering = true; + const endPage = Math.min(startPage + count - 1, entry.totalPages); + const newCache = {}; + for (let i = startPage; i <= endPage; i++) { + if (previewPagesCache[i]) continue; + try { + newCache[i] = await renderPageToImage(pdf, i, 1.5); + } catch (e) { + console.error(`Failed to render preview page ${i}`, e); + } + } + if (Object.keys(newCache).length > 0) { + previewPagesCache = { ...previewPagesCache, ...newCache }; + } + isPreviewRendering = false; + } - async function openGallery(entry) { - activeFile = entry; - viewMode = 'gallery'; - galleryThumbnails = []; - galleryLoading = true; - previewPagesCache = {}; - await tick(); + function togglePageSelection(pageNum) { + if (!activeFile?.selectedPages) return; + const newSet = new Set(activeFile.selectedPages); + if (newSet.has(pageNum)) { + if (newSet.size > 1) newSet.delete(pageNum); + } else { + newSet.add(pageNum); + } + updateFile(activeFile.id, { selectedPages: newSet }); + } + function selectAllPages() { + if (!activeFile?.totalPages) return; + updateFile(activeFile.id, { + selectedPages: new Set(Array.from({ length: activeFile.totalPages }, (_, i) => i + 1)) + }); + } + function deselectAllPages() { + if (!activeFile) return; + updateFile(activeFile.id, { selectedPages: new Set([1]) }); + } - const pdf = await ensurePdfLoaded(entry); - if (!pdf || entry.totalPages === 0) { - galleryLoading = false; - return; - } + // ─────────────── Отправка на печать + job-протокол ─────────────── - const thumbs = []; - for (let i = 1; i <= entry.totalPages; i++) { - try { - thumbs.push(await renderPageToImage(pdf, i, 0.4)); - } catch { - thumbs.push(''); - } - galleryThumbnails = [...thumbs]; - } - galleryLoading = false; - } + async function submitPrint() { + if (files.length === 0 || isPrinting) return; + isPrinting = true; + const formData = new FormData(); + for (const f of files) { + formData.append('files', f.file, f.file.name); + const pagesValue = + f.fileType === 'office' + ? 'all' + : f.selectedPages && f.selectedPages.size > 0 + ? Array.from(f.selectedPages).sort((a, b) => a - b).join(',') + : 'all'; + formData.append( + 'settings', + JSON.stringify({ + filename: f.file.name, + pages: pagesValue, + copies: f.copies, + colorMode: f.colorMode, + format: f.fileType === 'office' ? 'auto' : f.format, + sides: f.sides ?? 'Односторонняя', + quality: f.qualityIndex, + dpi: f.dpi ?? 'Авто' + }) + ); + } + try { + const res = await fetch('/api/print', { method: 'POST', body: formData }); + const text = await res.text(); + if (!res.ok) { + alert(`❌ Ошибка (${res.status}):\n${text}`); + return; + } + let data; + try { + data = JSON.parse(text); + } catch { + alert('⚠️ Некорректный ответ сервера печати.'); + return; + } + job = data; + showPayment = false; + await advanceJob(); // старт первого файла + } catch { + alert('⚠️ Не удалось связаться с сервером печати.'); + } finally { + isPrinting = false; + } + } - async function openPreview(entry) { - activeFile = entry; - viewMode = 'preview'; - previewPagesCache = {}; - galleryThumbnails = []; - await tick(); + /** Подтверждение: старт первого файла / «забрал документ → печатай следующий» */ + async function advanceJob() { + if (!job || advancing) return; + advancing = true; + try { + const res = await fetch(`/api/print/${job.job_id}/advance`, { method: 'POST' }); + if (res.ok) job = await res.json(); + } catch (e) { + console.error('advance error:', e); + } finally { + advancing = false; + } + } - if (entry.fileType === 'pdf') { - const pdf = await ensurePdfLoaded(entry); - if (pdf && entry.totalPages > 0) { - preloadPreviewPages(entry, 1, 5); - } - } - } + /** Поллинг статуса; при autoContinue сами шлём подтверждение */ + async function refreshJob() { + if (!job) return; + try { + const res = await fetch(`/api/print/${job.job_id}`); + if (!res.ok) return; + job = await res.json(); + if (job.phase === 'waiting_start') { + advanceJob(); + } else if (job.phase === 'awaiting_pickup' && autoContinue) { + advanceJob(); + } + } catch (e) { + console.error('refresh error:', e); + } + } - async function preloadPreviewPages(entry, startPage, count) { - const currentFile = files.find((f) => f.id === entry.id); - const pdf = currentFile?.pdfDoc || entry.pdfDoc; - if (!pdf || isPreviewRendering) return; + async function cancelJob() { + if (!job) return; + try { + const res = await fetch(`/api/print/${job.job_id}/cancel`, { method: 'POST' }); + if (res.ok) job = await res.json(); + } catch (e) { + console.error('cancel error:', e); + } + } - isPreviewRendering = true; - const endPage = Math.min(startPage + count - 1, entry.totalPages); - const newCache = {}; + function resetJob() { + job = null; + autoContinue = false; + files.forEach((f) => f.previewUrl && URL.revokeObjectURL(f.previewUrl)); + files = []; + activeFile = null; + galleryThumbnails = []; + previewPagesCache = {}; + showPayment = false; + } - for (let i = startPage; i <= endPage; i++) { - if (previewPagesCache[i]) continue; - try { - newCache[i] = await renderPageToImage(pdf, i, 1.5); - } catch (e) { - console.error(`Failed to render preview page ${i}`, e); - } - } + function setAutoContinue(v) { + autoContinue = !!v; + } - if (Object.keys(newCache).length > 0) { - previewPagesCache = { ...previewPagesCache, ...newCache }; - } - isPreviewRendering = false; - } + onDestroy(() => { + files.forEach((f) => f.previewUrl && URL.revokeObjectURL(f.previewUrl)); + }); - function togglePageSelection(pageNum) { - if (!activeFile?.selectedPages) return; - const newSet = new Set(activeFile.selectedPages); - if (newSet.has(pageNum)) { - if (newSet.size > 1) newSet.delete(pageNum); - } else { - newSet.add(pageNum); - } - updateFile(activeFile.id, { selectedPages: newSet }); - } - - function selectAllPages() { - if (!activeFile?.totalPages) return; - updateFile(activeFile.id, { - selectedPages: new Set(Array.from({ length: activeFile.totalPages }, (_, i) => i + 1)) - }); - } - - function deselectAllPages() { - if (!activeFile) return; - updateFile(activeFile.id, { selectedPages: new Set([1]) }); - } - - async function submitPrint() { - if (files.length === 0 || isPrinting) return; - isPrinting = true; - - const formData = new FormData(); - for (const f of files) { - formData.append('files', f.file, f.file.name); - - // Для офисных файлов всегда отправляем все страницы — - // CUPS сам разберётся - const pagesValue = - f.fileType === 'office' - ? 'all' - : f.selectedPages && f.selectedPages.size > 0 - ? Array.from(f.selectedPages).sort((a, b) => a - b).join(',') - : 'all'; - - formData.append( - 'settings', - JSON.stringify({ - filename: f.file.name, - pages: pagesValue, - copies: f.copies, - colorMode: f.colorMode, - format: f.fileType === 'office' ? 'auto' : f.format, - sides: f.sides ?? 'Односторонняя', - quality: f.qualityIndex, - dpi: f.dpi ?? 'Авто' - }) - ); - } - - try { - const res = await fetch('/api/print', { method: 'POST', body: formData }); - const text = await res.text(); - alert(res.ok ? `✅ Успешно!\n${text}` : `❌ Ошибка (${res.status}):\n${text}`); - } catch { - alert('⚠️ Не удалось связаться с сервером печати.'); - } finally { - isPrinting = false; - showPayment = false; - } - } - - onDestroy(() => { - files.forEach((f) => f.previewUrl && URL.revokeObjectURL(f.previewUrl)); - }); - - return { - get files() { return files; }, - get activeFile() { return activeFile; }, - get viewMode() { return viewMode; }, - get showPayment() { return showPayment; }, - get isPrinting() { return isPrinting; }, - get totalPrice() { return totalPrice; }, - get extraCopies() { return extraCopies; }, - get galleryThumbnails() { return galleryThumbnails; }, - get galleryLoading() { return galleryLoading; }, - get previewPagesCache() { return previewPagesCache; }, - get isPreviewRendering() { return isPreviewRendering; }, - addFiles, - removeFile, - toggleExpand, - updateFile, - openGallery, - openPreview, - closeOverlay: () => { activeFile = null; galleryThumbnails = []; previewPagesCache = {}; }, - openPayment: () => { if (files.length > 0 && !isPrinting) showPayment = true; }, - hidePayment: () => { showPayment = false; }, - submitPrint, - togglePageSelection, - selectAllPages, - deselectAllPages, - requestPreviewPages: (start, count) => { if (activeFile) preloadPreviewPages(activeFile, start, count); }, - pagesLabel: _pagesLabel - }; + return { + get files() { return files; }, + get activeFile() { return activeFile; }, + get viewMode() { return viewMode; }, + get showPayment() { return showPayment; }, + get isPrinting() { return isPrinting; }, + get totalPrice() { return totalPrice; }, + get extraCopies() { return extraCopies; }, + get galleryThumbnails() { return galleryThumbnails; }, + get galleryLoading() { return galleryLoading; }, + get previewPagesCache() { return previewPagesCache; }, + get isPreviewRendering() { return isPreviewRendering; }, + get job() { return job; }, + get jobActive() { return jobActive; }, + get autoContinue() { return autoContinue; }, + addFiles, + removeFile, + toggleExpand, + updateFile, + openGallery, + openPreview, + closeOverlay: () => { activeFile = null; galleryThumbnails = []; previewPagesCache = {}; }, + openPayment: () => { if (files.length > 0 && !isPrinting) showPayment = true; }, + hidePayment: () => { showPayment = false; }, + submitPrint, + advanceJob, + refreshJob, + cancelJob, + resetJob, + setAutoContinue, + togglePageSelection, + selectAllPages, + deselectAllPages, + requestPreviewPages: (start, count) => { if (activeFile) preloadPreviewPages(activeFile, start, count); }, + pagesLabel: _pagesLabel + }; }