v1.1.0 upd connections instruct and checkup
This commit is contained in:
@ -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;
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,24 +1,30 @@
|
|||||||
|
<!-- 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). Перед печатью.
|
- 'need-local' — нужна локальная сеть принтера (домен → 192.168.20.1). Перед печатью.
|
||||||
Сам опрашивает подключение; как только оно появилось — показывает «Подключено» и вызывает onReady().
|
|
||||||
|
Определяем сеть через 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 copySuccess = $state(false);
|
||||||
|
|
||||||
let busy = false;
|
let busy = false;
|
||||||
let finished = false;
|
let finished = false;
|
||||||
let timer = null;
|
let timer = null;
|
||||||
@ -29,12 +35,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 +62,6 @@ mode:
|
|||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
check();
|
check();
|
||||||
startPoll();
|
|
||||||
const onVis = () => {
|
const onVis = () => {
|
||||||
if (document.visibilityState === 'visible') check();
|
if (document.visibilityState === 'visible') check();
|
||||||
};
|
};
|
||||||
@ -62,6 +71,39 @@ mode:
|
|||||||
stopPoll();
|
stopPoll();
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
async function copyWifiName() {
|
||||||
|
let success = false;
|
||||||
|
if (navigator.clipboard && window.isSecureContext) {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(LOCAL_WIFI_NAME);
|
||||||
|
success = true;
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('Clipboard API failed:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!success) {
|
||||||
|
try {
|
||||||
|
const textArea = document.createElement('textarea');
|
||||||
|
textArea.value = LOCAL_WIFI_NAME;
|
||||||
|
textArea.style.position = 'fixed';
|
||||||
|
textArea.style.left = '-9999px';
|
||||||
|
textArea.style.opacity = '0';
|
||||||
|
document.body.appendChild(textArea);
|
||||||
|
textArea.focus();
|
||||||
|
textArea.select();
|
||||||
|
const deprecated = /** @type {any} */ (document).execCommand;
|
||||||
|
if (deprecated && deprecated.call(document, 'copy')) success = true;
|
||||||
|
document.body.removeChild(textArea);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Fallback copy failed:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (success) {
|
||||||
|
copySuccess = true;
|
||||||
|
setTimeout(() => (copySuccess = false), 2000);
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="gate-container">
|
<div class="gate-container">
|
||||||
@ -70,7 +112,7 @@ mode:
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main class="gate-content">
|
<main class="gate-content">
|
||||||
{#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,6 +121,7 @@ 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}
|
||||||
<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">
|
||||||
@ -98,21 +141,22 @@ mode:
|
|||||||
|
|
||||||
<div class="gate-info">
|
<div class="gate-info">
|
||||||
{#if isLocalMode}
|
{#if isLocalMode}
|
||||||
<h2>Подключитесь к принтеру</h2>
|
<h2>Нет связи с принтером</h2>
|
||||||
<p>Печать доступна только в локальной сети принтера. Подключите устройство к сети:</p>
|
<p>Печать доступна только в локальной сети принтера. Подключите устройство к сети:</p>
|
||||||
{:else}
|
{:else}
|
||||||
<h2>Необходимо подключение к интернету</h2>
|
<h2>Необходимо подключение к интернету</h2>
|
||||||
<p>Сканирование доступно только онлайн</p>
|
<p>Убедитесь, что вы отключены от локальной сети Wi-Fi принтера.</p>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if isLocalMode}
|
|
||||||
<div class="steps-card">
|
<div class="steps-card">
|
||||||
|
{#if isLocalMode}
|
||||||
<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>
|
||||||
<span class="wifi-name">
|
<div class="wifi-chip-row">
|
||||||
|
<span class="wifi-chip">
|
||||||
<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="16" height="16" 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="M5 12.55a11 11 0 0 1 14.08 0"></path>
|
||||||
<path d="M8.53 16.11a6 6 0 0 1 6.95 0"></path>
|
<path d="M8.53 16.11a6 6 0 0 1 6.95 0"></path>
|
||||||
@ -120,7 +164,18 @@ mode:
|
|||||||
</svg>
|
</svg>
|
||||||
{LOCAL_WIFI_NAME}
|
{LOCAL_WIFI_NAME}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
<button
|
||||||
|
type="button"
|
||||||
|
class="copy-btn"
|
||||||
|
class:success={copySuccess}
|
||||||
|
onclick={copyWifiName}
|
||||||
|
title={copySuccess ? 'Скопировано' : 'Скопировать название сети'}
|
||||||
|
aria-label={copySuccess ? 'Скопировано' : 'Скопировать название сети'}
|
||||||
|
>
|
||||||
|
{copySuccess ? '✓' : '📋'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="step">
|
<div class="step">
|
||||||
<span class="step-num">2</span>
|
<span class="step-num">2</span>
|
||||||
@ -130,12 +185,29 @@ mode:
|
|||||||
<span class="step-num">3</span>
|
<span class="step-num">3</span>
|
||||||
<span class="step-text">Отключите VPN</span>
|
<span class="step-text">Отключите VPN</span>
|
||||||
</div>
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="step">
|
||||||
|
<span class="step-num">1</span>
|
||||||
|
<span class="step-text">Отключитесь от сети <strong>{LOCAL_WIFI_NAME}</strong></span>
|
||||||
|
</div>
|
||||||
|
<div class="step">
|
||||||
|
<span class="step-num">2</span>
|
||||||
|
<span class="step-text">Включите мобильный интернет или подключитесь к другому Wi-Fi</span>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
<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 +252,7 @@ mode:
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 22px;
|
gap: 22px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── состояние success ── */
|
/* ── состояние success ── */
|
||||||
.state-block {
|
.state-block {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
@ -209,6 +282,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;
|
||||||
@ -230,6 +304,7 @@ 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%;
|
||||||
@ -257,19 +332,54 @@ 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: 10px; }
|
||||||
.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; }
|
||||||
.wifi-name {
|
|
||||||
display: inline-flex;
|
/* ── Wi-Fi чип + кнопка копирования ── */
|
||||||
|
.wifi-chip-row { display: flex; align-items: center; gap: 10px; }
|
||||||
|
.wifi-chip {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
color: #7db4ff;
|
padding: 10px 14px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: rgba(91, 140, 255, 0.14);
|
||||||
|
border: 1px solid rgba(91, 140, 255, 0.45);
|
||||||
|
color: #fff;
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
letter-spacing: 0.01em;
|
letter-spacing: 0.01em;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
.wifi-name svg { flex-shrink: 0; }
|
.wifi-chip svg { flex-shrink: 0; color: #7db4ff; }
|
||||||
|
.copy-btn {
|
||||||
|
width: 42px;
|
||||||
|
height: 42px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: rgba(99, 102, 241, 0.2);
|
||||||
|
border: 1px solid rgba(99, 102, 241, 0.4);
|
||||||
|
border-radius: 12px;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 18px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
padding: 0;
|
||||||
|
touch-action: manipulation;
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
}
|
||||||
|
.copy-btn:hover { background: rgba(99, 102, 241, 0.35); border-color: #6366f1; }
|
||||||
|
.copy-btn:active { transform: scale(0.95); }
|
||||||
|
.copy-btn.success { background: rgba(34, 197, 94, 0.25); border-color: rgba(34, 197, 94, 0.5); }
|
||||||
|
|
||||||
/* ── ожидание ── */
|
/* ── ожидание ── */
|
||||||
.waiting-row {
|
.waiting-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
@ -280,6 +390,7 @@ mode:
|
|||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── спиннер ── */
|
/* ── спиннер ── */
|
||||||
.spinner {
|
.spinner {
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
@ -289,6 +400,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; }
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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; }
|
||||||
|
|||||||
@ -109,8 +109,9 @@
|
|||||||
/**
|
/**
|
||||||
* Инструкция очистки лотка:
|
* Инструкция очистки лотка:
|
||||||
* - если сервер явно просит awaiting_clear_output И есть уже напечатанные файлы;
|
* - если сервер явно просит awaiting_clear_output И есть уже напечатанные файлы;
|
||||||
* - для двусторонней печати — до первого прохода,
|
* - для двусторонней печати — ДО первого прохода,
|
||||||
* пока ещё не было flip/pickup/done И есть напечатанные файлы.
|
* пока ещё не было flip/pickup/done И есть напечатанные файлы
|
||||||
|
* (т.е. файл не первый в очереди и лоток уже занят).
|
||||||
*/
|
*/
|
||||||
const showClearOutput = $derived(
|
const showClearOutput = $derived(
|
||||||
store.jobActive &&
|
store.jobActive &&
|
||||||
@ -155,6 +156,35 @@
|
|||||||
: 'Убрал листы — продолжать'
|
: 'Убрал листы — продолжать'
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* В какой карточке файла показывать инструкцию.
|
||||||
|
* Инструкция всегда живёт ВНУТРИ карточки соответствующего файла,
|
||||||
|
* а не отдельным блоком над списком.
|
||||||
|
*/
|
||||||
|
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);
|
||||||
let raf = 0;
|
let raf = 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,35 @@
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{#if hint === 'clear'}
|
||||||
|
<ol class="pickup-hint clear-hint">
|
||||||
|
<li>Уберите распечатанные листы из выходного лотка</li>
|
||||||
|
{#if clearActionAvailable}
|
||||||
|
<li>Нажмите кнопку ниже для продолжения</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,7 +288,6 @@
|
|||||||
</main>
|
</main>
|
||||||
|
|
||||||
<footer class="actions">
|
<footer class="actions">
|
||||||
{#if canCancel}
|
|
||||||
<label class="auto-row">
|
<label class="auto-row">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
@ -284,7 +300,6 @@
|
|||||||
{#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}
|
||||||
{#if pickupActionAvailable}
|
{#if pickupActionAvailable}
|
||||||
@ -338,6 +353,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 +418,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 +473,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;
|
||||||
|
|||||||
Reference in New Issue
Block a user