v1.0.8 add network change instruct pages
This commit is contained in:
69
src/lib/common/services/network.service.js
Normal file
69
src/lib/common/services/network.service.js
Normal file
@ -0,0 +1,69 @@
|
||||
// lib/common/services/network.service.js
|
||||
//
|
||||
// Определение сети, в которой находится устройство.
|
||||
//
|
||||
// - внутри локальной сети принтера (Wi-Fi UnitPrintLocal)
|
||||
// домен unitprint.ru резолвится на 192.168.20.1 — локальный сервер печати;
|
||||
// - из интернета домен отвечает публичным адресом 95.165.135.233.
|
||||
//
|
||||
// Скан работает только через интернет, печать — только через локальную сеть.
|
||||
|
||||
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';
|
||||
|
||||
/**
|
||||
* Сетевая доступность URL.
|
||||
* mode: 'no-cors' — чтобы CORS не мешал: важен сам факт ответа сервера,
|
||||
* а не содержимое ответа. Любой HTTP-ответ = хост доступен.
|
||||
*/
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/** Доступен ли локальный сервер печати (192.168.20.1) */
|
||||
export async function isLocalServerReachable() {
|
||||
const ts = Date.now();
|
||||
if (await canReach(`http://${LOCAL_IP}/api/version?ping=${ts}`, 3000)) return true;
|
||||
return canReach(`https://${LOCAL_IP}/api/version?ping=${ts}`, 3000);
|
||||
}
|
||||
|
||||
/** Есть ли интернет (домен отвечает публичным сервером) */
|
||||
export function isInternetReachable() {
|
||||
return canReach(`https://${DOMAIN}/api/version?ping=${Date.now()}`, 5000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Определяет текущий режим сети.
|
||||
* @returns {Promise<'local' | 'internet' | 'offline'>}
|
||||
* - 'local' → устройство в сети принтера (домен → 192.168.20.1)
|
||||
* - 'internet' → устройство в интернете (домен → 95.165.135.233)
|
||||
* - 'offline' → нет ни того, ни другого
|
||||
*/
|
||||
export async function detectNetwork() {
|
||||
const [local, internet] = await Promise.all([
|
||||
isLocalServerReachable(),
|
||||
isInternetReachable(),
|
||||
]);
|
||||
// Локальная сеть в приоритете: если 192.168.20.1 доступен,
|
||||
// значит домен сейчас указывает на локальный сервер печати.
|
||||
if (local) return 'local';
|
||||
if (internet) return 'internet';
|
||||
return 'offline';
|
||||
}
|
||||
444
src/lib/common/ui/NetworkGate.svelte
Normal file
444
src/lib/common/ui/NetworkGate.svelte
Normal file
@ -0,0 +1,444 @@
|
||||
<!--
|
||||
lib/common/ui/NetworkGate.svelte
|
||||
|
||||
Экран-блокиратор: не пускает дальше, пока устройство не окажется в нужной сети.
|
||||
|
||||
mode:
|
||||
- 'need-internet' — нужен интернет (домен → 95.165.135.233). Используется в скане.
|
||||
- 'need-local' — нужна локальная сеть принтера (домен → 192.168.20.1). Перед печатью.
|
||||
|
||||
Как только сеть появилась — показывает «Подключено» и вызывает onReady().
|
||||
-->
|
||||
<script>
|
||||
import { onMount } from 'svelte';
|
||||
import { detectNetwork, LOCAL_WIFI_NAME } from '$lib/common/services/network.service.js';
|
||||
|
||||
let {
|
||||
/** 'need-internet' | 'need-local' */
|
||||
mode,
|
||||
onReady = () => {},
|
||||
onBack = () => {},
|
||||
pollMs = 3000,
|
||||
} = $props();
|
||||
|
||||
/** 'checking' — первая проверка, 'waiting' — ждём сеть, 'success' — сеть появилась */
|
||||
let status = $state('checking');
|
||||
let copySuccess = $state(false);
|
||||
|
||||
let busy = false;
|
||||
let finished = false;
|
||||
let timer = null;
|
||||
|
||||
const isLocalMode = $derived(mode === 'need-local');
|
||||
|
||||
async function check() {
|
||||
if (busy || finished) return;
|
||||
busy = true;
|
||||
try {
|
||||
const net = await detectNetwork();
|
||||
const ok = isLocalMode ? net === 'local' : net === 'internet';
|
||||
if (ok) {
|
||||
finished = true;
|
||||
status = 'success';
|
||||
stopPoll();
|
||||
setTimeout(() => onReady?.(), 900);
|
||||
} else if (status === 'checking') {
|
||||
status = 'waiting';
|
||||
startPoll();
|
||||
}
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
function startPoll() {
|
||||
stopPoll();
|
||||
timer = setInterval(check, pollMs);
|
||||
}
|
||||
|
||||
function stopPoll() {
|
||||
if (timer) clearInterval(timer);
|
||||
timer = null;
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
check();
|
||||
const onVis = () => {
|
||||
if (document.visibilityState === 'visible') check();
|
||||
};
|
||||
document.addEventListener('visibilitychange', onVis);
|
||||
return () => {
|
||||
document.removeEventListener('visibilitychange', onVis);
|
||||
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>
|
||||
|
||||
<div class="gate-container">
|
||||
<header class="gate-header">
|
||||
<button type="button" class="back-btn" onclick={() => onBack?.()}>←</button>
|
||||
<h1>{isLocalMode ? 'Подключение к принтеру' : 'Нет интернета'}</h1>
|
||||
</header>
|
||||
|
||||
<main class="gate-content">
|
||||
{#if status === 'checking'}
|
||||
<div class="state-block">
|
||||
<div class="spinner big"></div>
|
||||
<p class="state-title">Проверка подключения…</p>
|
||||
<p class="state-sub">Определяем, в какой вы сети</p>
|
||||
</div>
|
||||
{:else 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>Убедитесь, что вы отключены от локальной сети Wi-Fi принтера.</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<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>{isLocalMode ? 'Ожидание подключения к принтеру…' : 'Ожидание интернета…'}</span>
|
||||
</div>
|
||||
<p class="auto-hint">Как только подключение появится — продолжим автоматически</p>
|
||||
|
||||
<button type="button" class="retry-btn" onclick={check}>Проверить снова</button>
|
||||
{/if}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.gate-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;
|
||||
}
|
||||
.gate-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;
|
||||
touch-action: manipulation;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
.gate-header h1 {
|
||||
margin: 0;
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.gate-content {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
max-width: 520px;
|
||||
margin: 0 auto;
|
||||
box-sizing: border-box;
|
||||
padding: 28px 20px 40px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 22px;
|
||||
}
|
||||
|
||||
/* ── состояния checking / success ── */
|
||||
.state-block {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 14px;
|
||||
text-align: center;
|
||||
padding: 40px 0;
|
||||
}
|
||||
.state-title { margin: 0; font-size: 19px; font-weight: 700; color: #f5f7fa; }
|
||||
.state-title.ok { color: #4ade80; }
|
||||
.state-sub { margin: 0; font-size: 14px; color: #8b93a1; }
|
||||
.success-icon {
|
||||
width: 88px;
|
||||
height: 88px;
|
||||
border-radius: 50%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: linear-gradient(135deg, #22c55e 0%, #16a34a 100%);
|
||||
box-shadow: 0 16px 38px -12px rgba(34, 197, 94, 0.55);
|
||||
color: #fff;
|
||||
animation: pop-in 0.4s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
}
|
||||
@keyframes pop-in {
|
||||
from { transform: scale(0.5); opacity: 0; }
|
||||
to { transform: scale(1); opacity: 1; }
|
||||
}
|
||||
|
||||
/* ── состояние waiting ── */
|
||||
.icon-wrap {
|
||||
width: 92px;
|
||||
height: 92px;
|
||||
border-radius: 28px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: #fff;
|
||||
margin-top: 6px;
|
||||
}
|
||||
.icon-wrap.local {
|
||||
background: linear-gradient(135deg, #5b8cff 0%, #38c6ff 100%);
|
||||
box-shadow: 0 16px 38px -12px rgba(72, 150, 255, 0.55);
|
||||
}
|
||||
.icon-wrap.internet {
|
||||
background: linear-gradient(135deg, #34d399 0%, #2dd4bf 100%);
|
||||
box-shadow: 0 16px 38px -12px rgba(52, 211, 153, 0.55);
|
||||
}
|
||||
.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 p { margin: 0; font-size: 14px; line-height: 1.5; color: #9ca3af; }
|
||||
|
||||
/* ── шаги ── */
|
||||
.steps { width: 100%; display: flex; flex-direction: column; gap: 10px; }
|
||||
.step {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 14px 16px;
|
||||
border-radius: 16px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
text-align: left;
|
||||
}
|
||||
.step-num {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(99, 102, 241, 0.18);
|
||||
border: 1px solid rgba(99, 102, 241, 0.4);
|
||||
color: #a5b4fc;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.step-text { font-size: 15px; line-height: 1.45; color: #e5e9f0; }
|
||||
.step-text strong { color: #fff; }
|
||||
.step-body { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 10px; }
|
||||
|
||||
/* ── плашка Wi-Fi + копирование ── */
|
||||
.wifi-chip-row { display: flex; align-items: center; gap: 10px; }
|
||||
.wifi-chip {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 12px 14px;
|
||||
border-radius: 12px;
|
||||
background: rgba(91, 140, 255, 0.14);
|
||||
border: 1px solid rgba(91, 140, 255, 0.45);
|
||||
color: #fff;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.01em;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.wifi-chip svg { flex-shrink: 0; color: #7db4ff; }
|
||||
.copy-btn {
|
||||
width: 46px;
|
||||
height: 46px;
|
||||
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: 20px;
|
||||
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 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-top: 4px;
|
||||
color: #8b93a1;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.auto-hint {
|
||||
margin: -12px 0 0;
|
||||
text-align: center;
|
||||
color: rgba(139, 147, 161, 0.6);
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.retry-btn {
|
||||
width: 100%;
|
||||
padding: 16px 24px;
|
||||
border-radius: 16px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: #f5f7fa;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s ease, border-color 0.2s ease;
|
||||
touch-action: manipulation;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
.retry-btn:hover { background: rgba(255, 255, 255, 0.1); border-color: rgba(255, 255, 255, 0.24); }
|
||||
|
||||
/* ── спиннер ── */
|
||||
.spinner {
|
||||
border-radius: 50%;
|
||||
border: 3px solid rgba(255, 255, 255, 0.12);
|
||||
border-top-color: #6366f1;
|
||||
animation: gate-spin 0.8s linear infinite;
|
||||
}
|
||||
.spinner.big { width: 46px; height: 46px; }
|
||||
.spinner.small { width: 18px; height: 18px; border-width: 2px; }
|
||||
@keyframes gate-spin { to { transform: rotate(360deg); } }
|
||||
|
||||
@media (max-width: 380px) {
|
||||
.gate-header h1 { font-size: 22px; }
|
||||
.gate-info h2 { font-size: 19px; }
|
||||
}
|
||||
</style>
|
||||
@ -205,5 +205,5 @@
|
||||
|
||||
<div class="version">
|
||||
<span>v{backendVersion}</span>
|
||||
<span>v1.0.7</span>
|
||||
<span>v1.0.8</span>
|
||||
</div>
|
||||
|
||||
@ -6,6 +6,8 @@
|
||||
import PrintProgress from './PrintProgress.svelte';
|
||||
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 { detectNetwork } from '$lib/common/services/network.service.js';
|
||||
|
||||
let { onBack = () => {} } = $props();
|
||||
const store = createPrintStore();
|
||||
@ -33,18 +35,59 @@
|
||||
return f.sides !== 'Двусторонняя' && s >= 25;
|
||||
})
|
||||
);
|
||||
|
||||
/* ── Проверка сети перед отправкой печати ──
|
||||
* Печать работает только в локальной сети принтера (домен → 192.168.20.1).
|
||||
* Если «Я оплатил(а)» нажата не в той сети — показываем экран подключения
|
||||
* и автоматически отправляем печать, как только сеть появится.
|
||||
*/
|
||||
/** @type {null | 'checking' | 'blocked'} */
|
||||
let printNetGate = $state(null);
|
||||
|
||||
async function handleConfirmPayment() {
|
||||
if (printNetGate === 'checking') return;
|
||||
printNetGate = 'checking';
|
||||
const net = await detectNetwork();
|
||||
if (net === 'local') {
|
||||
printNetGate = null;
|
||||
store.submitPrint();
|
||||
} else {
|
||||
printNetGate = 'blocked';
|
||||
}
|
||||
}
|
||||
|
||||
function handleGateReady() {
|
||||
// сеть принтера появилась — пропускаем пользователя и сразу печатаем
|
||||
printNetGate = null;
|
||||
store.submitPrint();
|
||||
}
|
||||
|
||||
function handleGateBack() {
|
||||
printNetGate = null; // возврат к странице оплаты
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if store.job}
|
||||
<!-- Страница прогресса печати (после оплаты) -->
|
||||
<PrintProgress {store} onBack={onBack} />
|
||||
{:else if store.showPayment}
|
||||
<PaymentPage
|
||||
totalPrice={store.totalPrice}
|
||||
filesCount={store.files.length}
|
||||
onBack={store.hidePayment}
|
||||
onConfirmPayment={store.submitPrint}
|
||||
/>
|
||||
{#if printNetGate === 'blocked'}
|
||||
<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}
|
||||
filesCount={store.files.length}
|
||||
onBack={store.hidePayment}
|
||||
onConfirmPayment={handleConfirmPayment}
|
||||
/>
|
||||
{/if}
|
||||
{:else if !store.activeFile}
|
||||
<div class="page-container">
|
||||
<header class="header">
|
||||
@ -99,6 +142,9 @@
|
||||
{/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; }
|
||||
|
||||
@ -1,8 +1,10 @@
|
||||
<script>
|
||||
import { onDestroy } from 'svelte';
|
||||
import { processImage, createBlobUrl, downloadBlob } from '$lib/common/services/scan.service.js';
|
||||
import NetworkGate from '$lib/common/ui/NetworkGate.svelte';
|
||||
|
||||
let { onBack = () => {} } = $props();
|
||||
let netOk = $state(false);
|
||||
|
||||
/** @type {'upload' | 'unwrapping' | 'unwrapped' | 'deblurring' | 'deblurred' | 'correcting' | 'corrected'} */
|
||||
let stage = $state('upload');
|
||||
@ -116,131 +118,139 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="scan-page">
|
||||
<header class="header">
|
||||
<button class="back-btn" onclick={handleBack}>←</button>
|
||||
<h1>Сканирование документов</h1>
|
||||
</header>
|
||||
{#if !netOk}
|
||||
<NetworkGate
|
||||
mode="need-internet"
|
||||
onReady={() => (netOk = true)}
|
||||
onBack={handleBack}
|
||||
/>
|
||||
{:else}
|
||||
<div class="scan-page">
|
||||
<header class="header">
|
||||
<button class="back-btn" onclick={handleBack}>←</button>
|
||||
<h1>Сканирование документов</h1>
|
||||
</header>
|
||||
|
||||
<div class="scan-content">
|
||||
{#if stage === 'upload'}
|
||||
<div class="upload-area">
|
||||
<div class="upload-icon">
|
||||
<svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
|
||||
<polyline points="17 8 12 3 7 8"/>
|
||||
<line x1="12" y1="3" x2="12" y2="15"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h2>Загрузите изображение документа</h2>
|
||||
<p>Поддерживаются форматы: JPG, PNG, WebP</p>
|
||||
<label class="upload-button">
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onchange={handleFileSelect}
|
||||
style="display: none"
|
||||
/>
|
||||
Выбрать файл
|
||||
</label>
|
||||
</div>
|
||||
{:else if stage === 'unwrapping'}
|
||||
<div class="processing-area">
|
||||
<div class="spinner"></div>
|
||||
<h2>Выпрямление документа...</h2>
|
||||
<p>Пожалуйста, подождите</p>
|
||||
</div>
|
||||
{:else if stage === 'deblurring'}
|
||||
<div class="processing-area">
|
||||
<div class="spinner"></div>
|
||||
<h2>Улучшение четкости...</h2>
|
||||
<p>Удаление размытия и улучшение деталей</p>
|
||||
</div>
|
||||
{:else if stage === 'correcting'}
|
||||
<div class="processing-area">
|
||||
<div class="spinner"></div>
|
||||
<h2>Коррекция освещения...</h2>
|
||||
<p>Удаление теней и улучшение контраста</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="preview-area">
|
||||
<img src={currentBlobUrl} alt="Обработанный документ" class="preview-img" />
|
||||
<div class="scan-content">
|
||||
{#if stage === 'upload'}
|
||||
<div class="upload-area">
|
||||
<div class="upload-icon">
|
||||
<svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
|
||||
<polyline points="17 8 12 3 7 8"/>
|
||||
<line x1="12" y1="3" x2="12" y2="15"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h2>Загрузите изображение документа</h2>
|
||||
<p>Поддерживаются форматы: JPG, PNG, WebP</p>
|
||||
<label class="upload-button">
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onchange={handleFileSelect}
|
||||
style="display: none"
|
||||
/>
|
||||
Выбрать файл
|
||||
</label>
|
||||
</div>
|
||||
{:else if stage === 'unwrapping'}
|
||||
<div class="processing-area">
|
||||
<div class="spinner"></div>
|
||||
<h2>Выпрямление документа...</h2>
|
||||
<p>Пожалуйста, подождите</p>
|
||||
</div>
|
||||
{:else if stage === 'deblurring'}
|
||||
<div class="processing-area">
|
||||
<div class="spinner"></div>
|
||||
<h2>Улучшение четкости...</h2>
|
||||
<p>Удаление размытия и улучшение деталей</p>
|
||||
</div>
|
||||
{:else if stage === 'correcting'}
|
||||
<div class="processing-area">
|
||||
<div class="spinner"></div>
|
||||
<h2>Коррекция освещения...</h2>
|
||||
<p>Удаление теней и улучшение контраста</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="preview-area">
|
||||
<img src={currentBlobUrl} alt="Обработанный документ" class="preview-img" />
|
||||
|
||||
<div class="preview-actions">
|
||||
{#if stage === 'unwrapped'}
|
||||
<div class="action-group">
|
||||
<h3>Документ выпрямлен</h3>
|
||||
<p>Вы можете скачать результат или продолжить улучшение</p>
|
||||
<div class="action-buttons">
|
||||
<button class="btn-secondary" onclick={handleDownload}>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
|
||||
<polyline points="7 10 12 15 17 10"/>
|
||||
<line x1="12" y1="15" x2="12" y2="3"/>
|
||||
</svg>
|
||||
Скачать
|
||||
</button>
|
||||
<button class="btn-primary" onclick={processDeblur}>
|
||||
Улучшить четкость
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{:else if stage === 'deblurred'}
|
||||
<div class="action-group">
|
||||
<h3>Четкость улучшена</h3>
|
||||
<p>Вы можете скачать результат или удалить тени</p>
|
||||
<div class="action-buttons">
|
||||
<button class="btn-secondary" onclick={handleDownload}>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
|
||||
<polyline points="7 10 12 15 17 10"/>
|
||||
<line x1="12" y1="15" x2="12" y2="3"/>
|
||||
</svg>
|
||||
Скачать
|
||||
</button>
|
||||
<button class="btn-primary" onclick={processIllumination}>
|
||||
Убрать тени
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{:else if stage === 'corrected'}
|
||||
<div class="action-group">
|
||||
<h3>Обработка завершена</h3>
|
||||
<p>Документ полностью обработан и готов к скачиванию</p>
|
||||
<div class="action-buttons">
|
||||
<button class="btn-primary large" onclick={handleDownload}>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
|
||||
<polyline points="7 10 12 15 17 10"/>
|
||||
<line x1="12" y1="15" x2="12" y2="3"/>
|
||||
</svg>
|
||||
Скачать документ
|
||||
</button>
|
||||
<button class="btn-secondary" onclick={handleReset}>
|
||||
Обработать другой документ
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="preview-actions">
|
||||
{#if stage === 'unwrapped'}
|
||||
<div class="action-group">
|
||||
<h3>Документ выпрямлен</h3>
|
||||
<p>Вы можете скачать результат или продолжить улучшение</p>
|
||||
<div class="action-buttons">
|
||||
<button class="btn-secondary" onclick={handleDownload}>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
|
||||
<polyline points="7 10 12 15 17 10"/>
|
||||
<line x1="12" y1="15" x2="12" y2="3"/>
|
||||
</svg>
|
||||
Скачать
|
||||
</button>
|
||||
<button class="btn-primary" onclick={processDeblur}>
|
||||
Улучшить четкость
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{:else if stage === 'deblurred'}
|
||||
<div class="action-group">
|
||||
<h3>Четкость улучшена</h3>
|
||||
<p>Вы можете скачать результат или удалить тени</p>
|
||||
<div class="action-buttons">
|
||||
<button class="btn-secondary" onclick={handleDownload}>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
|
||||
<polyline points="7 10 12 15 17 10"/>
|
||||
<line x1="12" y1="15" x2="12" y2="3"/>
|
||||
</svg>
|
||||
Скачать
|
||||
</button>
|
||||
<button class="btn-primary" onclick={processIllumination}>
|
||||
Убрать тени
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{:else if stage === 'corrected'}
|
||||
<div class="action-group">
|
||||
<h3>Обработка завершена</h3>
|
||||
<p>Документ полностью обработан и готов к скачиванию</p>
|
||||
<div class="action-buttons">
|
||||
<button class="btn-primary large" onclick={handleDownload}>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
|
||||
<polyline points="7 10 12 15 17 10"/>
|
||||
<line x1="12" y1="15" x2="12" y2="3"/>
|
||||
</svg>
|
||||
Скачать документ
|
||||
</button>
|
||||
<button class="btn-secondary" onclick={handleReset}>
|
||||
Обработать другой документ
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if error}
|
||||
<div class="error-message">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
<line x1="12" y1="8" x2="12" y2="12"/>
|
||||
<line x1="12" y1="16" x2="12.01" y2="16"/>
|
||||
</svg>
|
||||
<div>
|
||||
<strong>Ошибка</strong>
|
||||
<p>{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{#if error}
|
||||
<div class="error-message">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
<line x1="12" y1="8" x2="12" y2="12"/>
|
||||
<line x1="12" y1="16" x2="12.01" y2="16"/>
|
||||
</svg>
|
||||
<div>
|
||||
<strong>Ошибка</strong>
|
||||
<p>{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.scan-page {
|
||||
|
||||
Reference in New Issue
Block a user