v1.1.0 upd connections instruct and checkup

This commit is contained in:
2026-09-19 17:36:33 +03:00
parent db9be1a90a
commit 1a3104e15e
4 changed files with 139 additions and 165 deletions

View File

@ -1,88 +1,45 @@
// lib/common/services/network.service.js
//
// Проверка подключения через эндпоинты бэкенда:
// Определение сети, в которой находится устройство.
//
// - /api/is_server — публичный сервер unitprint.ru.
// Отвечает {"is_server":true} → интернет есть, скан доступен;
// - local.unitprint.ru/api/is_local — локальный сервер печати.
// Отвечает {"is_local":true} → устройство в Wi-Fi принтера (UnitPrintLocal),
// печать доступна.
// - внутри локальной сети принтера (Wi-Fi UnitPrintLocal)
// домен unitprint.ru резолвится на 192.168.20.1 — локальный сервер печати;
// - из интернета домен отвечает публичным адресом 95.165.135.233.
//
// Сайт всегда отдаётся с unitprint.ru, поэтому проверка локалки ходит
// на отдельный хост local.unitprint.ru: вне локальной сети принтера
// он просто недоступен.
//
// Скан работает только через интернет, печать — только через локальную сеть.
// Клиент не может сам отличить эти случаи по IP (сертификаты, mixed content),
// поэтому спрашиваем сам сервер: 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';
/**
* 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) {
if (typeof fetch === 'undefined') return null;
export async function detectNetwork(timeoutMs = 6000) {
if (typeof fetch === 'undefined') return 'offline';
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
const res = await fetch(url, {
method: 'GET',
const res = await fetch('/api/is_local', {
cache: 'no-store',
signal: controller.signal,
});
if (!res.ok) return null;
return await res.json();
if (!res.ok) return 'offline';
const data = await res.json();
if (typeof data?.is_local !== 'boolean') return 'offline';
return data.is_local ? 'local' : 'internet';
} catch {
return null;
return 'offline';
} finally {
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

@ -15,7 +15,7 @@ mode:
mode,
onReady = () => {},
onBack = () => {},
pollMs = 3000,
pollMs = 700,
} = $props();
let success = $state(false);
@ -69,76 +69,109 @@ mode:
<button type="button" class="back-btn" onclick={() => onBack?.()}>←</button>
</header>
<main class="gate-content">
{#if success}
<div class="state-block">
<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>
</div>
<p class="state-title ok">{isLocalMode ? 'Принтер найден' : 'Интернет подключен'}</p>
<p class="state-sub">Продолжаем…</p>
</div>
{:else}
<div class="icon-wrap {isLocalMode ? 'local' : 'internet'}">
{#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">
<path d="M5 12.55a11 11 0 0 1 14.08 0"></path>
<path d="M1.42 9a16 16 0 0 1 21.16 0"></path>
<path d="M8.53 16.11a6 6 0 0 1 6.95 0"></path>
<line x1="12" y1="20" x2="12.01" y2="20"></line>
</svg>
{: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">
<circle cx="12" cy="12" r="10"></circle>
<line x1="2" y1="12" x2="22" y2="12"></line>
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"></path>
</svg>
{/if}
</div>
<main class="gate-content">
{#if status === 'success'}
<div class="state-block">
<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>
</div>
<p class="state-title ok">{isLocalMode ? 'Принтер найден' : 'Интернет подключен'}</p>
<p class="state-sub">Продолжаем…</p>
</div>
{:else}
<!-- Инструкции видны сразу: первая проверка идёт фоном -->
<div class="icon-wrap {isLocalMode ? 'local' : 'internet'}">
{#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">
<path d="M5 12.55a11 11 0 0 1 14.08 0"></path>
<path d="M1.42 9a16 16 0 0 1 21.16 0"></path>
<path d="M8.53 16.11a6 6 0 0 1 6.95 0"></path>
<line x1="12" y1="20" x2="12.01" y2="20"></line>
</svg>
{: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">
<circle cx="12" cy="12" r="10"></circle>
<line x1="2" y1="12" x2="22" y2="12"></line>
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"></path>
</svg>
{/if}
</div>
<div class="gate-info">
{#if isLocalMode}
<h2>Подключитесь к принтеру</h2>
<p>Печать доступна только в локальной сети принтера. Подключите устройство к сети:</p>
{:else}
<h2>Необходимо подключение к интернету</h2>
<p>Сканирование доступно только онлайн</p>
{/if}
</div>
<div class="gate-info">
{#if isLocalMode}
<h2>Нет связи с принтером</h2>
<p>Печать доступна только в локальной сети принтера. Подключите устройство к сети:</p>
{:else}
<h2>Необходимо подключение к интернету</h2>
<p>Убедитесь, что вы отключены от локальной сети Wi-Fi принтера.</p>
{/if}
</div>
{#if isLocalMode}
<div class="steps-card">
<div class="step">
<span class="step-num">1</span>
<span class="step-body">
<span class="step-text">Подключитесь к Wi-Fi</span>
<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">
<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>
<line x1="12" y1="20" x2="12.01" y2="20"></line>
</svg>
{LOCAL_WIFI_NAME}
</span>
</span>
</div>
<div class="step">
<span class="step-num">2</span>
<span class="step-text">Отключите мобильный интернет</span>
</div>
<div class="step">
<span class="step-num">3</span>
<span class="step-text">Отключите VPN</span>
</div>
</div>
{/if}
<div class="steps">
{#if isLocalMode}
<div class="step">
<span class="step-num">1</span>
<div class="step-body">
<span class="step-text">Подключитесь к Wi-Fi</span>
<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">
<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>
<line x1="12" y1="20" x2="12.01" y2="20"></line>
</svg>
{LOCAL_WIFI_NAME}
</span>
<button
type="button"
class="copy-btn"
class:success={copySuccess}
onclick={copyWifiName}
title={copySuccess ? 'Скопировано' : 'Скопировать название сети'}
aria-label={copySuccess ? 'Скопировано' : 'Скопировать название сети'}
>
{copySuccess ? '✓' : '📋'}
</button>
</div>
</div>
</div>
<div class="step">
<span class="step-num">2</span>
<span class="step-text">Отключите мобильный интернет</span>
</div>
<div class="step">
<span class="step-num">3</span>
<span class="step-text">Отключите VPN</span>
</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>
{/if}
</div>
<div class="waiting-row">
<div class="spinner small"></div>
<span>Ожидание подключения…</span>
</div>
{/if}
</main>
<div class="waiting-row">
<div class="spinner small"></div>
<span>
{#if status === 'checking'}
Проверка подключения…
{:else if isLocalMode}
Ожидание подключения к принтеру…
{:else}
Ожидание интернета…
{/if}
</span>
</div>
<p class="auto-hint">Как только подключение появится — продолжим автоматически</p>
<button type="button" class="retry-btn" onclick={check}>Проверить снова</button>
{/if}
</main>
</div>
<style>

View File

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

View File

@ -7,7 +7,6 @@
import PaymentPage from '$lib/common/ui/PaymentPage.svelte';
import { plFiles, plCopies } from '$lib/common/utils/pricing.util.js';
import NetworkGate from '$lib/common/ui/NetworkGate.svelte';
import { isLocalNetwork } from '$lib/common/services/network.service.js';
let { onBack = () => {} } = $props();
const store = createPrintStore();
@ -38,32 +37,27 @@
/* ── Проверка сети перед отправкой печати ──
* Печать работает только в локальной сети принтера (домен → 192.168.20.1).
* Если «Я оплатил(а)» нажата не в той сети — показываем экран подключения
* и автоматически отправляем печать, как только сеть появится.
* Гейт открывается мгновенно по нажатию «Я оплатил(а)» и сам внутри себя
* делает первую проверку: если мы уже в локалке — короткая зелёная вспышка
* и печать ушла; если нет — инструкции и авто-проход при появлении сети.
*/
/** @type {null | 'checking' | 'blocked'} */
let printNetGate = $state(null);
/** true → показываем экран подключения к принтеру */
let printNetGate = $state(false);
async function handleConfirmPayment() {
if (printNetGate === 'checking') return;
printNetGate = 'checking';
const net = await isLocalNetwork();
if (net === 'local') {
printNetGate = null;
store.submitPrint();
} else {
printNetGate = 'blocked';
}
function handleConfirmPayment() {
// Гейт сам проверит сеть: если уже локальная — сразу пропустит и напечатает,
// если нет — покажет инструкции и будет ждать появления сети.
printNetGate = true;
}
function handleGateReady() {
// сеть принтера появилась — пропускаем пользователя и сразу печатаем
printNetGate = null;
printNetGate = false;
store.submitPrint();
}
function handleGateBack() {
printNetGate = null; // возврат к странице оплаты
printNetGate = false; // возврат к странице оплаты
}
</script>
@ -71,15 +65,8 @@
<!-- Страница прогресса печати (после оплаты) -->
<PrintProgress {store} onBack={onBack} />
{:else if store.showPayment}
{#if printNetGate === 'blocked'}
{#if printNetGate}
<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}
<PaymentPage
totalPrice={store.totalPrice}
@ -142,9 +129,6 @@
{/if}
<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; }
.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; }