Подключение кнопки Печать к бэкенду + временный прокси через vite.config.js

This commit is contained in:
2026-09-05 01:27:59 +03:00
parent cd8549daa3
commit debeada468
2 changed files with 369 additions and 275 deletions

View File

@ -3,7 +3,7 @@ import { onDestroy, tick } from 'svelte';
import SelectionPage from './preview/SelectionPage.svelte'; import SelectionPage from './preview/SelectionPage.svelte';
import PreviewPage from './preview/PreviewPage.svelte'; import PreviewPage from './preview/PreviewPage.svelte';
let { onBack = () => {}, onPrint = () => {} } = $props(); let { onBack = () => {} } = $props();
// ── Модель данных ── // ── Модель данных ──
/** @typedef {{ id: number, file: File, previewUrl: string | null, fileType: 'image' | 'pdf' | 'other', expanded: boolean, format: 'A4' | 'A5', colorMode: 'bw' | 'color', qualityIndex: number, copies: number, pageRange: string, totalPages: number, selectedPages: Set<number>, pdfDoc: any }} FileEntry */ /** @typedef {{ id: number, file: File, previewUrl: string | null, fileType: 'image' | 'pdf' | 'other', expanded: boolean, format: 'A4' | 'A5', colorMode: 'bw' | 'color', qualityIndex: number, copies: number, pageRange: string, totalPages: number, selectedPages: Set<number>, pdfDoc: any }} FileEntry */
@ -25,7 +25,11 @@ let galleryLoading = $state(false);
let previewPagesCache = $state({}); let previewPagesCache = $state({});
let isPreviewRendering = $state(false); let isPreviewRendering = $state(false);
// ── Состояние кнопки печати ──
let isPrinting = $state(false);
let uid = 0; let uid = 0;
const PRICE_PER_PAGE = 9;
const qualities = [ const qualities = [
{ id: 'low', label: 'Эконом' }, { id: 'low', label: 'Эконом' },
@ -38,27 +42,39 @@ const colorModes = [
{ id: 'color', label: 'Цвет' } { id: 'color', label: 'Цвет' }
]; ];
// ── Вспомогательная функция для реактивного обновления ──
function updateFile(id, changes) {
files = files.map(f => f.id === id ? { ...f, ...changes } : f);
if (activeFile && activeFile.id === id) {
activeFile = files.find(f => f.id === id);
}
}
// ── PDF.js Core ── // ── PDF.js Core ──
async function loadPdfDocument(entry) { async function loadPdfDocument(entry) {
if (entry.pdfDoc) return entry.pdfDoc; if (entry.pdfDoc) return entry.pdfDoc;
try { try {
const pdfjsLib = await import('https://mozilla.github.io/pdf.js/build/pdf.mjs'); const pdfjsLib = await import('https://mozilla.github.io/pdf.js/build/pdf.mjs');
pdfjsLib.GlobalWorkerOptions.workerSrc = 'https://mozilla.github.io/pdf.js/build/pdf.worker.mjs'; pdfjsLib.GlobalWorkerOptions.workerSrc = 'https://mozilla.github.io/pdf.js/build/pdf.worker.mjs';
const arrayBuffer = await entry.file.arrayBuffer(); const arrayBuffer = await entry.file.arrayBuffer();
const loadingTask = pdfjsLib.getDocument({ data: arrayBuffer }); const loadingTask = pdfjsLib.getDocument({ data: arrayBuffer });
const pdf = await loadingTask.promise; const pdf = await loadingTask.promise;
entry.pdfDoc = pdf; entry.pdfDoc = pdf;
entry.totalPages = pdf.numPages;
// Инициализируем выбранные страницы только если они еще не заданы const numPages = pdf.numPages;
if (!entry.selectedPages || entry.selectedPages.size === 0) { const allPages = new Set(Array.from({ length: numPages }, (_, i) => i + 1));
entry.selectedPages = new Set(Array.from({ length: pdf.numPages }, (_, i) => i + 1));
} updateFile(entry.id, {
pdfDoc: pdf,
totalPages: numPages,
selectedPages: allPages
});
return pdf; return pdf;
} catch (err) { } catch (err) {
console.error('PDF load error:', err); console.error('PDF load error:', err);
entry.totalPages = 0; updateFile(entry.id, { totalPages: 0 });
return null; return null;
} }
} }
@ -69,20 +85,16 @@ async function renderPageToImage(pdfDoc, pageNum, scale = 1.0) {
const canvas = document.createElement('canvas'); const canvas = document.createElement('canvas');
const context = canvas.getContext('2d'); const context = canvas.getContext('2d');
const outputScale = window.devicePixelRatio || 1; const outputScale = window.devicePixelRatio || 1;
canvas.width = Math.floor(viewport.width * outputScale); canvas.width = Math.floor(viewport.width * outputScale);
canvas.height = Math.floor(viewport.height * outputScale); canvas.height = Math.floor(viewport.height * outputScale);
canvas.style.width = `${Math.floor(viewport.width)}px`; canvas.style.width = `${Math.floor(viewport.width)}px`;
canvas.style.height = `${Math.floor(viewport.height)}px`; canvas.style.height = `${Math.floor(viewport.height)}px`;
const transform = outputScale !== 1 ? [outputScale, 0, 0, outputScale, 0, 0] : undefined; const transform = outputScale !== 1 ? [outputScale, 0, 0, outputScale, 0, 0] : undefined;
await page.render({ await page.render({
canvasContext: context, canvasContext: context,
viewport, viewport,
transform transform
}).promise; }).promise;
return canvas.toDataURL('image/jpeg', 0.85); return canvas.toDataURL('image/jpeg', 0.85);
} }
@ -93,13 +105,14 @@ async function openPageGallery(entry) {
galleryThumbnails = []; galleryThumbnails = [];
galleryLoading = true; galleryLoading = true;
previewPagesCache = {}; previewPagesCache = {};
await tick(); await tick();
// Используем уже загруженный документ или загружаем при необходимости let pdfDoc = entry.pdfDoc;
if (!entry.pdfDoc) await loadPdfDocument(entry); if (!pdfDoc) {
pdfDoc = await loadPdfDocument(entry);
}
if (!entry.pdfDoc || entry.totalPages === 0) { if (!pdfDoc || entry.totalPages === 0) {
galleryLoading = false; galleryLoading = false;
return; return;
} }
@ -107,7 +120,7 @@ async function openPageGallery(entry) {
const thumbs = []; const thumbs = [];
for (let i = 1; i <= entry.totalPages; i++) { for (let i = 1; i <= entry.totalPages; i++) {
try { try {
const url = await renderPageToImage(entry.pdfDoc, i, 0.4); const url = await renderPageToImage(pdfDoc, i, 0.4);
thumbs.push(url); thumbs.push(url);
galleryThumbnails = [...thumbs]; galleryThumbnails = [...thumbs];
} catch (err) { } catch (err) {
@ -124,15 +137,11 @@ async function openPreview(entry) {
viewMode = 'preview'; viewMode = 'preview';
previewPagesCache = {}; previewPagesCache = {};
galleryThumbnails = []; galleryThumbnails = [];
await tick(); await tick();
if (entry.fileType === 'pdf') { if (entry.fileType === 'pdf') {
// Документ должен быть уже загружен при добавлении файла, let pdfDoc = entry.pdfDoc;
// но на всякий случай проверяем if (!pdfDoc) pdfDoc = await loadPdfDocument(entry);
if (!entry.pdfDoc) await loadPdfDocument(entry);
// Берем totalPages из entry, которое было получено при добавлении файла
if (entry.totalPages > 0) { if (entry.totalPages > 0) {
preloadPreviewPages(entry, 1, 5); preloadPreviewPages(entry, 1, 5);
} }
@ -140,7 +149,10 @@ async function openPreview(entry) {
} }
async function preloadPreviewPages(entry, startPage, count, direction = 'forward') { async function preloadPreviewPages(entry, startPage, count, direction = 'forward') {
if (!entry.pdfDoc || isPreviewRendering) return; const currentFile = files.find(f => f.id === entry.id);
const pdfDoc = currentFile ? currentFile.pdfDoc : entry.pdfDoc;
if (!pdfDoc || isPreviewRendering) return;
isPreviewRendering = true; isPreviewRendering = true;
let pagesToLoad = []; let pagesToLoad = [];
@ -160,7 +172,7 @@ async function preloadPreviewPages(entry, startPage, count, direction = 'forward
const newCacheEntries = {}; const newCacheEntries = {};
for (const pageNum of pagesToLoad) { for (const pageNum of pagesToLoad) {
try { try {
const url = await renderPageToImage(entry.pdfDoc, pageNum, 1.5); const url = await renderPageToImage(pdfDoc, pageNum, 1.5);
newCacheEntries[pageNum] = url; newCacheEntries[pageNum] = url;
} catch (e) { } catch (e) {
console.error(`Failed to render preview page ${pageNum}`, e); console.error(`Failed to render preview page ${pageNum}`, e);
@ -193,22 +205,23 @@ function togglePageSelection(pageNum) {
} else { } else {
newSet.add(pageNum); newSet.add(pageNum);
} }
activeFile.selectedPages = newSet; updateFile(activeFile.id, { selectedPages: newSet });
} }
function selectAllPages() { function selectAllPages() {
if (!activeFile?.totalPages) return; if (!activeFile?.totalPages) return;
activeFile.selectedPages = new Set(Array.from({ length: activeFile.totalPages }, (_, i) => i + 1)); const allPages = new Set(Array.from({ length: activeFile.totalPages }, (_, i) => i + 1));
updateFile(activeFile.id, { selectedPages: allPages });
} }
function deselectAllPages() { function deselectAllPages() {
if (!activeFile) return; if (!activeFile) return;
activeFile.selectedPages = new Set([1]); updateFile(activeFile.id, { selectedPages: new Set([1]) });
} }
function pagesLabel(entry) { function pagesLabel(entry) {
if (!entry.totalPages || entry.totalPages === 0) return 'Все'; if (!entry.totalPages || entry.totalPages === 0) return '...';
if (!entry.selectedPages || entry.selectedPages.size === 0) return 'Все'; if (!entry.selectedPages || entry.selectedPages.size === 0) return 'Нет';
if (entry.selectedPages.size === entry.totalPages) return 'Все'; if (entry.selectedPages.size === entry.totalPages) return 'Все';
if (entry.selectedPages.size === 1) return `стр. ${[...entry.selectedPages][0]}`; if (entry.selectedPages.size === 1) return `стр. ${[...entry.selectedPages][0]}`;
return `${entry.selectedPages.size} из ${entry.totalPages}`; return `${entry.selectedPages.size} из ${entry.totalPages}`;
@ -229,6 +242,7 @@ async function handleFileSelect(event) {
const chosen = input.files; const chosen = input.files;
if (!chosen || chosen.length === 0) return; if (!chosen || chosen.length === 0) return;
const newFiles = [];
for (let i = 0; i < chosen.length; i++) { for (let i = 0; i < chosen.length; i++) {
const fType = detectFileType(chosen[i]); const fType = detectFileType(chosen[i]);
const entry = { const entry = {
@ -246,14 +260,17 @@ async function handleFileSelect(event) {
selectedPages: fType === 'image' ? new Set([1]) : new Set(), selectedPages: fType === 'image' ? new Set([1]) : new Set(),
pdfDoc: null, pdfDoc: null,
}; };
newFiles.push(entry);
}
files.push(entry); files = [...files, ...newFiles];
// ВАЖНО: Загружаем PDF сразу при добавлении, чтобы сохранить totalPages for (const entry of newFiles) {
if (fType === 'pdf') { if (entry.fileType === 'pdf') {
loadPdfDocument(entry); loadPdfDocument(entry);
} }
} }
input.value = ''; input.value = '';
} }
@ -262,7 +279,9 @@ function triggerFileInput(event) {
fileInput.click(); fileInput.click();
} }
function toggleExpanded(file) { file.expanded = !file.expanded; } function toggleExpanded(file) {
updateFile(file.id, { expanded: !file.expanded });
}
function removeFile(file) { function removeFile(file) {
if (file.previewUrl) URL.revokeObjectURL(file.previewUrl); if (file.previewUrl) URL.revokeObjectURL(file.previewUrl);
@ -270,14 +289,23 @@ function removeFile(file) {
} }
// ── Опции печати ── // ── Опции печати ──
function toggleFormat(file, f) { file.format = f; } function toggleFormat(file, f) { updateFile(file.id, { format: f }); }
function toggleColor(file, c) { file.colorMode = c.id; } function toggleColor(file, c) { updateFile(file.id, { colorMode: c.id }); }
function incrementCopies(file) { file.copies += 1; } function incrementCopies(file) { updateFile(file.id, { copies: file.copies + 1 }); }
function decrementCopies(file) { if (file.copies > 1) file.copies -= 1; } function decrementCopies(file) { if (file.copies > 1) updateFile(file.id, { copies: file.copies - 1 }); }
function qualityFillFor(file) { return Math.round((file.qualityIndex / (qualities.length - 1)) * 100); } function qualityFillFor(file) { return Math.round((file.qualityIndex / (qualities.length - 1)) * 100); }
function pageStubFor() { return 0; } function updateQuality(file, val) { updateFile(file.id, { qualityIndex: val }); }
// ── Расчет цены ──
const totalPrice = $derived(() => {
let sum = 0;
for (const f of files) {
const pagesCount = f.selectedPages ? f.selectedPages.size : 0;
sum += pagesCount * f.copies;
}
return sum * PRICE_PER_PAGE;
});
const totalPrice = $derived(`${files.reduce((sum, f) => sum + pageStubFor(f), 0)}₽`);
const extraCopies = $derived(files.reduce((sum, f) => sum + (f.copies - 1), 0)); const extraCopies = $derived(files.reduce((sum, f) => sum + (f.copies - 1), 0));
onDestroy(() => { onDestroy(() => {
@ -298,6 +326,47 @@ function handlePagesClick(file) {
if (file.fileType === 'pdf') openPageGallery(file); if (file.fileType === 'pdf') openPageGallery(file);
else openPreview(file); else openPreview(file);
} }
// ── НОВАЯ ФУНКЦИЯ: Отправка на сервер ──
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 settings = {
filename: f.file.name,
pages: f.selectedPages ? Array.from(f.selectedPages).sort((a, b) => a - b).join(',') : 'all',
copies: f.copies,
colorMode: f.colorMode,
format: f.format
};
formData.append('settings', JSON.stringify(settings));
}
try {
const response = await fetch('/api/print', {
method: 'POST',
body: formData
});
const text = await response.text();
if (response.ok) {
alert(`✅ Успешно отправлено в печать!\n${text}`);
} else {
alert(`❌ Ошибка печати (${response.status}):\n${text}`);
}
} catch (error) {
console.error('Print submission error:', error);
alert(`⚠️ Не удалось связаться с сервером печати.`);
} finally {
isPrinting = false;
}
}
</script> </script>
{#if !activeFile} {#if !activeFile}
@ -366,7 +435,16 @@ function handlePagesClick(file) {
<div class="option-sub quality-sub"> <div class="option-sub quality-sub">
<label class="option-title">Качество {qualities[file.qualityIndex].label}</label> <label class="option-title">Качество {qualities[file.qualityIndex].label}</label>
<input type="range" min={0} max={qualities.length - 1} step={1} bind:value={file.qualityIndex} class="quality-slider" style="--fill: {qualityFillFor(file)}%" /> <input
type="range"
min={0}
max={qualities.length - 1}
step={1}
value={file.qualityIndex}
oninput={(e) => updateQuality(file, parseInt(e.target.value))}
class="quality-slider"
style="--fill: {qualityFillFor(file)}%"
/>
<div class="quality-marks-inline"> <div class="quality-marks-inline">
{#each qualities as q}<span class="quality-mark">{q.label}</span>{/each} {#each qualities as q}<span class="quality-mark">{q.label}</span>{/each}
</div> </div>
@ -375,7 +453,6 @@ function handlePagesClick(file) {
{/if} {/if}
</section> </section>
{/each} {/each}
<button type="button" class="add-file-btn" onclick={triggerFileInput}> Добавить файлы</button> <button type="button" class="add-file-btn" onclick={triggerFileInput}> Добавить файлы</button>
{/if} {/if}
</main> </main>
@ -389,9 +466,17 @@ function handlePagesClick(file) {
{/if} {/if}
<div class="price-summary"> <div class="price-summary">
<span class="summary-itogo">Итого</span> <span class="summary-itogo">Итого</span>
<span class="summary-price">{totalPrice}</span> <span class="summary-price">{totalPrice()}</span>
</div> </div>
<button type="button" class="action-btn secondary" onclick={onPrint}>Печать</button> <!-- ИЗМЕНЕННАЯ КНОПКА -->
<button
type="button"
class="action-btn secondary"
onclick={submitPrint}
disabled={isPrinting || files.length === 0}
>
{isPrinting ? 'Отправка...' : 'Печать'}
</button>
<input type="file" bind:this={fileInput} accept="image/*,.pdf" multiple onchange={handleFileSelect} style="display:none" /> <input type="file" bind:this={fileInput} accept="image/*,.pdf" multiple onchange={handleFileSelect} style="display:none" />
</footer> </footer>
</div> </div>
@ -466,13 +551,13 @@ function handlePagesClick(file) {
.add-file-btn { align-self: center; background: none; border: 1px dashed rgba(255, 255, 255, 0.18); color: #a9b0c0; font-size: 14px; font-weight: 600; padding: 12px 18px; border-radius: 14px; cursor: pointer; margin-top: 8px; } .add-file-btn { align-self: center; background: none; border: 1px dashed rgba(255, 255, 255, 0.18); color: #a9b0c0; font-size: 14px; font-weight: 600; padding: 12px 18px; border-radius: 14px; cursor: pointer; margin-top: 8px; }
.actions { padding: 16px 20px 32px; display: flex; flex-direction: column; gap: 12px; } .actions { padding: 16px 20px 32px; display: flex; flex-direction: column; gap: 12px; }
.file-count { color: #8b93a1; font-size: 13px; margin: 0; text-align: center; line-height: 1.4; } .file-count { color: #8b93a1; font-size: 13px; margin: 0; text-align: center; line-height: 1.4; }
.action-btn { width: 100%; padding: 16px 24px; border-radius: 16px; font-size: 16px; font-weight: 700; cursor: pointer; border: none; color: #fff; } .action-btn { width: 100%; padding: 16px 24px; border-radius: 16px; font-size: 16px; font-weight: 700; cursor: pointer; border: none; color: #fff; transition: opacity 0.2s; }
.action-btn:disabled { opacity: 0.6; cursor: wait; }
.action-btn.primary { background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%); box-shadow: 0 8px 24px -6px rgba(99, 102, 241, 0.5); } .action-btn.primary { background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%); box-shadow: 0 8px 24px -6px rgba(99, 102, 241, 0.5); }
.action-btn.secondary { background: linear-gradient(135deg, #5b8cff 0%, #38c6ff 100%); box-shadow: 0 8px 24px -6px rgba(72, 150, 255, 0.4); } .action-btn.secondary { background: linear-gradient(135deg, #5b8cff 0%, #38c6ff 100%); box-shadow: 0 8px 24px -6px rgba(72, 150, 255, 0.4); }
.price-summary { display: flex; justify-content: space-between; align-items: baseline; width: 100%; } .price-summary { display: flex; justify-content: space-between; align-items: baseline; width: 100%; }
.summary-itogo { font-size: 30px; font-weight: 700; color: #8b93a1; } .summary-itogo { font-size: 30px; font-weight: 700; color: #8b93a1; }
.summary-price { font-size: 38px; font-weight: 800; color: #fff; } .summary-price { font-size: 38px; font-weight: 800; color: #fff; }
.overlay-backdrop { .overlay-backdrop {
position: fixed; inset: 0; z-index: 1000; position: fixed; inset: 0; z-index: 1000;
background: rgba(10, 12, 18, 0.92); backdrop-filter: blur(12px); background: rgba(10, 12, 18, 0.92); backdrop-filter: blur(12px);

View File

@ -24,4 +24,13 @@ export default defineConfig({
} }
}) })
], ],
server: {
proxy: {
'/api': {
target: 'http://localhost:3000',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, '')
}
}
}
}) })