v1.1.0 upd connections instruct and checkup

This commit is contained in:
2026-09-19 17:36:33 +03:00
parent db9be1a90a
commit 956dd1a75b
5 changed files with 222 additions and 239 deletions

View File

@ -1,88 +1,45 @@
// lib/common/services/network.service.js // lib/common/services/network.service.js
// //
// Проверка подключения через эндпоинты бэкенда: // Определение сети, в которой находится устройство.
// //
// - /api/is_server — публичный сервер unitprint.ru. // - внутри локальной сети принтера (Wi-Fi UnitPrintLocal)
// Отвечает {"is_server":true} → интернет есть, скан доступен; // домен unitprint.ru резолвится на 192.168.20.1 — локальный сервер печати;
// - local.unitprint.ru/api/is_local — локальный сервер печати. // - из интернета домен отвечает публичным адресом 95.165.135.233.
// Отвечает {"is_local":true} → устройство в Wi-Fi принтера (UnitPrintLocal),
// печать доступна.
// //
// Сайт всегда отдаётся с unitprint.ru, поэтому проверка локалки ходит // Клиент не может сам отличить эти случаи по IP (сертификаты, mixed content),
// на отдельный хост local.unitprint.ru: вне локальной сети принтера // поэтому спрашиваем сам сервер: GET /api/is_local → { "is_local": true|false }.
// он просто недоступен. // Эндпоинт обязан быть на обоих инстансах:
// // локальный → { "is_local": true }
// Скан работает только через интернет, печать — только через локальную сеть. // публичный → { "is_local": false }
export const LOCAL_HOST = 'local.unitprint.ru'; export const DOMAIN = 'unitprint.ru';
export const PUBLIC_IP = '95.165.135.233';
export const LOCAL_IP = '192.168.20.1';
export const LOCAL_WIFI_NAME = 'UnitPrintLocal'; export const LOCAL_WIFI_NAME = 'UnitPrintLocal';
/** /**
* GET с таймаутом, читаем JSON. * Определяет текущий режим сети.
* @returns {Promise<any|null>} null — если хост недоступен или ошибка. * @returns {Promise<'local' | 'internet' | 'offline'>}
* - 'local' → отвечающий сервер — локальный (домен → 192.168.20.1)
* - 'internet' → отвечающий сервер — публичный (домен → 95.165.135.233)
* - 'offline' → сервер вообще не ответил
*/ */
async function getJson(url, timeoutMs = 4000) { export async function detectNetwork(timeoutMs = 6000) {
if (typeof fetch === 'undefined') return null; if (typeof fetch === 'undefined') return 'offline';
const controller = new AbortController(); const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs); const timer = setTimeout(() => controller.abort(), timeoutMs);
try { try {
const res = await fetch(url, { const res = await fetch('/api/is_local', {
method: 'GET',
cache: 'no-store', cache: 'no-store',
signal: controller.signal, signal: controller.signal,
}); });
if (!res.ok) return null; if (!res.ok) return 'offline';
return await res.json(); const data = await res.json();
if (typeof data?.is_local !== 'boolean') return 'offline';
return data.is_local ? 'local' : 'internet';
} catch { } catch {
return null; return 'offline';
} finally { } finally {
clearTimeout(timer); clearTimeout(timer);
} }
} }
/**
* Доступность хоста в режиме 'no-cors': CORS не мешает, важен сам факт ответа.
*/
async function canReach(url, timeoutMs = 4000) {
if (typeof fetch === 'undefined') return false;
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
await fetch(url, {
method: 'GET',
mode: 'no-cors',
cache: 'no-store',
signal: controller.signal,
});
return true;
} catch {
return false;
} finally {
clearTimeout(timer);
}
}
/**
* Устройство в локальной сети принтера:
* local.unitprint.ru/api/is_local отвечает {"is_local":true}.
*/
export async function isLocalNetwork() {
const ts = Date.now();
const httpsUrl = `https://${LOCAL_HOST}/api/is_local?ping=${ts}`;
const httpUrl = `http://${LOCAL_HOST}/api/is_local?ping=${ts}`;
const data = (await getJson(httpsUrl, 3000)) ?? (await getJson(httpUrl, 3000));
if (data) return data.is_local === true;
// JSON не читается (CORS / самоподписанный сертификат) —fallback:
// сам факт ответа local.unitprint.ru означает, что мы в локалке.
return canReach(httpsUrl, 3000);
}
/**
* Есть интернет: публичный сервер отвечает на /api/is_server.
*/
export async function isServerReachable() {
const data = await getJson(`/api/is_server?ping=${Date.now()}`, 5000);
return data?.is_server === true;
}

