Files
web-app/src/lib/pages/print/PrintProgress.svelte
2026-09-18 21:30:02 +03:00

306 lines
11 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!-- lib/pages/print/PrintProgress.svelte -->
<script>
let { store, onBack = () => {} } = $props();
const currentStatus = $derived.by(() => {
if (!store.job) return 'idle';
const f = store.job.files.find(f => f.status === 'printing');
if (f) return 'printing';
const q = store.job.files.find(f => f.status === 'queued');
return q ? 'queued' : store.job.phase;
});
const OFFICE_EXT = ['DOC', 'DOCX', 'ODT', 'XLS', 'XLSX', 'ODS', 'PPT', 'PPTX', 'ODP'];
const STATUS_LABEL = {
queued: 'В очереди',
printing: 'Отправка...',
sent: 'Отправлен',
awaiting_clear_output: 'Уберите распечатанные листы',
awaiting_flip: 'Переложите бумагу',
awaiting_pickup: 'Печать завершается...',
done: 'Готов',
error: 'Ошибка',
cancelled: 'Отменено'
};
function badge(name) {
const e = (name.split('.').pop() || '').toUpperCase();
return OFFICE_EXT.includes(e) ? e : null;
}
$effect(() => {
if (!store.jobActive) return;
const int = setInterval(() => store.refreshJob(), 1000);
return () => clearInterval(int);
});
const canCancel = $derived(
store.job?.files.some(f => f.status === 'queued' || f.status === 'printing')
);
const HOLD_MS = 1500;
let hold = $state(0);
let raf = 0;
let t0 = 0;
function startHold(e) {
if (!store.jobActive || !canCancel) return;
e.preventDefault();
e.currentTarget.setPointerCapture?.(e.pointerId);
t0 = performance.now();
const step = (now) => {
hold = Math.min(1, (now - t0) / HOLD_MS);
if (hold >= 1) { raf = 0; hold = 0; store.cancelJob(); return; }
raf = requestAnimationFrame(step);
};
raf = requestAnimationFrame(step);
}
function endHold() {
if (raf) cancelAnimationFrame(raf);
raf = 0;
hold = 0;
}
function finish() {
store.resetJob();
onBack();
}
</script>
<div class="page-container">
<header class="header">
<button class="back-btn" disabled={store.jobActive} onclick={finish}></button>
<h1>Печать</h1>
</header>
<main class="list">
{#if store.job}
{#each store.job.files as f, i (i + '-' + f.name)}
{@const b = badge(f.name)}
{@const isActive = f.status === 'awaiting_flip'
|| f.status === 'awaiting_clear_output'
|| f.status === 'awaiting_pickup'
|| f.status === 'printing'}
<section class="file-card" class:current={isActive}>
<div class="file-row">
{#if b}<span class="type-badge">{b}</span>{/if}
<span class="file-name" title={f.name}>{f.name}</span>
<span class="file-status st-{f.status}">
{STATUS_LABEL[f.status] ?? f.status}
</span>
</div>
{#if f.status === 'awaiting_pickup'}
<ol class="pickup-hint">
<li>Дождитесь завершения печати</li>
<li>Нажмите кнопку ниже для подтверждения</li>
</ol>
{:else if f.status === 'awaiting_clear_output'}
<ol class="pickup-hint clear-hint">
<li>Уберите распечатанные листы из выходного лотка</li>
<li>Нажмите кнопку ниже для продолжения</li>
</ol>
{:else if f.status === 'awaiting_flip'}
<ol class="pickup-hint flip-hint">
<li>Не переворачивая, положите листы в слот ручной подачи</li>
<li>Нажмите кнопку ниже для печати обратной стороны</li>
</ol>
{/if}
{#if f.error}
<p class="file-error">{f.error}</p>
{/if}
</section>
{/each}
{/if}
</main>
<footer class="actions">
<label class="auto-row">
<input
type="checkbox"
checked={store.autoContinue}
onchange={(e) => store.setAutoContinue(e.target.checked)}
/>
<span>Автоматически продолжать печать</span>
</label>
{#if store.autoContinue}
<p class="auto-warning">⚠️ Отправленные файлы нельзя будет отменить</p>
{/if}
{#if store.jobActive}
{#if store.job?.phase === 'awaiting_pickup'}
<button class="next-btn" onclick={store.advanceJob}>
Печать закончена — далее
</button>
{:else if store.job?.phase === 'awaiting_clear_output'}
<button class="next-btn next-btn-clear" onclick={store.advanceJob}>
Убрал листы — продолжать
</button>
{:else if store.job?.phase === 'awaiting_flip'}
<button class="next-btn next-btn-flip" onclick={store.advanceJob}>
Переложил — печатать обратную сторону
</button>
{:else}
<button class="next-btn next-btn-disabled" disabled>
{currentStatus === 'printing' ? 'Отправка...' : 'Ожидание...'}
</button>
{/if}
<button
class="cancel-btn"
disabled={!canCancel}
onpointerdown={canCancel ? startHold : null}
onpointerup={endHold}
onpointercancel={endHold}
onpointerleave={endHold}
oncontextmenu={(e) => e.preventDefault()}
>
<span class="cancel-fill" style:width="{hold * 100}%"></span>
<span class="cancel-label">{canCancel ? 'Отмена' : 'Отмена недоступна'}</span>
</button>
{#if canCancel}
<p class="hold-hint">Удерживайте для отмены печати</p>
{/if}
{:else}
<button class="home-btn" onclick={finish}>На главную</button>
{/if}
</footer>
</div>
<style>
.st-awaiting_clear_output { color: #f59e0b; }
.st-awaiting_flip { color: #f59e0b; }
.st-awaiting_pickup { color: #a5b4fc; }
.st-sent { color: #8b93a1; }
.st-done { color: #22c55e; }
.st-queued { color: #8b93a1; }
.st-printing { color: #a5b4fc; }
.st-error { color: #ef4444; }
.st-cancelled { color: #6b7280; }
.clear-hint { color: #f59e0b; }
.flip-hint { color: #f59e0b; }
.auto-warning {
margin: -4px 0 8px;
text-align: center;
color: #f59e0b;
font-size: 13px;
font-weight: 600;
}
.page-container {
width: 100%; min-height: 100dvh;
display: flex; flex-direction: column;
background: radial-gradient(120% 60% at 50% -10%, rgba(99,102,241,0.18) 0%, transparent 60%);
font-family: system-ui, -apple-system, sans-serif;
color: #f5f7fa;
}
.header { padding: 24px 20px 8px; display: flex; align-items: center; position: relative; }
.back-btn {
background: none; border: none; color: #6366f1;
font-size: 15px; font-weight: 600; cursor: pointer;
padding: 8px 4px; margin-right: auto; min-height: 44px;
}
.back-btn:disabled { opacity: 0.3; cursor: not-allowed; }
.header h1 {
margin: 0; font-size: 28px; font-weight: 700;
position: absolute; left: 50%; transform: translateX(-50%);
white-space: nowrap;
}
.list { flex: 1; padding: 24px 20px 16px; display: flex; flex-direction: column; gap: 14px; overflow-y: auto; }
.file-card {
border-radius: 16px; background: rgba(255,255,255,0.04);
border: 1px solid rgba(255,255,255,0.08);
padding: 14px; display: flex; flex-direction: column; gap: 8px;
transition: border-color 0.2s ease, background 0.2s ease;
}
.file-card.current { border-color: rgba(99,102,241,0.45); background: rgba(99,102,241,0.07); }
.file-row { display: flex; align-items: center; gap: 12px; min-width: 0; }
.file-name {
flex: 1; min-width: 0; font-size: 15px; font-weight: 500;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.file-status { flex-shrink: 0; font-size: 14px; font-weight: 700; }
.type-badge {
flex-shrink: 0; padding: 3px 7px; border-radius: 6px;
background: rgba(99,102,241,0.2); border: 1px solid rgba(99,102,241,0.35);
color: #a5b4fc; font-size: 10px; font-weight: 700;
letter-spacing: 0.04em; line-height: 1.2; user-select: none;
}
.pickup-hint {
margin: 0; padding-left: 22px;
color: #eab308; font-size: 14px; line-height: 1.6; font-weight: 600;
}
.file-error { margin: 0; color: #fca5a5; font-size: 12px; line-height: 1.4; }
.actions { padding: 16px 20px 32px; display: flex; flex-direction: column; gap: 12px; }
.auto-row {
display: flex; align-items: center; justify-content: center; gap: 10px;
color: #a9b0c0; font-size: 14px; font-weight: 600;
user-select: none; cursor: pointer;
}
.auto-row input { width: 18px; height: 18px; accent-color: #6366f1; cursor: pointer; }
.next-btn {
width: 100%; padding: 16px 24px; border-radius: 16px;
font-size: 16px; font-weight: 700; cursor: pointer; border: none; color: #fff;
background: linear-gradient(135deg, #5b8cff 0%, #38c6ff 100%);
box-shadow: 0 8px 24px -6px rgba(72,150,255,0.4);
}
.next-btn-clear {
background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%);
box-shadow: 0 8px 24px -6px rgba(245,158,11,0.4);
}
.next-btn-flip {
background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%);
box-shadow: 0 8px 24px -6px rgba(245,158,11,0.4);
}
.next-btn-disabled {
opacity: 0.5; cursor: wait; pointer-events: none;
background: linear-gradient(135deg, #475569 0%, #64748b 100%);
box-shadow: none;
}
.cancel-btn {
position: relative; overflow: hidden; width: 100%; padding: 16px 24px;
border-radius: 16px; border: 1px solid rgba(239,68,68,0.35);
background: rgba(239,68,68,0.08); color: #fca5a5;
font-size: 16px; font-weight: 700; cursor: pointer;
touch-action: none; user-select: none;
-webkit-user-select: none; -webkit-touch-callout: none;
-webkit-tap-highlight-color: transparent;
}
.cancel-btn:disabled { opacity: 0.4; cursor: not-allowed; pointer-events: none; }
.cancel-fill {
position: absolute; left: 0; top: 0; bottom: 0; width: 0%;
background: rgba(239,68,68,0.35); pointer-events: none;
}
.cancel-label { position: relative; }
.hold-hint { margin: -4px 0 0; text-align: center; color: rgba(139,147,161,0.6); font-size: 11px; }
.home-btn {
width: 100%; padding: 16px 24px; border-radius: 16px;
font-size: 16px; font-weight: 700; cursor: pointer; border: none; color: #fff;
background: linear-gradient(135deg, #22c55e 0%, #16a34a 100%);
box-shadow: 0 8px 24px -6px rgba(34,197,94,0.4);
}
</style>