View File

@ -1,24 +1,33 @@
<!-- lib/common/ui/NetworkGate.svelte -->
<!-- <!--
lib/common/ui/NetworkGate.svelte Экран-блокиратор: не пускает дальше, пока устройство не окажется в нужной сети.
Экран-блокиратор: не пускает дальше, пока устройство не окажется в нужной сети.
mode: mode:
- 'need-internet' — нужен интернет (/api/is_server отвечает true). Используется в скане. - 'need-internet' — нужен интернет (домен → 95.165.135.233). Используется в скане.
- 'need-local' — нужна локальная сеть принтера (local.unitprint.ru/api/is_local отвечает true). Перед печатью. Минималистичный экран: иконка, заголовок, одна строка-подсказка и спиннер,
Сам опрашивает подключение; как только оно появилось — показывает «Подключено» и вызывает onReady(). всё отцентровано по высоте. Без карточки шагов.
- 'need-local' — нужна локальная сеть принтера (домен → 192.168.20.1). Перед печатью.
Полные инструкции с карточкой шагов.
Определяем сеть через GET /api/is_local — быстро и без проблем с сертификатами.
Сам опрашивает подключение; как только оно появилось — показывает «Подключено»
и вызывает onReady(). Кнопки «проверить снова» нет — проверка автоматическая.
--> -->
<script> <script>
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { isLocalNetwork, isServerReachable, LOCAL_WIFI_NAME } from '$lib/common/services/network.service.js'; import { detectNetwork, LOCAL_WIFI_NAME } from '$lib/common/services/network.service.js';
let { let {
/** 'need-internet' | 'need-local' */ /** 'need-internet' | 'need-local' */
mode, mode,
onReady = () => {}, onReady = () => {},
onBack = () => {}, onBack = () => {},
pollMs = 3000, pollMs = 2000,
} = $props(); } = $props();
let success = $state(false); /** 'checking' | 'waiting' | 'success' */
let status = $state('checking');
let busy = false; let busy = false;
let finished = false; let finished = false;
let timer = null; let timer = null;
@ -29,12 +38,16 @@ mode:
if (busy || finished) return; if (busy || finished) return;
busy = true; busy = true;
try { try {
const ok = isLocalMode ? await isLocalNetwork() : await isServerReachable(); const net = await detectNetwork();
const ok = isLocalMode ? net === 'local' : net === 'internet';
if (ok) { if (ok) {
finished = true; finished = true;
success = true; status = 'success';
stopPoll(); stopPoll();
setTimeout(() => onReady?.(), 900); setTimeout(() => onReady?.(), 900);
} else if (status === 'checking') {
status = 'waiting';
startPoll();
} }
} finally { } finally {
busy = false; busy = false;
@ -52,7 +65,6 @@ mode:
onMount(() => { onMount(() => {
check(); check();
startPoll();
const onVis = () => { const onVis = () => {
if (document.visibilityState === 'visible') check(); if (document.visibilityState === 'visible') check();
}; };
@ -69,8 +81,8 @@ mode:
<button type="button" class="back-btn" onclick={() => onBack?.()}>←</button> <button type="button" class="back-btn" onclick={() => onBack?.()}>←</button>
</header> </header>
<main class="gate-content"> <main class="gate-content" class:centered={!isLocalMode}>
{#if success} {#if status === 'success'}
<div class="state-block"> <div class="state-block">
<div class="success-icon"> <div class="success-icon">
<svg width="42" height="42" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="20 6 9 17 4 12"></polyline></svg> <svg width="42" height="42" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="20 6 9 17 4 12"></polyline></svg>
@ -79,13 +91,16 @@ mode:
<p class="state-sub">Продолжаем…</p> <p class="state-sub">Продолжаем…</p>
</div> </div>
{:else} {:else}
<!-- Инструкции видны сразу: первая проверка идёт фоном -->
<div class="icon-wrap {isLocalMode ? 'local' : 'internet'}"> <div class="icon-wrap {isLocalMode ? 'local' : 'internet'}">
{#if isLocalMode} {#if isLocalMode}
<!-- Wi-Fi: 4 дуги + точка -->
<svg width="44" height="44" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"> <svg width="44" height="44" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M5 12.55a11 11 0 0 1 14.08 0"></path> <path d="M0.78 8.25a17 17 0 0 1 22.44 0"></path>
<path d="M1.42 9a16 16 0 0 1 21.16 0"></path> <path d="M3.42 11.25a13 13 0 0 1 17.16 0"></path>
<path d="M8.53 16.11a6 6 0 0 1 6.95 0"></path> <path d="M6.06 14.25a9 9 0 0 1 11.88 0"></path>
<line x1="12" y1="20" x2="12.01" y2="20"></line> <path d="M8.7 17.25a5 5 0 0 1 6.6 0"></path>
<line x1="12" y1="20.5" x2="12.01" y2="20.5"></line>
</svg> </svg>
{:else} {:else}
<svg width="44" height="44" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"> <svg width="44" height="44" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
@ -102,25 +117,33 @@ mode:
<p>Печать доступна только в локальной сети принтера. Подключите устройство к сети:</p> <p>Печать доступна только в локальной сети принтера. Подключите устройство к сети:</p>
{:else} {:else}
<h2>Необходимо подключение к интернету</h2> <h2>Необходимо подключение к интернету</h2>
<p>Сканирование доступно только онлайн</p> <p>
Убедитесь, что вы отключены от сети Wi-Fi принтера {LOCAL_WIFI_NAME},
и включите мобильный интернет или подключитесь к другому Wi-Fi.
</p>
{/if} {/if}
</div> </div>
<!-- Карточка шагов — только для локального режима (печать) -->
{#if isLocalMode} {#if isLocalMode}
<div class="steps-card"> <div class="steps-card">
<div class="step"> <div class="step">
<span class="step-num">1</span> <span class="step-num">1</span>
<span class="step-body"> <div class="step-body">
<span class="step-text">Подключитесь к Wi-Fi</span> <span class="step-text">Подключитесь к Wi-Fi</span>
<!-- имя сети без рамки и без копирования; не кликабельно:
из браузера iOS/Android нельзя открыть настройки Wi-Fi -->
<span class="wifi-name"> <span class="wifi-name">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"> <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M5 12.55a11 11 0 0 1 14.08 0"></path> <path d="M0.78 8.25a17 17 0 0 1 22.44 0"></path>
<path d="M8.53 16.11a6 6 0 0 1 6.95 0"></path> <path d="M3.42 11.25a13 13 0 0 1 17.16 0"></path>
<line x1="12" y1="20" x2="12.01" y2="20"></line> <path d="M6.06 14.25a9 9 0 0 1 11.88 0"></path>
<path d="M8.7 17.25a5 5 0 0 1 6.6 0"></path>
<line x1="12" y1="20.5" x2="12.01" y2="20.5"></line>
</svg> </svg>
{LOCAL_WIFI_NAME} {LOCAL_WIFI_NAME}
</span> </span>
</span> </div>
</div> </div>
<div class="step"> <div class="step">
<span class="step-num">2</span> <span class="step-num">2</span>
@ -135,7 +158,15 @@ mode:
<div class="waiting-row"> <div class="waiting-row">
<div class="spinner small"></div> <div class="spinner small"></div>
<span>Ожидание подключения…</span> <span>
{#if status === 'checking'}
Проверка подключения…
{:else if isLocalMode}
Ожидание подключения…
{:else}
Ожидание интернета…
{/if}
</span>
</div> </div>
{/if} {/if}
</main> </main>
@ -180,6 +211,11 @@ mode:
align-items: center; align-items: center;
gap: 22px; gap: 22px;
} }
/* Интернет-режим: без карточки шагов, весь блок по центру экрана */
.gate-content.centered {
justify-content: center;
}
/* ── состояние success ── */ /* ── состояние success ── */
.state-block { .state-block {
flex: 1; flex: 1;
@ -209,6 +245,7 @@ mode:
from { transform: scale(0.5); opacity: 0; } from { transform: scale(0.5); opacity: 0; }
to { transform: scale(1); opacity: 1; } to { transform: scale(1); opacity: 1; }
} }
/* ── состояние ожидания ── */ /* ── состояние ожидания ── */
.icon-wrap { .icon-wrap {
width: 92px; width: 92px;
@ -219,6 +256,7 @@ mode:
color: #fff; color: #fff;
margin-top: 6px; margin-top: 6px;
} }
.gate-content.centered .icon-wrap { margin-top: 0; }
.icon-wrap.local { .icon-wrap.local {
background: linear-gradient(135deg, #5b8cff 0%, #38c6ff 100%); background: linear-gradient(135deg, #5b8cff 0%, #38c6ff 100%);
box-shadow: 0 16px 38px -12px rgba(72, 150, 255, 0.55); box-shadow: 0 16px 38px -12px rgba(72, 150, 255, 0.55);
@ -230,7 +268,8 @@ mode:
.gate-info { text-align: center; display: flex; flex-direction: column; gap: 8px; } .gate-info { text-align: center; display: flex; flex-direction: column; gap: 8px; }
.gate-info h2 { margin: 0; font-size: 21px; font-weight: 700; letter-spacing: -0.01em; } .gate-info h2 { margin: 0; font-size: 21px; font-weight: 700; letter-spacing: -0.01em; }
.gate-info p { margin: 0; font-size: 14px; line-height: 1.5; color: #9ca3af; } .gate-info p { margin: 0; font-size: 14px; line-height: 1.5; color: #9ca3af; }
/* ── единый блок шагов ── */
/* ── единый блок шагов (только локальный режим) ── */
.steps-card { .steps-card {
width: 100%; width: 100%;
display: flex; display: flex;
@ -257,19 +296,22 @@ mode:
font-size: 13px; font-size: 13px;
font-weight: 700; font-weight: 700;
} }
.step-body { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 6px; } .step-body { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 8px; }
.step-text { font-size: 15px; line-height: 1.45; color: #e5e9f0; } .step-text { font-size: 15px; line-height: 1.45; color: #e5e9f0; }
/* имя Wi-Fi без рамки, просто иконка + текст */ .step-text strong { color: #fff; }
/* ── имя Wi-Fi: без рамки, иконка + текст, не кликабельно ── */
.wifi-name { .wifi-name {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
color: #7db4ff; color: #7db4ff;
font-size: 15px; font-size: 16px;
font-weight: 700; font-weight: 700;
letter-spacing: 0.01em; letter-spacing: 0.01em;
} }
.wifi-name svg { flex-shrink: 0; } .wifi-name svg { flex-shrink: 0; }
/* ── ожидание ── */ /* ── ожидание ── */
.waiting-row { .waiting-row {
display: flex; display: flex;
@ -280,6 +322,7 @@ mode:
font-size: 14px; font-size: 14px;
font-weight: 600; font-weight: 600;
} }
/* ── спиннер ── */ /* ── спиннер ── */
.spinner { .spinner {
border-radius: 50%; border-radius: 50%;
@ -289,6 +332,7 @@ mode:
} }
.spinner.small { width: 18px; height: 18px; border-width: 2px; } .spinner.small { width: 18px; height: 18px; border-width: 2px; }
@keyframes gate-spin { to { transform: rotate(360deg); } } @keyframes gate-spin { to { transform: rotate(360deg); } }
@media (max-width: 380px) { @media (max-width: 380px) {
.gate-info h2 { font-size: 19px; } .gate-info h2 { font-size: 19px; }
} }

View File

@ -205,5 +205,5 @@
<div class="version"> <div class="version">
<span>v{backendVersion}</span> <span>v{backendVersion}</span>
<span>v1.0.9</span> <span>v1.1.0</span>
</div> </div>

View File

@ -7,7 +7,6 @@
import PaymentPage from '$lib/common/ui/PaymentPage.svelte'; import PaymentPage from '$lib/common/ui/PaymentPage.svelte';
import { plFiles, plCopies } from '$lib/common/utils/pricing.util.js'; import { plFiles, plCopies } from '$lib/common/utils/pricing.util.js';
import NetworkGate from '$lib/common/ui/NetworkGate.svelte'; import NetworkGate from '$lib/common/ui/NetworkGate.svelte';
import { isLocalNetwork } from '$lib/common/services/network.service.js';
let { onBack = () => {} } = $props(); let { onBack = () => {} } = $props();
const store = createPrintStore(); const store = createPrintStore();
@ -38,32 +37,27 @@
/* ── Проверка сети перед отправкой печати ── /* ── Проверка сети перед отправкой печати ──
* Печать работает только в локальной сети принтера (домен → 192.168.20.1). * Печать работает только в локальной сети принтера (домен → 192.168.20.1).
* Если «Я оплатил(а)» нажата не в той сети — показываем экран подключения * Гейт открывается мгновенно по нажатию «Я оплатил(а)» и сам внутри себя
* и автоматически отправляем печать, как только сеть появится. * делает первую проверку: если мы уже в локалке — короткая зелёная вспышка
* и печать ушла; если нет — инструкции и авто-проход при появлении сети.
*/ */
/** @type {null | 'checking' | 'blocked'} */ /** true → показываем экран подключения к принтеру */
let printNetGate = $state(null); let printNetGate = $state(false);
async function handleConfirmPayment() { function handleConfirmPayment() {
if (printNetGate === 'checking') return; // Гейт сам проверит сеть: если уже локальная — сразу пропустит и напечатает,
printNetGate = 'checking'; // если нет — покажет инструкции и будет ждать появления сети.
const net = await isLocalNetwork(); printNetGate = true;
if (net === 'local') {
printNetGate = null;
store.submitPrint();
} else {
printNetGate = 'blocked';
}
} }
function handleGateReady() { function handleGateReady() {
// сеть принтера появилась — пропускаем пользователя и сразу печатаем // сеть принтера появилась — пропускаем пользователя и сразу печатаем
printNetGate = null; printNetGate = false;
store.submitPrint(); store.submitPrint();
} }
function handleGateBack() { function handleGateBack() {
printNetGate = null; // возврат к странице оплаты printNetGate = false; // возврат к странице оплаты
} }
</script> </script>
@ -71,15 +65,8 @@
<!-- Страница прогресса печати (после оплаты) --> <!-- Страница прогресса печати (после оплаты) -->
<PrintProgress {store} onBack={onBack} /> <PrintProgress {store} onBack={onBack} />
{:else if store.showPayment} {:else if store.showPayment}
{#if printNetGate === 'blocked'} {#if printNetGate}
<NetworkGate mode="need-local" onReady={handleGateReady} onBack={handleGateBack} /> <NetworkGate mode="need-local" onReady={handleGateReady} onBack={handleGateBack} />
{:else if printNetGate === 'checking'}
<div class="page-container">
<div class="net-checking">
<div class="net-spinner"></div>
<p>Проверка подключения…</p>
</div>
</div>
{:else} {:else}
<PaymentPage <PaymentPage
totalPrice={store.totalPrice} totalPrice={store.totalPrice}
@ -142,9 +129,6 @@
{/if} {/if}
<style> <style>
.net-checking { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 18px; padding: 40px 20px; color: #8b93a1; font-size: 15px; font-weight: 600; text-align: center; }
.net-spinner { width: 42px; height: 42px; border-radius: 50%; border: 3px solid rgba(255,255,255,0.12); border-top-color: #6366f1; animation: net-spin 0.8s linear infinite; }
@keyframes net-spin { to { transform: rotate(360deg); } }
.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; } .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; } .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 { background: none; border: none; color: #6366f1; font-size: 15px; font-weight: 600; cursor: pointer; padding: 8px 4px; margin-right: auto; min-height: 44px; }

View File

@ -109,17 +109,18 @@
/** /**
* Инструкция очистки лотка: * Инструкция очистки лотка:
* - если сервер явно просит awaiting_clear_output И есть уже напечатанные файлы; * - если сервер явно просит awaiting_clear_output И есть уже напечатанные файлы;
* - для двусторонней печати — до первого прохода, * - для двусторонней печати — ДО первого прохода,
* пока ещё не было flip/pickup/done И есть напечатанные файлы. * пока ещё не было flip/pickup/done И есть напечатанные файлы
* (т.е. файл не первый в очереди и лоток уже занят).
*/ */
const showClearOutput = $derived( const showClearOutput = $derived(
store.jobActive && store.jobActive &&
!firstSideFinished && (
( jobPhase === 'waiting_start' ||
(hasClearPhase && hasPrintedFiles) || jobPhase === 'awaiting_clear_output' ||
(isDuplex && hasPrintedFiles && ['waiting_start', 'printing', 'awaiting_clear_output'].includes(jobPhase ?? '')) jobFiles.some((f) => f.status === 'awaiting_clear_output')
) )
); );
const showFlip = $derived( const showFlip = $derived(
store.jobActive && store.jobActive &&
@ -149,11 +150,40 @@
showPickup && jobPhase === 'awaiting_pickup' showPickup && jobPhase === 'awaiting_pickup'
); );
const clearButtonLabel = $derived( const clearButtonLabel = $derived(
jobPhase === 'waiting_start' jobPhase === 'waiting_start'
? 'Убрал листы — начать печать' ? 'Начать печать'
: 'Убрал листы — продолжать' : 'Убрал листы — продолжать'
);
/**
* В какой карточке файла показывать инструкцию.
* Инструкция всегда живёт ВНУТРИ карточки соответствующего файла,
* а не отдельным блоком над списком.
*/
const flipTarget = $derived(
showFlip ? (jobFiles.find((f) => f.status === 'awaiting_flip') ?? null) : null
); );
const pickupTarget = $derived(
showPickup ? (jobFiles.find((f) => f.status === 'awaiting_pickup') ?? null) : null
);
const clearTarget = $derived.by(() => {
if (!showClearOutput) return null;
return (
jobFiles.find((f) => f.status === 'awaiting_clear_output') ??
(jobPhase !== 'waiting_start' ? jobFiles.find((f) => f.status === 'printing') : undefined) ??
jobFiles.find((f) => f.status === 'queued') ??
null
);
});
/** @returns {'clear' | 'flip' | 'pickup' | null} */
function hintFor(f) {
if (showClearOutput && clearTarget === f) return 'clear';
if (showFlip && flipTarget === f) return 'flip';
if (showPickup && pickupTarget === f) return 'pickup';
return null;
}
const HOLD_MS = 1500; const HOLD_MS = 1500;
let hold = $state(0); let hold = $state(0);
@ -201,57 +231,15 @@
</header> </header>
<main class="list"> <main class="list">
{#if showClearOutput}
<section class="instruction-card clear">
<p class="instruction-title">
Уберите распечатанные листы из выходного лотка
</p>
<ol class="instruction-list">
<li>Уберите распечатанные листы из выходного лотка</li>
{#if clearActionAvailable}
<li>Нажмите кнопку ниже для продолжения</li>
{:else}
<li>Дождитесь завершения первого прохода</li>
{/if}
</ol>
</section>
{:else if showFlip}
<section class="instruction-card flip">
<p class="instruction-title">
Переложите бумагу
</p>
<ol class="instruction-list">
<li>Не переворачивая, положите листы в слот ручной подачи</li>
{#if flipActionAvailable}
<li>Нажмите кнопку ниже для печати обратной стороны</li>
{:else}
<li>Дождитесь завершения текущего этапа</li>
{/if}
</ol>
</section>
{:else if showPickup}
<section class="instruction-card pickup">
<p class="instruction-title">
Печать завершается...
</p>
<ol class="instruction-list">
<li>Дождитесь завершения печати</li>
{#if pickupActionAvailable}
<li>Нажмите кнопку ниже для подтверждения</li>
{:else}
<li>Дождитесь завершения текущего этапа</li>
{/if}
</ol>
</section>
{/if}
{#if store.job} {#if store.job}
{#each store.job.files as f, i (i + '-' + f.name)} {#each store.job.files as f, i (i + '-' + f.name)}
{@const b = badge(f.name)} {@const b = badge(f.name)}
{@const hint = hintFor(f)}
{@const isActive = f.status === 'awaiting_flip' {@const isActive = f.status === 'awaiting_flip'
|| f.status === 'awaiting_clear_output' || f.status === 'awaiting_clear_output'
|| f.status === 'awaiting_pickup' || f.status === 'awaiting_pickup'
|| f.status === 'printing'} || f.status === 'printing'
|| hint !== null}
<section class="file-card" class:current={isActive}> <section class="file-card" class:current={isActive}>
<div class="file-row"> <div class="file-row">
@ -262,6 +250,41 @@
</span> </span>
</div> </div>
{#if hint === 'clear'}
<ol class="pickup-hint clear-hint">
{#if jobPhase !== 'waiting_start'}
<li>Уберите распечатанные листы из выходного лотка</li>
{/if}
{#if clearActionAvailable}
<li>
Нажмите кнопку ниже
{jobPhase === 'waiting_start' ? 'для начала печати' : 'для продолжения'}
</li>
{:else}
<li>Дождитесь завершения первого прохода</li>
{/if}
</ol>
{:else if hint === 'flip'}
<ol class="pickup-hint flip-hint">
<li>Не переворачивая, положите листы в слот ручной подачи</li>
{#if flipActionAvailable}
<li>Нажмите кнопку ниже для печати обратной стороны</li>
{:else}
<li>Дождитесь завершения текущего этапа</li>
{/if}
</ol>
{:else if hint === 'pickup'}
<ol class="pickup-hint">
<li>Дождитесь завершения печати</li>
{#if pickupActionAvailable}
<li>Нажмите кнопку ниже для подтверждения</li>
{:else}
<li>Дождитесь завершения текущего этапа</li>
{/if}
</ol>
{/if}
{#if f.error} {#if f.error}
<p class="file-error">{f.error}</p> <p class="file-error">{f.error}</p>
{/if} {/if}
@ -271,19 +294,17 @@
</main> </main>
<footer class="actions"> <footer class="actions">
{#if canCancel} <label class="auto-row">
<label class="auto-row"> <input
<input type="checkbox"
type="checkbox" checked={store.autoContinue}
checked={store.autoContinue} onchange={(e) => store.setAutoContinue(e.target.checked)}
onchange={(e) => store.setAutoContinue(e.target.checked)} />
/> <span>Автоматически продолжать печать</span>
<span>Автоматически продолжать печать</span> </label>
</label>
{#if store.autoContinue} {#if store.autoContinue}
<p class="auto-warning">⚠️ Отправленные файлы нельзя будет отменить</p> <p class="auto-warning">⚠️ Отправленные файлы нельзя будет отменить</p>
{/if}
{/if} {/if}
{#if store.jobActive} {#if store.jobActive}
@ -338,6 +359,9 @@
.st-error { color: #ef4444; } .st-error { color: #ef4444; }
.st-cancelled { color: #6b7280; } .st-cancelled { color: #6b7280; }
.clear-hint { color: #f59e0b; }
.flip-hint { color: #f59e0b; }
.auto-warning { .auto-warning {
margin: -4px 0 8px; margin: -4px 0 8px;
text-align: center; text-align: center;
@ -400,47 +424,6 @@
text-align: left; text-align: left;
} }
.instruction-card {
border-radius: 16px;
padding: 14px;
display: flex;
flex-direction: column;
gap: 8px;
border: 1px solid rgba(245, 158, 11, 0.35);
background: rgba(245, 158, 11, 0.08);
text-align: left;
}
.instruction-card.pickup {
border-color: rgba(165, 180, 252, 0.35);
background: rgba(99, 102, 241, 0.08);
}
.instruction-title {
margin: 0;
font-size: 15px;
font-weight: 700;
color: #f59e0b;
}
.instruction-card.pickup .instruction-title {
color: #a5b4fc;
}
.instruction-list {
margin: 0;
padding-left: 22px;
color: #f59e0b;
font-size: 14px;
line-height: 1.6;
font-weight: 600;
text-align: left;
}
.instruction-card.pickup .instruction-list {
color: #a5b4fc;
}
.file-card { .file-card {
border-radius: 16px; border-radius: 16px;
background: rgba(255,255,255,0.04); background: rgba(255,255,255,0.04);
@ -496,6 +479,21 @@
user-select: none; user-select: none;
} }
/* Подсказки живут ВНУТРИ карточки файла, текст — по левому краю */
.pickup-hint {
margin: 0;
padding-left: 22px;
color: #eab308;
font-size: 14px;
line-height: 1.6;
font-weight: 600;
text-align: left;
}
.pickup-hint li {
text-align: left;
}
.file-error { .file-error {
margin: 0; margin: 0;
color: #fca5a5; color: #fca5a5;