я косяк, поправил веб. Капитан, работайте!

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
влад
2026-07-16 11:06:47 +03:00
co-authored by Cursor
parent 148d645760
commit 2c294424ef
85 changed files with 17410 additions and 110 deletions
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

+35
View File
@@ -0,0 +1,35 @@
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 300;
font-display: swap;
src: url('/static/fonts/inter/inter-300.ttf') format('truetype');
}
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/static/fonts/inter/inter-400.ttf') format('truetype');
}
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 500;
font-display: swap;
src: url('/static/fonts/inter/inter-500.ttf') format('truetype');
}
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 600;
font-display: swap;
src: url('/static/fonts/inter/inter-600.ttf') format('truetype');
}
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url('/static/fonts/inter/inter-700.ttf') format('truetype');
}
@@ -0,0 +1,93 @@
.kiosk-calibration-backdrop {
display: none;
position: fixed;
inset: 0;
background: rgba(26, 30, 28, 0.45);
z-index: 5000;
align-items: center;
justify-content: center;
padding: 16px;
box-sizing: border-box;
}
.kiosk-calibration-backdrop.is-open {
display: flex;
}
.kiosk-calibration-panel {
width: min(920px, 100%);
max-height: min(92vh, 900px);
display: flex;
flex-direction: column;
overflow: hidden;
border-radius: 16px;
box-shadow: 0 16px 48px rgba(26, 30, 28, 0.18);
}
.kiosk-calibration-panel--dark {
background: #1f1f1f;
color: #fff;
border: 1px solid #2f2f2f;
}
.kiosk-calibration-panel--light {
background: #fff;
color: #1a1e1c;
border: 1px solid rgba(26, 30, 28, 0.08);
}
.kiosk-calibration-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 14px 18px;
flex-shrink: 0;
}
.kiosk-calibration-panel--dark .kiosk-calibration-head {
border-bottom: 1px solid #2f2f2f;
}
.kiosk-calibration-panel--light .kiosk-calibration-head {
border-bottom: 1px solid rgba(26, 30, 28, 0.08);
}
.kiosk-calibration-title {
margin: 0;
font-family: system-ui, -apple-system, sans-serif;
font-size: 18px;
font-weight: 600;
line-height: 1.3;
}
.kiosk-calibration-close {
padding: 8px 14px;
border-radius: 8px;
cursor: pointer;
font-size: 14px;
font-family: inherit;
flex-shrink: 0;
}
.kiosk-calibration-panel--dark .kiosk-calibration-close {
border: 1px solid #7a7a7a;
background: #3a3a3a;
color: #fff;
}
.kiosk-calibration-panel--light .kiosk-calibration-close {
border: 1px solid rgba(26, 30, 28, 0.14);
background: #fff;
color: #1a1e1c;
}
.kiosk-calibration-frame {
border: 0;
width: 100%;
flex: 1 1 auto;
min-height: 320px;
max-height: calc(92vh - 64px);
background: #fff;
display: block;
}
@@ -0,0 +1,6 @@
/* Киоск: скрыть указатель мыши (Chromium + labwc). */
html.wesp-kiosk-hide-cursor,
html.wesp-kiosk-hide-cursor body,
html.wesp-kiosk-hide-cursor * {
cursor: none !important;
}
+404
View File
@@ -0,0 +1,404 @@
/**
* Тема киоск-модалок: в светлой теме (html[data-theme="light"]) шапки и панели
* совпадают со светлым интерфейсом, без «тёмного» вида заголовков.
*/
/* --- Меню ☰ --- */
html[data-theme="light"] #wespKioskMenuOverlay .wesp-kiosk-menu-panel {
background: #ffffff !important;
border-color: #e5e7eb !important;
color: #111827 !important;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12) !important;
}
html[data-theme="light"] #wespKioskMenuOverlay .wesp-kiosk-menu-link {
background: #f3f4f6 !important;
border-color: #e5e7eb !important;
color: #111827 !important;
}
html[data-theme="light"] #wespKioskMenuOverlay .wesp-kiosk-menu-link:hover {
background: #1b66a8 !important;
border-color: #4ea8ff !important;
color: #ffffff !important;
}
html[data-theme="light"] #wespKioskMenuOverlay .wesp-kiosk-menu-close {
border-color: #d1d5db !important;
background: #f3f4f6 !important;
color: #111827 !important;
}
/* --- Проверка БД / калибровка (iframe) --- */
html[data-theme="light"] #kioskDbCheckModal .kiosk-db-check-panel,
html[data-theme="light"] #kioskCalibrationModal .kiosk-calibration-panel {
background: #ffffff !important;
color: #111827 !important;
border-color: #e5e7eb !important;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12) !important;
}
html[data-theme="light"] #kioskDbCheckModal .kiosk-db-check-panel > div:first-child,
html[data-theme="light"] #kioskCalibrationModal .kiosk-calibration-panel > div:first-child {
border-bottom-color: #e5e7eb !important;
}
html[data-theme="light"] #kioskDbCheckModal .kiosk-db-check-panel h2,
html[data-theme="light"] #kioskCalibrationModal .kiosk-calibration-panel h2 {
color: #111827 !important;
}
html[data-theme="light"] #kioskDbCheckClose,
html[data-theme="light"] #kioskCalibrationClose {
border-color: #d1d5db !important;
background: #f3f4f6 !important;
color: #111827 !important;
}
/* --- Привязка терминала (QR) --- */
@keyframes kioskPairSpin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
#kioskPairModal {
display: none;
position: fixed;
inset: 0;
z-index: 5000;
box-sizing: border-box;
padding: 8px;
padding:
max(8px, env(safe-area-inset-top, 0px))
max(8px, env(safe-area-inset-right, 0px))
max(8px, env(safe-area-inset-bottom, 0px))
max(8px, env(safe-area-inset-left, 0px));
background: rgba(0, 0, 0, 0.55);
overflow: hidden;
}
#kioskPairModal.kiosk-pair-open {
display: flex;
align-items: stretch;
justify-content: center;
height: 100vh;
height: 100dvh;
}
body.kiosk-pair-modal-open {
overflow: hidden !important;
}
#kioskPairModal .kiosk-pair-panel {
box-sizing: border-box;
display: flex;
flex-direction: column;
width: min(480px, 100%);
height: 100%;
max-height: 100%;
min-height: 0;
margin: 0 auto;
overflow: hidden;
background: #1f1f1f;
color: #fff;
border-radius: 14px;
padding: 12px;
text-align: center;
border: 1px solid #2f2f2f;
box-shadow: 0 0 20px rgba(0, 0, 0, 0.45);
}
#kioskPairModal .kiosk-pair-header {
flex: 0 0 auto;
}
#kioskPairModal .kiosk-pair-body {
flex: 1 1 auto;
min-height: 0;
display: flex;
flex-direction: column;
overflow-x: hidden;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
overscroll-behavior: contain;
padding: 0 2px;
}
#kioskPairModal h3 {
margin: 0 0 4px 0;
font-size: clamp(0.95rem, 3.6vw, 1.1rem);
}
#kioskPairModal .kiosk-pair-subtitle {
margin: 0 0 6px 0;
font-size: clamp(0.8rem, 3vw, 0.9rem);
}
#kioskPairModal .kiosk-pair-steps {
flex: 0 0 auto;
margin: 0 auto 8px auto;
text-align: left;
max-width: 400px;
font-size: clamp(0.72rem, 2.6vw, 0.8rem);
line-height: 1.35;
color: #d7d7d7;
}
#kioskPairModal .kiosk-pair-loading {
display: none;
align-items: center;
justify-content: center;
gap: 10px;
margin: 8px auto;
color: #d7d7d7;
font-size: 14px;
}
#kioskPairModal .kiosk-pair-spinner {
display: inline-block;
width: 16px;
height: 16px;
border: 2px solid #5a5a5a;
border-top-color: #4ea8ff;
border-radius: 50%;
animation: kioskPairSpin 0.8s linear infinite;
flex-shrink: 0;
}
#kioskPairModal .kiosk-pair-qr-wrap {
display: none;
flex: 1 1 auto;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 0;
width: 100%;
margin: 4px auto;
padding: 4px 0;
}
#kioskPairModal .kiosk-pair-qr-wrap.kiosk-pair-qr-visible {
display: flex;
}
#kioskPairModal .kiosk-pair-qr {
display: block;
width: min(92vw, 52vh, 360px);
min-width: min(160px, 78vw);
height: auto;
aspect-ratio: 1 / 1;
object-fit: contain;
border: 1px solid #3b3b3b;
border-radius: 10px;
background: #fff;
}
#kioskPairModal .kiosk-pair-url {
flex: 0 0 auto;
font-size: clamp(0.62rem, 2.2vw, 0.72rem);
word-break: break-all;
margin: 6px 0;
color: #b8d8ff;
text-align: left;
max-height: 3.6em;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
}
#kioskPairModal .kiosk-pair-status {
flex: 0 0 auto;
font-size: clamp(0.72rem, 2.6vw, 0.8rem);
color: #d7d7d7;
margin: 4px 0 6px;
}
#kioskPairModal .kiosk-pair-actions {
flex: 0 0 auto;
display: flex;
gap: 8px;
justify-content: stretch;
align-items: stretch;
flex-wrap: nowrap;
margin: 8px -12px -12px;
padding: 12px;
border-top: 1px solid #333;
border-radius: 0 0 14px 14px;
background: #1f1f1f;
}
#kioskPairModal .kiosk-pair-btn {
box-sizing: border-box;
padding: 10px 12px;
border-radius: 8px;
border: 1px solid #7a7a7a;
background: #3a3a3a;
color: #fff;
cursor: pointer;
font: inherit;
font-size: clamp(0.75rem, 2.8vw, 0.875rem);
line-height: 1.25;
min-height: 42px;
flex: 1 1 0;
min-width: 0;
max-width: none;
appearance: none;
-webkit-appearance: none;
}
#kioskPairModal .kiosk-pair-btn--primary {
border-color: #4ea8ff;
background: #1b66a8;
color: #fff;
}
#kioskPairModal .kiosk-pair-btn--secondary {
border-color: #5a5a5a;
background: #2a2a2a;
color: #fff;
}
#kioskPairModal .kiosk-pair-btn--ghost {
border-color: #6a6a6a;
background: #333;
color: #f0f0f0;
}
#kioskPairModal .kiosk-pair-btn:focus-visible {
outline: 2px solid #4ea8ff;
outline-offset: 2px;
}
@media (max-height: 520px) {
#kioskPairModal .kiosk-pair-panel {
padding: 8px;
border-radius: 10px;
}
#kioskPairModal .kiosk-pair-qr {
width: min(88vw, 38vh, 280px);
}
#kioskPairModal .kiosk-pair-steps {
font-size: 0.7rem;
line-height: 1.25;
}
#kioskPairModal .kiosk-pair-btn {
min-height: 38px;
padding: 8px 10px;
}
#kioskPairModal .kiosk-pair-actions {
flex-wrap: wrap;
}
#kioskPairModal .kiosk-pair-btn {
flex: 1 1 calc(50% - 4px);
}
#kioskPairModal .kiosk-pair-btn--ghost {
flex: 1 1 100%;
}
}
#kioskBlockedOverlay {
box-sizing: border-box;
padding: max(16px, env(safe-area-inset-top, 0px)) 16px;
overflow-y: auto;
}
#kioskBlockedOverlay > div {
max-width: min(520px, 100%);
}
html[data-theme="light"] #kioskPairModal .kiosk-pair-panel {
background: #ffffff !important;
color: #111827 !important;
border-color: #e5e7eb !important;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12) !important;
}
html[data-theme="light"] #kioskPairModal h3 {
color: #111827 !important;
}
html[data-theme="light"] #kioskPairModal .kiosk-pair-subtitle {
color: #374151 !important;
}
html[data-theme="light"] #kioskPairModal .kiosk-pair-steps {
color: #4b5563 !important;
}
html[data-theme="light"] #kioskPairLoading,
html[data-theme="light"] #kioskPairModal .kiosk-pair-loading {
color: #4b5563 !important;
}
html[data-theme="light"] #kioskPairLoading span:first-child,
html[data-theme="light"] #kioskPairModal .kiosk-pair-spinner {
border-color: #d1d5db !important;
border-top-color: #2563eb !important;
}
html[data-theme="light"] #kioskPairUrl {
color: #2563eb !important;
}
html[data-theme="light"] #kioskPairStatus {
color: #6b7280 !important;
}
html[data-theme="dark"] #kioskPairStatus {
color: #d7d7d7 !important;
}
html[data-theme="light"] #kioskPairModal .kiosk-pair-actions {
background: #ffffff !important;
border-top-color: #e5e7eb !important;
}
html[data-theme="light"] #kioskPairModal .kiosk-pair-btn--primary,
html[data-theme="light"] #kioskPairCopy {
border-color: #2563eb !important;
background: #2563eb !important;
color: #ffffff !important;
}
html[data-theme="light"] #kioskPairModal .kiosk-pair-btn--secondary,
html[data-theme="light"] #kioskPairRefresh {
border-color: #d1d5db !important;
background: #ffffff !important;
color: #111827 !important;
}
html[data-theme="light"] #kioskPairModal .kiosk-pair-btn--ghost,
html[data-theme="light"] #kioskPairClose {
border-color: #d1d5db !important;
background: #f3f4f6 !important;
color: #111827 !important;
}
/* --- Блокировка до привязки --- */
html[data-theme="light"] #kioskBlockedOverlay {
background: rgba(15, 23, 42, 0.45) !important;
color: #111827 !important;
}
html[data-theme="light"] #kioskBlockedOverlay h2 {
color: #111827 !important;
}
html[data-theme="light"] #kioskBlockedOverlay p {
color: #4b5563 !important;
}
html[data-theme="light"] #kioskBlockedPairBtn {
background: #2563eb !important;
border: 1px solid #2563eb !important;
color: #ffffff !important;
padding: 10px 18px;
border-radius: 8px;
cursor: pointer;
font-size: 15px;
}
+24
View File
@@ -0,0 +1,24 @@
@font-face {
font-family: 'Roboto';
font-style: normal;
font-weight: 400;
font-stretch: normal;
font-display: swap;
src: url('/static/fonts/roboto/roboto-400.ttf') format('truetype');
}
@font-face {
font-family: 'Roboto';
font-style: normal;
font-weight: 500;
font-stretch: normal;
font-display: swap;
src: url('/static/fonts/roboto/roboto-500.ttf') format('truetype');
}
@font-face {
font-family: 'Roboto';
font-style: normal;
font-weight: 700;
font-stretch: normal;
font-display: swap;
src: url('/static/fonts/roboto/roboto-700.ttf') format('truetype');
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,139 @@
/* Карточка в главных настройках + модалка контроля отклонений */
/* Поверх #settingsModal (z-index: 3000) */
#feedQualitySettingsModal.modal.wesp-shell-modal {
z-index: 3100;
}
.modal.wesp-shell-modal .modal-dialog.modal-dialog--shell-fq {
max-width: min(760px, calc(100vw - 1rem));
}
#feedQualitySettingsModal .modal-content.fq-settings-modal__content {
max-height: min(94vh, 900px);
display: flex;
flex-direction: column;
}
#feedQualitySettingsModal .wesp-shell-bsmodal-body {
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
max-height: min(calc(94vh - 8rem), 760px);
}
#feedQualitySettingsModal .wesp-shell-bsmodal-actions {
flex-shrink: 0;
margin-top: 0;
padding-top: 12px;
border-top: 1px solid var(--border-light, #e2e8f0);
}
.settings-feed-quality-card {
background: #f8fafc;
border-radius: 12px;
padding: 1rem 1.25rem;
margin-bottom: 0.75rem;
border: 1px solid #e2e8f0;
}
.settings-feed-quality-card--clickable {
cursor: pointer;
transition: all 0.2s ease;
}
.settings-feed-quality-card--clickable:hover {
background: rgba(var(--primary-rgb), 0.05);
border-color: var(--primary-color, #2196f3);
}
.settings-feed-quality-card .settings-card-title {
margin: 0;
justify-content: center;
position: relative;
}
.settings-feed-quality-card .settings-card-title > i:first-child {
position: absolute;
left: 0;
}
.settings-feed-quality-card .settings-card-title .settings-chevron {
position: absolute;
right: 0;
}
.fq-settings-modal .fq-settings-section {
border: 1px solid var(--border-light, #e2e8f0);
border-radius: 10px;
padding: 0.85rem 1rem;
margin-bottom: 0.75rem;
background: var(--bg-light, #f8fafc);
}
.fq-settings-modal .fq-settings-section__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
margin-bottom: 0.65rem;
}
.fq-settings-modal .fq-settings-section__title {
margin: 0;
font-size: 0.9rem;
font-weight: 600;
color: var(--text-dark, #1e293b);
}
.fq-settings-modal .fq-settings-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 0.65rem 1rem;
}
.fq-settings-modal .fq-settings-field label {
display: block;
font-size: 0.75rem;
font-weight: 500;
color: var(--text-muted, #64748b);
margin-bottom: 0.25rem;
}
.fq-settings-modal .fq-settings-checks {
display: flex;
flex-wrap: wrap;
gap: 0.75rem 1.25rem;
margin-top: 0.5rem;
}
.fq-settings-modal .fq-settings-check {
display: inline-flex;
align-items: center;
gap: 0.35rem;
font-size: 0.8125rem;
color: var(--text-dark, #334155);
margin: 0;
cursor: pointer;
}
html[data-theme="dark"] .settings-feed-quality-card {
background: rgba(255, 255, 255, 0.06);
border-color: var(--dm-border, rgba(255, 255, 255, 0.12));
}
html[data-theme="dark"] .settings-feed-quality-card--clickable:hover {
background: rgba(255, 255, 255, 0.08);
border-color: rgba(255, 255, 255, 0.25);
}
html[data-theme="dark"] .fq-settings-modal .fq-settings-section {
background: rgba(255, 255, 255, 0.04);
border-color: rgba(255, 255, 255, 0.1);
}
html[data-theme="dark"] .fq-settings-modal .fq-settings-section__title,
html[data-theme="dark"] .fq-settings-modal .fq-settings-check {
color: #f1f5f9;
}
@@ -0,0 +1,204 @@
/**
* Kiosk/operator skeleton layouts — builds on wesp-recipes-skeleton.css primitives.
*/
/* --- Grid cards (direct children of .dispenser-grid) --- */
.dispenser-card.kiosk-skeleton-card {
pointer-events: none;
cursor: default;
}
.dispenser-card.kiosk-skeleton-card:nth-child(1) { --skel-i: 0; }
.dispenser-card.kiosk-skeleton-card:nth-child(2) { --skel-i: 1; }
.dispenser-card.kiosk-skeleton-card:nth-child(3) { --skel-i: 2; }
.dispenser-card.kiosk-skeleton-card:nth-child(4) { --skel-i: 3; }
.dispenser-grid.kiosk-skeleton-host--exit {
opacity: 0;
pointer-events: none;
transition: opacity 0.15s ease;
}
.dashboard-skeleton-bar--kiosk-title {
width: 72%;
height: 1.75rem;
margin-bottom: 12px;
}
.kiosk-skeleton-info {
display: flex;
flex-direction: column;
gap: 6px;
}
.dashboard-skeleton-bar--kiosk-meta {
width: 88%;
height: 0.72rem;
margin-bottom: 0;
}
.dashboard-skeleton-bar--kiosk-meta:last-child {
width: 62%;
}
/* Legacy grid wrapper (unused on recipes_selection; kept for compatibility) */
.kiosk-skeleton-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 16px;
width: 100%;
min-height: 8rem;
transition: opacity 0.15s ease;
}
.kiosk-skeleton-grid--exit {
opacity: 0;
pointer-events: none;
}
/* --- Unloading groups list --- */
.kiosk-skeleton-groups {
display: flex;
flex-direction: column;
gap: 12px;
min-height: 6rem;
transition: opacity 0.15s ease;
}
.kiosk-skeleton-groups--exit {
opacity: 0;
pointer-events: none;
}
.kiosk-skeleton-group-item {
background: var(--white, #fff);
border: 1px solid var(--gray-200, #e5e7eb);
border-radius: 8px;
padding: 16px 20px;
pointer-events: none;
}
.kiosk-skeleton-group-item:nth-child(1) { --skel-i: 0; }
.kiosk-skeleton-group-item:nth-child(2) { --skel-i: 1; }
.kiosk-skeleton-group-item:nth-child(3) { --skel-i: 2; }
.kiosk-skeleton-group-item .dashboard-skeleton-bar--title {
width: 55%;
height: 1.1rem;
margin-bottom: 10px;
}
.kiosk-skeleton-group-item .dashboard-skeleton-bar--meta {
width: 40%;
height: 0.85rem;
margin-bottom: 0;
}
/* --- Duplicate / weight panel --- */
.kiosk-skeleton-panel {
transition: opacity 0.15s ease;
}
.kiosk-skeleton-panel--exit {
opacity: 0;
pointer-events: none;
}
.kiosk-skeleton-weight-hero {
width: min(280px, 70%);
height: clamp(2.5rem, 8vmin, 4rem);
margin: 0 auto 16px;
border-radius: 8px;
}
.kiosk-skeleton-recipe-header {
padding: clamp(10px, 2.5vmin, 16px) clamp(12px, 3vw, 20px);
}
.kiosk-skeleton-recipe-header .dashboard-skeleton-bar--title {
width: 65%;
height: 1.25rem;
margin-bottom: 8px;
}
.kiosk-skeleton-recipe-header .dashboard-skeleton-bar--subtitle {
width: 45%;
height: 0.85rem;
margin-bottom: 0;
}
.kiosk-skeleton-component-name {
width: min(320px, 85%);
height: 1.5rem;
margin: 0 auto 20px;
}
/* --- Ingredients table skeleton --- */
.kiosk-skeleton-table {
display: flex;
flex-direction: column;
gap: 10px;
min-height: 12rem;
transition: opacity 0.15s ease;
}
.kiosk-skeleton-table--exit {
opacity: 0;
pointer-events: none;
}
.kiosk-skeleton-table-row {
display: flex;
gap: 12px;
align-items: center;
}
.kiosk-skeleton-table-row:nth-child(1) { --skel-i: 0; }
.kiosk-skeleton-table-row:nth-child(2) { --skel-i: 1; }
.kiosk-skeleton-table-row:nth-child(3) { --skel-i: 2; }
.kiosk-skeleton-table-row:nth-child(4) { --skel-i: 3; }
.kiosk-skeleton-table-row:nth-child(5) { --skel-i: 4; }
.kiosk-skeleton-table-row:nth-child(6) { --skel-i: 5; }
.kiosk-skeleton-table-row .dashboard-skeleton-bar {
margin-bottom: 0;
height: 0.85rem;
}
.kiosk-skeleton-table-row .dashboard-skeleton-bar:first-child {
flex: 2 1 0;
min-width: 0;
}
.kiosk-skeleton-table-row .dashboard-skeleton-bar:last-child {
flex: 1 1 0;
min-width: 0;
max-width: 35%;
}
/* --- Dark theme --- */
html[data-theme="dark"] .dispenser-card.kiosk-skeleton-card {
background: #242424;
border-color: #3a3a3a;
}
html[data-theme="dark"] .kiosk-skeleton-group-item {
background: #242424;
border-color: #3a3a3a;
}
@media (prefers-reduced-motion: reduce) {
.dispenser-grid.kiosk-skeleton-host--exit,
.kiosk-skeleton-grid--exit,
.kiosk-skeleton-groups--exit,
.kiosk-skeleton-panel--exit,
.kiosk-skeleton-table--exit {
transition: none;
}
}
@@ -0,0 +1,203 @@
.wesp-logo-loader {
position: fixed;
inset: 0;
z-index: 9999;
display: flex;
flex-direction: column;
justify-content: flex-end;
background: #f6f6f4;
transition: opacity 0.48s ease, visibility 0.48s;
overflow: hidden;
}
.wesp-logo-loader--exiting {
opacity: 0;
}
.wesp-logo-loader--exiting .wesp-logo-loader__canvas {
transform: scale(1.03);
opacity: 0.92;
}
.wesp-logo-loader--hidden {
opacity: 0;
visibility: hidden;
pointer-events: none;
}
.wesp-logo-loader--embedded {
position: relative;
inset: auto;
width: 100%;
height: 100%;
background: #fff;
overflow: hidden;
}
.wesp-logo-loader--embedded.wesp-logo-loader--transparent,
.wesp-logo-loader--plain.wesp-logo-loader--transparent {
background: transparent !important;
}
.wesp-logo-loader--embedded .wesp-logo-loader__canvas {
position: absolute;
inset: 0;
}
.wesp-logo-loader--embedded:not(.wesp-logo-loader--no-chrome) {
display: flex;
flex-direction: column;
}
.wesp-logo-loader--embedded:not(.wesp-logo-loader--no-chrome) .wesp-logo-loader__canvas {
position: relative;
flex: 1 1 auto;
min-height: 0;
inset: auto;
}
.wesp-logo-loader--embedded:not(.wesp-logo-loader--no-chrome) .wesp-logo-loader__footer {
flex: 0 0 auto;
padding: 0 12px 10px;
}
.wesp-logo-loader--embedded:not(.wesp-logo-loader--no-chrome) .wesp-logo-loader__footer-row {
margin-bottom: 8px;
}
.wesp-logo-loader--plain {
background: #fff;
}
.wesp-logo-loader--no-chrome .wesp-logo-loader__footer {
display: none;
}
.wesp-logo-loader__canvas {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
image-rendering: pixelated;
image-rendering: crisp-edges;
transition: transform 0.52s ease, opacity 0.52s ease;
transform-origin: 50% 44%;
}
.wesp-logo-loader__footer {
position: relative;
z-index: 2;
padding: 0 12px 12px;
pointer-events: none;
}
.wesp-logo-loader__footer-row {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.wesp-logo-loader__label,
.wesp-logo-loader__percent {
font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
font-size: 12px;
letter-spacing: 0.14em;
text-transform: uppercase;
color: rgba(26, 30, 28, 0.72);
}
.wesp-logo-loader__percent {
font-variant-numeric: tabular-nums;
}
.wesp-logo-loader__bar {
position: relative;
height: 4px;
width: 100%;
background: rgba(26, 30, 28, 0.08);
}
.wesp-logo-loader__bar-fill {
position: absolute;
inset: 0 auto 0 0;
width: 0;
background: rgb(148, 152, 158);
transition: width 0.08s linear, background 0.18s ease;
}
.wesp-logo-loader__tagline {
margin: 14px 0 0;
padding: 0 8px;
text-align: center;
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
font-size: 0.92rem;
line-height: 1.45;
color: rgba(26, 30, 28, 0.52);
min-height: 1.45em;
transition: opacity 0.35s ease;
}
.wesp-logo-loader__tagline:empty {
display: none;
}
.wesp-logo-loader__tagline--fade {
opacity: 0;
}
@media (prefers-reduced-motion: reduce) {
.wesp-logo-loader__bar-fill {
transition: none;
}
}
/* Demo page helpers */
.wesp-logo-loader-demo {
min-height: 100vh;
background: #f6f6f4;
color: rgba(26, 30, 28, 0.72);
font-family: system-ui, -apple-system, sans-serif;
}
.wesp-logo-loader-demo__panel {
position: fixed;
left: 50%;
bottom: 72px;
transform: translateX(-50%);
display: flex;
flex-wrap: wrap;
gap: 10px;
justify-content: center;
padding: 12px 16px;
border-radius: 12px;
background: rgba(255, 255, 255, 0.72);
border: 1px solid rgba(26, 30, 28, 0.08);
box-shadow: 0 8px 24px rgba(26, 30, 28, 0.06);
}
.wesp-logo-loader-demo__btn {
appearance: none;
border: 1px solid rgba(26, 30, 28, 0.12);
background: rgba(72, 129, 109, 0.12);
color: #1a1e1c;
border-radius: 8px;
padding: 8px 14px;
font-size: 14px;
cursor: pointer;
}
.wesp-logo-loader-demo__btn:hover {
background: rgba(72, 129, 109, 0.22);
}
.wesp-logo-loader-demo__hint {
position: fixed;
top: 20px;
left: 50%;
transform: translateX(-50%);
margin: 0;
font-size: 14px;
text-align: center;
opacity: 0.65;
}
@@ -283,8 +283,13 @@
}
body.recipe-edit-mobile-open .mobile-navbar,
body.recipe-edit-mobile-open .mobile-menu-overlay,
body.recipe-edit-mobile-open .app-footer,
body.recipe-edit-mobile-open .page-content {
body.recipe-edit-mobile-open .page-content,
body.recipe-edit-mobile-open .wesp-zt-notify-fab,
body.ingredient-mobile-sheet-open .wesp-zt-notify-fab,
body.unloading-group-mobile-sheet-open .wesp-zt-notify-fab,
body.recipe-help-modal-open .wesp-zt-notify-fab {
display: none !important;
}
@@ -345,7 +350,14 @@
-webkit-overflow-scrolling: touch;
overscroll-behavior-y: contain;
touch-action: pan-y;
padding-bottom: calc(1rem + env(safe-area-inset-bottom, 0px));
padding-bottom: calc(1.25rem + env(safe-area-inset-bottom, 0px));
}
/* Nested section cards inherit global .card { height: 100% } and stretch to fill the scroll viewport */
body.recipe-edit-mobile-open #recipeEdit .recipe-section-card,
#recipeEdit.recipe-edit--mobile-active .recipe-section-card {
height: auto !important;
flex-grow: 0;
}
body.recipe-edit-mobile-open #recipeEdit #recipeForm > .card-header,
@@ -435,12 +447,23 @@
}
/* --- Card layout for edit data grids --- */
/* !important: wesp-recipes-editor.css loads later and sets display:grid on __head/__row */
#recipeEdit .zt-data-grid__scroll {
overflow: visible;
overflow: visible !important;
}
#recipeEdit .zt-data-grid {
gap: 0 !important;
min-width: 0 !important;
}
#recipeEdit .zt-data-grid__head {
display: none;
display: none !important;
}
#recipeEdit #ingredientsTableBody .ingredient-row,
#recipeEdit #unloadingGroupsTableBody .unloading-group-row {
min-width: 0 !important;
}
#recipeEdit #ingredientsTable.ingredients-table--dm-unlocked .ingredient-detail-cell.dry-matter-per-head {
+32 -10
View File
@@ -300,13 +300,12 @@ html[data-theme="dark"] .reports-page .reports-filter-select .form-select {
opacity: 0.7;
}
.reports-page .reports-export-dropdown__menu {
position: absolute;
right: 0;
top: calc(100% + 0.35rem);
z-index: 1200;
.reports-page .reports-export-dropdown__menu,
#reportsExportMenu.reports-export-dropdown__menu {
position: fixed;
z-index: 3600;
min-width: 16.5rem;
max-width: 19rem;
max-width: min(19rem, calc(100vw - 1.5rem));
padding: 0.35rem;
border: 1px solid var(--border-light);
border-radius: var(--zt-radius-md, 10px);
@@ -314,6 +313,16 @@ html[data-theme="dark"] .reports-page .reports-filter-select .form-select {
box-shadow: var(--shadow-soft, 0 8px 24px rgba(15, 23, 42, 0.12));
}
#reportsExportMenu.reports-export-dropdown__menu[hidden] {
display: none !important;
pointer-events: none !important;
}
#reportsExportMenu.reports-export-dropdown__menu--open {
display: block !important;
pointer-events: auto !important;
}
.reports-page .reports-export-dropdown__group + .reports-export-dropdown__group {
margin-top: 0.35rem;
padding-top: 0.35rem;
@@ -375,7 +384,8 @@ html[data-theme="dark"] .reports-page .reports-filter-select .form-select {
margin-top: 0.1rem;
}
html[data-theme="dark"] .reports-page .reports-export-dropdown__menu {
html[data-theme="dark"] .reports-page .reports-export-dropdown__menu,
html[data-theme="dark"] #reportsExportMenu.reports-export-dropdown__menu {
background: var(--dm-field, #2c3338);
border-color: rgba(255, 255, 255, 0.12);
}
@@ -432,9 +442,10 @@ html[data-theme="dark"] .reports-page .reports-export-dropdown__menu {
flex-shrink: 0;
}
.reports-page .reports-drp__panel {
.reports-page .reports-drp__panel,
#reportsDateRangePanel.reports-drp__panel {
position: fixed;
z-index: 2500;
z-index: 3600;
width: min(19rem, calc(100vw - 1.5rem));
padding: 0.75rem;
border: 1px solid var(--border-light);
@@ -443,6 +454,16 @@ html[data-theme="dark"] .reports-page .reports-export-dropdown__menu {
box-shadow: var(--shadow-soft, 0 8px 24px rgba(15, 23, 42, 0.12));
}
#reportsDateRangePanel.reports-drp__panel[hidden] {
display: none !important;
pointer-events: none !important;
}
#reportsDateRangePanel.reports-drp__panel--open {
display: block !important;
pointer-events: auto !important;
}
.reports-page .reports-drp__presets {
display: flex;
gap: 0.35rem;
@@ -549,7 +570,8 @@ html[data-theme="dark"] .reports-page .reports-export-dropdown__menu {
html[data-theme="dark"] .reports-page .reports-drp__trigger,
html[data-theme="dark"] .reports-page .reports-drp__panel {
html[data-theme="dark"] .reports-page .reports-drp__panel,
html[data-theme="dark"] #reportsDateRangePanel.reports-drp__panel {
background: var(--dm-field, var(--bg-white, #2c3338));
border-color: rgba(255, 255, 255, 0.12);
color: #f5f5f5;
+737
View File
@@ -0,0 +1,737 @@
:root {
--setup-page-bg: #fff;
--setup-overlay-bg: #fff;
--setup-surface: #fff;
--setup-surface-muted: rgba(26, 30, 28, 0.04);
--setup-border: rgba(26, 30, 28, 0.08);
--setup-border-strong: rgba(26, 30, 28, 0.14);
--setup-text: #1a1e1c;
--setup-text-muted: rgba(26, 30, 28, 0.72);
--setup-text-soft: rgba(26, 30, 28, 0.55);
--setup-text-faint: rgba(26, 30, 28, 0.5);
--setup-accent: #2c7be5;
--setup-accent-soft: rgba(44, 123, 229, 0.08);
--setup-accent-text: #1a5bb8;
--setup-shadow: 0 16px 48px rgba(26, 30, 28, 0.08);
--setup-busy-bg: rgba(255, 255, 255, 0.82);
--setup-info-bg: rgba(44, 123, 229, 0.08);
--setup-info-text: rgba(26, 30, 28, 0.78);
--setup-warn-bg: #fff7e6;
--setup-warn-border: #ffd591;
--setup-warn-text: #874d00;
--setup-error: #b42318;
--setup-ok: #067647;
--setup-loader-bg: #fff;
}
html[data-theme="dark"] {
--setup-page-bg: #0f1114;
--setup-overlay-bg: #0f1114;
--setup-surface: #181b20;
--setup-surface-muted: rgba(255, 255, 255, 0.05);
--setup-border: rgba(255, 255, 255, 0.1);
--setup-border-strong: rgba(255, 255, 255, 0.16);
--setup-text: #eef0f3;
--setup-text-muted: rgba(238, 240, 243, 0.78);
--setup-text-soft: rgba(238, 240, 243, 0.58);
--setup-text-faint: rgba(238, 240, 243, 0.45);
--setup-accent: #4d93ef;
--setup-accent-soft: rgba(77, 147, 239, 0.14);
--setup-accent-text: #8eb8ff;
--setup-shadow: 0 16px 48px rgba(0, 0, 0, 0.45);
--setup-busy-bg: rgba(24, 27, 32, 0.88);
--setup-info-bg: rgba(77, 147, 239, 0.12);
--setup-info-text: rgba(238, 240, 243, 0.86);
--setup-warn-bg: rgba(255, 169, 64, 0.12);
--setup-warn-border: rgba(255, 169, 64, 0.35);
--setup-warn-text: #ffd591;
--setup-error: #ff8a80;
--setup-ok: #6ee7a8;
--setup-loader-bg: #181b20;
}
html,
body {
margin: 0;
background: var(--setup-page-bg);
color: var(--setup-text);
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
-webkit-font-smoothing: antialiased;
}
.wesp-setup {
position: fixed;
inset: 0;
z-index: 10000;
background: var(--setup-overlay-bg);
}
.wesp-setup__intro {
position: fixed;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
z-index: 10001;
width: min(520px, calc(100vw - 32px));
height: min(240px, 36vh);
background: transparent;
transition: opacity 0.45s ease, visibility 0.45s;
}
.wesp-setup__intro--done {
opacity: 0;
visibility: hidden;
pointer-events: none;
}
.wesp-setup__intro .wesp-logo-loader,
.wesp-setup__intro .wesp-logo-loader--embedded,
.wesp-setup__intro .wesp-logo-loader--plain {
background: transparent !important;
}
.wesp-setup__panel {
position: fixed;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
z-index: 10002;
display: flex;
flex-direction: column;
width: min(520px, calc(100vw - 32px));
max-height: min(85vh, 620px);
overflow: hidden;
padding: 0;
border-radius: 16px;
background: var(--setup-surface);
border: 1px solid var(--setup-border);
box-shadow: var(--setup-shadow);
color: var(--setup-text);
opacity: 1;
visibility: visible;
transition: opacity 0.35s ease 0.08s, visibility 0.35s ease 0.08s, background 0.2s ease, color 0.2s ease;
isolation: isolate;
}
.wesp-setup__header {
flex: 0 0 auto;
padding: 22px 24px 0;
}
.wesp-setup__content {
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
padding: 16px 24px 8px;
overscroll-behavior: contain;
}
.wesp-setup__footer {
flex: 0 0 auto;
padding: 12px 24px calc(16px + env(safe-area-inset-bottom, 0px));
border-top: 1px solid var(--setup-border);
background: var(--setup-surface);
}
.wesp-setup__panel--busy {
pointer-events: none;
}
.wesp-setup__busy {
position: absolute;
inset: 0;
z-index: 4;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
border-radius: 16px;
background: var(--setup-busy-bg);
backdrop-filter: blur(2px);
}
.wesp-setup__spinner {
width: 28px;
height: 28px;
border: 2px solid var(--setup-border-strong);
border-top-color: var(--setup-accent);
border-radius: 50%;
animation: wesp-setup-spin 0.75s linear infinite;
}
.wesp-setup__busy-label {
font-size: 13px;
color: var(--setup-text-muted);
}
@keyframes wesp-setup-spin {
to {
transform: rotate(360deg);
}
}
.wesp-setup__panel--hidden {
opacity: 0;
visibility: hidden;
pointer-events: none;
}
.wesp-setup__title {
margin: 0 0 6px;
font-size: 21px;
font-weight: 650;
letter-spacing: -0.02em;
line-height: 1.25;
color: var(--setup-text);
}
.wesp-setup__lead {
margin: 0;
font-size: 14px;
line-height: 1.55;
color: var(--setup-text-muted);
}
.wesp-setup__step-indicator {
margin-bottom: 10px;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 10px;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--setup-text-faint);
}
.wesp-setup__field {
margin-bottom: 12px;
}
.wesp-setup__label {
display: block;
margin-bottom: 5px;
font-size: 13px;
font-weight: 500;
color: var(--setup-text);
}
.wesp-setup__input,
.wesp-setup__select {
width: 100%;
box-sizing: border-box;
padding: 11px 12px;
border: 1px solid var(--setup-border-strong);
border-radius: 10px;
font-size: 16px;
background: var(--setup-surface);
color: var(--setup-text);
transition: border-color 0.15s ease, box-shadow 0.15s ease;
}
.wesp-setup__input:focus,
.wesp-setup__select:focus {
outline: none;
border-color: var(--setup-accent);
box-shadow: 0 0 0 3px var(--setup-accent-soft);
}
.wesp-setup__input:-webkit-autofill,
.wesp-setup__input:-webkit-autofill:hover,
.wesp-setup__input:-webkit-autofill:focus,
.wesp-setup__input:-webkit-autofill:active {
-webkit-box-shadow: 0 0 0 1000px var(--setup-surface) inset !important;
box-shadow: 0 0 0 1000px var(--setup-surface) inset !important;
-webkit-text-fill-color: var(--setup-text) !important;
caret-color: var(--setup-text);
transition: background-color 600000s ease-in-out 0s;
}
.wesp-setup__input:autofill {
box-shadow: 0 0 0 1000px var(--setup-surface) inset !important;
-webkit-text-fill-color: var(--setup-text) !important;
caret-color: var(--setup-text);
}
.wesp-setup__input--readonly {
background: var(--setup-surface-muted);
color: var(--setup-text-muted);
cursor: default;
}
.wesp-setup__input-group {
display: flex;
align-items: stretch;
width: 100%;
}
.wesp-setup__input-group .wesp-setup__input {
flex: 1 1 auto;
min-width: 0;
border-top-right-radius: 0;
border-bottom-right-radius: 0;
border-right: none;
}
.wesp-setup__input-suffix {
display: flex;
align-items: center;
padding: 0 12px;
border: 1px solid var(--setup-border-strong);
border-left: none;
border-radius: 0 8px 8px 0;
background: var(--setup-surface-muted);
color: var(--setup-text-soft);
font-size: 14px;
white-space: nowrap;
user-select: none;
}
.wesp-setup__checkbox-row {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 12px;
font-size: 14px;
}
.wesp-setup__role-grid {
display: grid;
gap: 10px;
margin-bottom: 4px;
}
.wesp-setup__role-grid--cards {
gap: 12px;
}
.wesp-setup__role-btn {
text-align: left;
padding: 12px 14px;
border-radius: 12px;
border: 1px solid var(--setup-border-strong);
background: var(--setup-surface);
color: var(--setup-text);
cursor: pointer;
font-size: 14px;
font-family: inherit;
transition: border-color 0.15s ease, background 0.15s ease, box-shadow 0.15s ease;
-webkit-tap-highlight-color: transparent;
}
.wesp-setup__role-btn:hover {
border-color: var(--setup-accent);
}
.wesp-setup__role-btn:focus-visible {
outline: none;
box-shadow: 0 0 0 3px var(--setup-accent-soft);
}
.wesp-setup__role-card {
display: flex;
align-items: center;
justify-content: center;
min-height: 84px;
padding: 20px 18px;
border-radius: 12px;
text-align: center;
}
.wesp-setup__theme-grid .wesp-setup__theme-card {
position: relative;
flex-direction: row;
align-items: center;
justify-content: flex-start;
gap: 0;
min-height: 0;
padding: 12px 14px 12px 62px;
text-align: left;
background: var(--setup-surface-muted);
border-color: var(--setup-border);
color: var(--setup-text);
transition: border-color 0.15s ease, background 0.15s ease;
}
.wesp-setup__theme-grid .wesp-setup__theme-card::before {
content: "";
position: absolute;
left: 14px;
top: 50%;
width: 32px;
height: 32px;
margin-top: -16px;
border-radius: 8px;
border: 1px solid var(--setup-border-strong);
box-sizing: border-box;
}
.wesp-setup__theme-card--light::before {
background: linear-gradient(145deg, #ffffff 0%, #d8dee8 100%);
}
.wesp-setup__theme-card--dark::before {
background: linear-gradient(145deg, #505662 0%, #171b21 100%);
}
.wesp-setup__theme-card span {
display: block;
margin-top: 2px;
font-size: 12px;
line-height: 1.45;
font-weight: 400;
color: var(--setup-text-soft);
}
.wesp-setup__role-btn strong {
display: block;
margin-bottom: 4px;
}
.wesp-setup__role-card strong {
margin: 0;
font-size: 17px;
font-weight: 600;
line-height: 1.25;
color: var(--setup-text);
}
.wesp-setup__theme-card strong {
margin-bottom: 0;
font-size: 15px;
}
.wesp-setup__role-btn.is-selected:not(.wesp-setup__theme-card) {
border-color: var(--setup-accent);
background: var(--setup-accent-soft);
box-shadow: 0 0 0 1px rgba(44, 123, 229, 0.18);
}
.wesp-setup__theme-card.is-selected {
border-color: var(--setup-accent);
background: var(--setup-accent-soft);
box-shadow: none;
}
.wesp-setup__role-card.is-selected:not(.wesp-setup__theme-card) strong {
color: var(--setup-accent-text);
}
.wesp-setup__theme-grid .wesp-setup__theme-card strong {
color: var(--setup-text);
}
.wesp-setup__theme-grid .wesp-setup__theme-card.is-selected strong {
color: var(--setup-accent-text);
}
.wesp-setup__actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
}
.wesp-setup__actions-fill {
flex: 1 1 12px;
min-width: 8px;
}
.wesp-setup__btn {
appearance: none;
border: 1px solid var(--setup-border-strong);
border-radius: 10px;
padding: 10px 16px;
min-height: 42px;
font-size: 14px;
font-weight: 500;
font-family: inherit;
line-height: 1.2;
cursor: pointer;
background: var(--setup-surface);
color: var(--setup-text);
transition: background 0.15s ease, border-color 0.15s ease, opacity 0.15s ease;
-webkit-tap-highlight-color: transparent;
}
.wesp-setup__btn:hover:not(:disabled) {
border-color: var(--setup-accent);
}
.wesp-setup__btn:focus-visible {
outline: none;
box-shadow: 0 0 0 3px var(--setup-accent-soft);
}
.wesp-setup__btn--ghost {
background: transparent;
border-color: transparent;
color: var(--setup-text-muted);
}
.wesp-setup__btn--ghost:hover:not(:disabled) {
background: var(--setup-surface-muted);
border-color: var(--setup-border);
color: var(--setup-text);
}
.wesp-setup__btn--primary {
background: var(--setup-accent);
border-color: var(--setup-accent);
color: #fff;
padding-inline: 20px;
}
.wesp-setup__btn--primary:hover:not(:disabled) {
filter: brightness(1.06);
border-color: var(--setup-accent);
}
.wesp-setup__btn:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.wesp-setup__msg {
margin: 10px 0 0;
font-size: 13px;
line-height: 1.5;
min-height: 1.4em;
}
.wesp-setup__msg--error {
color: var(--setup-error);
}
.wesp-setup__msg--ok {
color: var(--setup-ok);
}
.wesp-setup__hint {
margin: 0 0 12px;
font-size: 13px;
line-height: 1.5;
color: var(--setup-text-soft);
}
.wesp-setup__info {
margin: 0 0 12px;
padding: 10px 12px;
border-radius: 10px;
background: var(--setup-info-bg);
font-size: 13px;
line-height: 1.5;
color: var(--setup-info-text);
}
.wesp-setup__info--warn {
background: var(--setup-warn-bg);
border: 1px solid var(--setup-warn-border);
color: var(--setup-warn-text);
}
.wesp-setup__section-title {
margin: 18px 0 10px;
font-size: 15px;
font-weight: 600;
}
.wesp-setup__qr-wrap {
display: flex;
justify-content: center;
margin: 8px 0 10px;
padding: 14px;
border-radius: 12px;
background: var(--setup-surface-muted);
border: 1px solid var(--setup-border);
}
.wesp-setup__qr {
display: block;
width: min(200px, 100%);
height: auto;
margin: 0;
}
.wesp-setup__url {
word-break: break-all;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 11px;
line-height: 1.5;
text-align: center;
color: var(--setup-text-muted);
}
.wesp-setup__progress-bar {
height: 6px;
background: var(--setup-surface-muted);
border-radius: 999px;
overflow: hidden;
margin-top: 4px;
}
.wesp-setup__progress-fill {
height: 100%;
width: 0;
background: var(--setup-accent);
transition: width 0.2s linear;
}
.wesp-setup__summary-list {
margin: 0;
padding-left: 18px;
font-size: 14px;
line-height: 1.6;
}
.wesp-setup__input--touch {
cursor: text;
caret-color: var(--setup-accent);
}
.wesp-setup__keyboard {
flex: 0 0 auto;
display: none;
padding: 10px 16px 4px;
border-top: 1px solid var(--setup-border);
background: var(--setup-surface);
opacity: 0;
transform: translateY(6px);
transition: opacity 0.2s ease, transform 0.2s ease;
}
.wesp-setup__keyboard.is-visible {
display: block;
opacity: 1;
transform: translateY(0);
}
.wesp-setup__keyboard-grid {
display: grid;
grid-template-columns: repeat(10, minmax(0, 1fr));
gap: 6px;
max-width: 100%;
margin: 0 auto;
}
.wesp-setup__keyboard-key {
appearance: none;
display: flex;
align-items: center;
justify-content: center;
min-height: 42px;
padding: 8px 4px;
border: 1px solid var(--setup-border-strong);
border-radius: 8px;
background: var(--setup-surface-muted);
color: var(--setup-text);
font-size: 15px;
font-weight: 500;
font-family: inherit;
line-height: 1;
cursor: pointer;
user-select: none;
-webkit-tap-highlight-color: transparent;
transition: background 0.12s ease, border-color 0.12s ease, transform 0.08s ease;
}
.wesp-setup__keyboard-key:active {
transform: scale(0.96);
background: var(--setup-accent-soft);
border-color: var(--setup-accent);
}
.wesp-setup__keyboard-key--wide {
font-size: 18px;
}
.wesp-setup__keyboard-key--action {
font-size: 12px;
font-weight: 600;
letter-spacing: -0.02em;
}
@media (max-width: 520px) {
.wesp-setup__intro {
height: min(200px, 32vh);
}
.wesp-setup__panel {
left: 0;
right: 0;
top: auto;
bottom: 0;
transform: none;
width: 100%;
max-width: 100%;
max-height: min(92dvh, 720px);
border-radius: 18px 18px 0 0;
border-bottom: none;
}
.wesp-setup__header {
padding: 18px 18px 0;
}
.wesp-setup__content {
padding: 14px 18px 6px;
}
.wesp-setup__footer {
padding: 10px 18px calc(14px + env(safe-area-inset-bottom, 0px));
}
.wesp-setup__title {
font-size: 20px;
}
.wesp-setup__role-card {
min-height: 72px;
padding: 16px 14px;
}
.wesp-setup__theme-grid .wesp-setup__theme-card {
padding: 11px 12px 11px 56px;
}
.wesp-setup__theme-grid .wesp-setup__theme-card::before {
left: 12px;
width: 28px;
height: 28px;
margin-top: -14px;
}
.wesp-setup__btn {
min-height: 44px;
}
.wesp-setup__actions {
gap: 6px;
}
.wesp-setup__actions-fill {
flex-basis: 100%;
height: 0;
min-width: 0;
}
.wesp-setup__btn--primary {
flex: 1 1 auto;
min-width: calc(50% - 4px);
}
.wesp-setup__keyboard {
padding: 8px 14px 2px;
}
.wesp-setup__keyboard-grid {
gap: 5px;
}
.wesp-setup__keyboard-key {
min-height: 44px;
font-size: 14px;
}
.wesp-setup__keyboard-key--action {
font-size: 11px;
}
}
+75
View File
@@ -0,0 +1,75 @@
/* Экран /starting — тот же масштаб логотипа, что intro в setup wizard */
.wesp-startup-screen {
min-height: 100vh;
margin: 0;
background: #f6f6f4;
color: rgba(26, 30, 28, 0.72);
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
}
.wesp-startup__stage {
position: fixed;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
z-index: 10001;
width: min(520px, calc(100vw - 32px));
display: flex;
flex-direction: column;
align-items: stretch;
}
.wesp-startup__stage .wesp-setup__intro {
position: relative;
left: auto;
top: auto;
transform: none;
width: 100%;
height: min(240px, 36vh);
background: transparent;
}
.wesp-startup__stage .wesp-logo-loader,
.wesp-startup__stage .wesp-logo-loader--embedded,
.wesp-startup__stage .wesp-logo-loader--plain {
background: transparent !important;
}
/* Подпись только снаружи (#startupQuip), не пустой tagline внутри лоадера */
.wesp-startup__stage .wesp-logo-loader__tagline {
display: none !important;
}
.wesp-startup__quip {
flex: 0 0 auto;
margin: 10px 0 0;
padding: 0 12px;
text-align: center;
font-size: 0.92rem;
line-height: 1.5;
color: rgba(26, 30, 28, 0.58);
min-height: 3em;
max-width: 100%;
}
.wesp-startup__quip--fade {
opacity: 0;
}
.wesp-startup__quip--error {
color: #cf1322;
}
/* Киоск / логин зоотехника: --z-login-bg из zootechnician-login.css */
html[data-theme="dark"] .wesp-startup-screen {
background: #12181b;
color: #ffffff;
}
html[data-theme="dark"] .wesp-startup__quip {
color: #a8b0b8;
}
html[data-theme="dark"] .wesp-startup__quip--error {
color: #ff7875;
}
@@ -0,0 +1,227 @@
/* Ненавязчивый баннер «доступно обновление» — киоск и операторские страницы */
#wespUpdateBanner {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 4500;
display: none;
box-sizing: border-box;
padding: 10px 14px;
padding-bottom: max(10px, env(safe-area-inset-bottom, 0px));
background: linear-gradient(180deg, rgba(30, 58, 138, 0.97), rgba(29, 78, 216, 0.98));
color: #f8fafc;
font-family: Inter, system-ui, -apple-system, "Segoe UI", sans-serif;
font-size: 14px;
line-height: 1.45;
box-shadow: 0 -4px 24px rgba(15, 23, 42, 0.25);
border-top: 1px solid rgba(255, 255, 255, 0.12);
}
#wespUpdateBanner.wesp-update-banner--visible {
display: block;
}
#wespUpdateBanner .wesp-update-banner-inner {
max-width: 1200px;
margin: 0 auto;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px 14px;
}
#wespUpdateBanner .wesp-update-banner-text {
flex: 1 1 200px;
min-width: 0;
}
#wespUpdateBanner .wesp-update-banner-text strong {
font-weight: 600;
}
#wespUpdateBanner .wesp-update-banner-changelog {
display: none;
width: 100%;
margin: 4px 0 0;
padding: 8px 10px;
background: rgba(0, 0, 0, 0.18);
border-radius: 6px;
font-size: 13px;
white-space: pre-wrap;
word-break: break-word;
max-height: 120px;
overflow: auto;
}
#wespUpdateBanner.wesp-update-banner--expanded .wesp-update-banner-changelog {
display: block;
}
#wespUpdateBanner .wesp-update-banner-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
}
#wespUpdateBanner .wesp-update-banner-btn {
border: 1px solid rgba(255, 255, 255, 0.35);
background: rgba(255, 255, 255, 0.12);
color: #fff;
border-radius: 6px;
padding: 8px 14px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
font-family: inherit;
transition: background 0.15s ease, border-color 0.15s ease;
}
#wespUpdateBanner .wesp-update-banner-btn:hover {
background: rgba(255, 255, 255, 0.22);
}
#wespUpdateBanner .wesp-update-banner-btn--primary {
background: #fff;
color: #1d4ed8;
border-color: #fff;
}
#wespUpdateBanner .wesp-update-banner-btn--primary:hover {
background: #eff6ff;
}
#wespUpdateOverlay {
position: fixed;
inset: 0;
z-index: 6000;
display: none;
align-items: center;
justify-content: center;
padding: 16px;
background: rgba(15, 23, 42, 0.72);
box-sizing: border-box;
}
#wespUpdateOverlay.wesp-update-overlay--visible {
display: flex;
}
#wespUpdateOverlay .wesp-update-overlay-panel {
background: #fff;
color: #111827;
border-radius: 10px;
padding: 24px 28px;
max-width: 420px;
width: 100%;
text-align: center;
font-family: Inter, system-ui, sans-serif;
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.25);
}
#wespUpdateOverlay .wesp-update-overlay-title {
margin: 0 0 10px;
font-size: 1.125rem;
font-weight: 600;
}
#wespUpdateOverlay .wesp-update-overlay-body {
margin: 0 0 16px;
font-size: 0.95rem;
color: #4b5563;
line-height: 1.5;
}
#wespUpdateOverlay .wesp-update-progress-wrap {
text-align: left;
}
#wespUpdateOverlay .wesp-update-progress-track {
height: 8px;
border-radius: 999px;
background: #e5e7eb;
overflow: hidden;
}
#wespUpdateOverlay .wesp-update-progress-bar {
height: 100%;
width: 0;
border-radius: 999px;
background: linear-gradient(
90deg,
var(--primary-color, #2196f3),
var(--primary-dark, #1976d2)
);
transition: width 0.25s ease;
}
#wespUpdateOverlay .wesp-update-progress-meta {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 12px;
margin-top: 8px;
font-size: 0.8125rem;
color: #6b7280;
}
#wespUpdateOverlay .wesp-update-progress-percent {
font-weight: 600;
color: #1d4ed8;
flex-shrink: 0;
}
#wespUpdateOverlay .wesp-update-progress-detail {
text-align: right;
min-width: 0;
word-break: break-word;
}
html[data-theme="dark"] #wespUpdateOverlay .wesp-update-overlay-panel {
background: #242424;
color: #f3f4f6;
}
#wespUpdateOverlay .wesp-update-progress-bar.wesp-update-progress-bar--error {
background: linear-gradient(90deg, #dc2626, #b91c1c);
}
#wespUpdateOverlay .wesp-update-overlay-panel--rollback {
border: 1px solid #fecaca;
}
html[data-theme="dark"] #wespUpdateOverlay .wesp-update-overlay-panel--rollback {
border-color: #7f1d1d;
}
html[data-theme="dark"] #wespUpdateOverlay .wesp-update-overlay-body {
color: #d1d5db;
}
html[data-theme="dark"] #wespUpdateOverlay .wesp-update-progress-track {
background: #374151;
}
html[data-theme="dark"] #wespUpdateOverlay .wesp-update-progress-meta {
color: #9ca3af;
}
html[data-theme="dark"] #wespUpdateOverlay .wesp-update-progress-percent {
color: #93c5fd;
}
@media (max-width: 480px) {
#wespUpdateBanner .wesp-update-banner-inner {
flex-direction: column;
align-items: stretch;
}
#wespUpdateBanner .wesp-update-banner-actions {
justify-content: stretch;
}
#wespUpdateBanner .wesp-update-banner-btn {
flex: 1;
text-align: center;
}
}
@@ -544,32 +544,44 @@ html[data-theme="dark"] .mobile-brand .logo--dark-theme {
inset: 0;
background: rgba(15, 23, 42, 0.55);
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
z-index: 2000;
opacity: 0;
visibility: hidden;
pointer-events: none;
transition: opacity var(--transition), visibility var(--transition);
}
.mobile-menu-overlay.active {
opacity: 1;
visibility: visible;
pointer-events: auto;
}
.mobile-menu {
position: fixed;
position: absolute;
top: 0;
right: -100%;
right: 0;
width: min(88vw, 340px);
max-width: 100%;
height: 100%;
height: 100dvh;
background: var(--gradient-blue);
box-shadow: -8px 0 32px rgba(0, 0, 0, 0.25);
transition: right 0.28s cubic-bezier(0.22, 1, 0.36, 1);
transform: translateX(100%);
transition: transform 0.28s cubic-bezier(0.22, 1, 0.36, 1);
display: flex;
flex-direction: column;
overflow: hidden;
will-change: transform;
}
.mobile-menu-overlay.active .mobile-menu {
right: 0;
transform: translateX(0);
}
body.mobile-menu-open {
overflow: hidden;
}
.mobile-menu-header {
@@ -578,6 +590,7 @@ html[data-theme="dark"] .mobile-brand .logo--dark-theme {
align-items: center;
padding: 1.25rem 1.25rem 1rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.12);
flex-shrink: 0;
}
.mobile-brand {
@@ -609,9 +622,11 @@ html[data-theme="dark"] .mobile-brand .logo--dark-theme {
}
.mobile-menu-items {
flex: 1;
flex: 1 1 auto;
min-height: 0;
padding: 0.5rem 0;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
}
.mobile-menu-item {
@@ -643,6 +658,7 @@ html[data-theme="dark"] .mobile-brand .logo--dark-theme {
.mobile-menu-footer {
padding: 1.25rem;
border-top: 1px solid rgba(255, 255, 255, 0.12);
flex-shrink: 0;
}
.mobile-user-info {
@@ -0,0 +1,345 @@
/* Страница входа зоотехника — тёмная тема по референсу, светлая — отдельная палитра */
:root {
--z-login-bg: var(--bg-page, #f5f7fa);
--z-login-surface: var(--surface, #ffffff);
--z-login-input-bg: #e8edf2;
--z-login-text: var(--text, #1e293b);
--z-login-muted: var(--muted, #64748b);
--z-login-icon: #4b5563;
--z-login-btn: var(--primary-color, #2196f3);
--z-login-btn-hover: var(--primary-dark, #1976d2);
--z-login-shadow: 0 16px 48px rgba(17, 24, 39, 0.1),
0 0 0 1px rgba(17, 24, 39, 0.06);
--z-login-switch-track: #cbd5e1;
--z-login-switch-thumb: #ffffff;
}
html[data-theme="dark"] {
--z-login-bg: var(--bg-page, #12181b);
--z-login-surface: var(--surface, #2c3338);
--z-login-input-bg: var(--field, #3e444a);
--z-login-text: var(--text, #f5f5f5);
--z-login-muted: var(--muted, #a8b0b8);
--z-login-icon: #ffffff;
--z-login-btn: var(--primary-color, #2196f3);
--z-login-btn-hover: var(--primary-light, #42a5f5);
--z-login-shadow: 0 20px 56px rgba(0, 0, 0, 0.45),
0 0 0 1px rgba(255, 255, 255, 0.04);
--z-login-switch-track: #252b30;
--z-login-switch-thumb: #ffffff;
}
html[data-theme="organic"] {
--z-login-bg: var(--bg-page, #f6f6f4);
--z-login-surface: var(--surface, #ffffff);
--z-login-input-bg: #eff4f2;
--z-login-text: var(--text, #1a1e1c);
--z-login-muted: var(--muted, #4a5a52);
--z-login-icon: #4a5a52;
--z-login-btn: var(--primary-color, #48816d);
--z-login-btn-hover: var(--primary-dark, #3a6b58);
--z-login-shadow: 0 16px 48px rgba(26, 30, 28, 0.08),
0 0 0 1px rgba(26, 30, 28, 0.04);
--z-login-switch-track: #e8e8e2;
--z-login-switch-thumb: #ffffff;
}
*,
*::before,
*::after {
box-sizing: border-box;
}
body.zootech-login-body {
margin: 0;
min-height: 100vh;
font-family: var(--zootech-font, "Inter", system-ui, sans-serif);
background: var(--z-login-bg);
color: var(--z-login-text);
display: flex;
align-items: center;
justify-content: center;
padding: 24px 16px;
}
.z-login-page {
width: 100%;
max-width: 420px;
transition: opacity 0.25s ease, visibility 0.25s;
}
.z-login-page--hidden {
opacity: 0;
visibility: hidden;
pointer-events: none;
}
.z-login-backdrop {
position: fixed;
inset: 0;
z-index: 10000;
background: var(--z-login-bg);
}
.z-login-loader {
position: fixed;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
z-index: 10001;
width: min(520px, calc(100vw - 32px));
height: min(240px, 36vh);
border-radius: 16px;
overflow: hidden;
background: transparent;
box-shadow: none;
}
.z-login-loader .wesp-logo-loader,
.z-login-loader .wesp-logo-loader--embedded,
.z-login-loader .wesp-logo-loader--plain {
background: transparent !important;
}
.z-login-loader .wesp-logo-loader__label,
.z-login-loader .wesp-logo-loader__percent {
color: var(--z-login-muted);
}
.z-login-loader .wesp-logo-loader__bar {
background: rgba(26, 30, 28, 0.1);
}
html[data-theme="dark"] .z-login-loader .wesp-logo-loader__bar {
background: rgba(255, 255, 255, 0.12);
}
.z-login-card {
border-radius: 18px;
overflow: hidden;
box-shadow: var(--z-login-shadow);
background: var(--z-login-surface);
}
.z-login-form-panel {
padding: 40px;
display: flex;
flex-direction: column;
justify-content: center;
}
.z-login-title {
font-family: inherit;
font-size: 1.75rem;
font-weight: 700;
text-align: center;
color: var(--z-login-text);
margin: 0 0 28px;
letter-spacing: 0.02em;
}
.z-login-field {
margin-bottom: 16px;
}
.z-login-field--with-icon {
display: flex;
align-items: stretch;
gap: 0;
background: var(--z-login-input-bg);
border-radius: 8px;
overflow: hidden;
border: none;
}
.z-login-field-icon {
flex: 0 0 48px;
display: flex;
align-items: center;
justify-content: center;
color: var(--z-login-icon);
}
.z-login-field-icon svg {
display: block;
}
.z-login-input {
flex: 1;
min-width: 0;
padding: 14px 14px 14px 0;
font-size: 1rem;
font-family: inherit;
border: none;
background: transparent;
color: var(--z-login-text);
outline: none;
}
.z-login-input::placeholder {
color: var(--z-login-muted);
opacity: 1;
}
.z-login-input:focus {
outline: none;
}
.z-login-input:-webkit-autofill,
.z-login-input:-webkit-autofill:hover,
.z-login-input:-webkit-autofill:focus,
.z-login-input:-webkit-autofill:active {
-webkit-box-shadow: 0 0 0 1000px var(--z-login-input-bg) inset !important;
box-shadow: 0 0 0 1000px var(--z-login-input-bg) inset !important;
-webkit-text-fill-color: var(--z-login-text) !important;
caret-color: var(--z-login-text);
transition: background-color 600000s ease-in-out 0s;
}
.z-login-input:autofill {
box-shadow: 0 0 0 1000px var(--z-login-input-bg) inset !important;
-webkit-text-fill-color: var(--z-login-text) !important;
caret-color: var(--z-login-text);
}
.z-login-field--with-icon:focus-within {
box-shadow: 0 0 0 2px rgba(var(--primary-rgb, 33, 150, 243), 0.45);
}
html[data-theme="dark"] .z-login-field--with-icon:focus-within {
box-shadow: 0 0 0 2px rgba(var(--primary-rgb, 33, 150, 243), 0.5);
}
.z-login-submit {
width: 100%;
margin-top: 8px;
padding: 14px 20px;
font-size: 1rem;
font-weight: 700;
font-family: inherit;
color: #fff;
border: none;
border-radius: 8px;
cursor: pointer;
background: var(--gradient-blue, var(--z-login-btn));
box-shadow: 0 4px 14px rgba(var(--primary-rgb, 33, 150, 243), 0.35);
transition: background 0.15s ease, transform 0.15s ease, opacity 0.15s ease;
}
.z-login-submit:hover:not(:disabled) {
background: var(--z-login-btn-hover);
transform: translateY(-1px);
}
.z-login-submit:disabled {
opacity: 0.65;
cursor: not-allowed;
transform: none;
}
.z-login-remember {
display: flex;
align-items: center;
gap: 12px;
margin-top: 22px;
cursor: pointer;
user-select: none;
}
.z-login-switch-input {
position: absolute;
opacity: 0;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
}
.z-login-switch-ui {
flex-shrink: 0;
width: 44px;
height: 26px;
border-radius: 13px;
background: var(--z-login-switch-track);
position: relative;
transition: background 0.2s ease;
}
.z-login-switch-ui::after {
content: "";
position: absolute;
width: 22px;
height: 22px;
border-radius: 50%;
background: var(--z-login-switch-thumb);
top: 2px;
left: 2px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.25);
transition: transform 0.2s ease;
}
.z-login-switch-input:checked + .z-login-switch-ui::after {
transform: translateX(18px);
}
.z-login-switch-input:focus-visible + .z-login-switch-ui {
outline: 2px solid var(--z-login-btn);
outline-offset: 2px;
}
.z-login-switch-input:disabled + .z-login-switch-ui {
opacity: 0.5;
}
.z-login-remember-text {
font-family: inherit;
font-size: 0.95rem;
color: var(--z-login-text);
}
.z-login-msg {
padding: 12px 14px;
border-radius: 8px;
margin-bottom: 16px;
font-size: 0.9rem;
display: none;
}
.z-login-msg--error {
background: #dc3545;
color: #fff;
}
.z-login-spinner {
display: none;
width: 22px;
height: 22px;
margin: 0 auto;
border: 2px solid rgba(255, 255, 255, 0.35);
border-top-color: #fff;
border-radius: 50%;
animation: z-login-spin 0.85s linear infinite;
}
@keyframes z-login-spin {
to {
transform: rotate(360deg);
}
}
@media (max-width: 520px) {
.z-login-loader {
height: min(200px, 32vh);
}
}
@media (max-width: 400px) {
.z-login-form-panel {
padding: 28px 22px 32px;
}
.z-login-title {
font-size: 1.5rem;
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 320 B

+5
View File
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" role="img" aria-label="Комптон">
<text x="16" y="24.5" text-anchor="middle"
font-family="Arial, Helvetica, 'DejaVu Sans', sans-serif"
font-size="22" font-weight="700" fill="#ffffff">К</text>
</svg>

After

Width:  |  Height:  |  Size: 284 B

Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.
+3
View File
@@ -0,0 +1,3 @@
<svg viewBox="0 0 640 512" fill="currentColor" aria-hidden="true" focusable="false" role="img">
<path d="M634 276.8l-9.999-13.88L624 185.7c0-11.88-12.5-19.49-23.12-14.11c-10.88 5.375-19.5 13.5-26.38 23l-65.75-90.92C490.6 78.71 461.8 64 431 64H112C63.37 64 24 103.4 24 152v86.38C9.5 250.1 0 267.9 0 288v32h8c35.38 0 64-28.62 64-64L72 152c0-16.88 10.5-31.12 25.38-37C96.5 119.1 96 123.5 96 128l.0002 304c0 8.875 7.126 16 16 16h63.1c8.875 0 16-7.125 16-16l.0006-112c9.375 9.375 20.25 16.5 32 21.88V368c0 8.875 7.252 16 16 16c8.875 0 15.1-7.125 15.1-16v-17.25c9.125 1 12.88 2.25 32-.125V368c0 8.875 7.25 16 16 16c8.875 0 16-7.125 16-16v-26.12C331.8 336.5 342.6 329.2 352 320l-.0012 112c0 8.875 7.125 16 15.1 16h64c8.75 0 16-7.125 16-16V256l31.1 32l.0006 41.55c0 12.62 3.752 24.95 10.75 35.45l41.25 62C540.8 440.1 555.5 448 571.4 448c22.5 0 41.88-15.88 46.25-38l21.75-108.6C641.1 292.8 639.1 283.9 634 276.8zM377.3 167.4l-22.88 22.75C332.5 211.8 302.9 224 272.1 224S211.5 211.8 189.6 190.1L166.8 167.4C151 151.8 164.4 128 188.9 128h166.2C379.6 128 393 151.8 377.3 167.4zM576 352c-8.875 0-16-7.125-16-16s7.125-16 16-16s16 7.125 16 16S584.9 352 576 352z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

@@ -0,0 +1,3 @@
<svg viewBox="5 2 14 20" fill="currentColor" aria-hidden="true" role="img">
<path d="M12 22q-2.65 0-4.325-.837T6 19q0-.6.363-1.112T7.374 17l1.575 1.475q-.225.1-.488.225t-.412.3q.325.4 1.5.7T12 20t2.463-.3t1.512-.7q-.175-.2-.45-.325T15 18.45l1.55-1.5q.7.4 1.075.913T18 19q0 1.325-1.675 2.163T12 22m0-3q-3.525-2.6-5.262-5.05T5 9.15q0-1.775.638-3.113T7.275 3.8t2.25-1.35T12 2t2.475.45t2.25 1.35t1.638 2.238T19 9.15q0 2.35-1.737 4.8T12 19m0-8q.825 0 1.413-.587T14 9t-.587-1.412T12 7t-1.412.588T10 9t.588 1.413T12 11"/>
</svg>

After

Width:  |  Height:  |  Size: 524 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

@@ -0,0 +1,401 @@
/**
* Модалка «Учётные документы» на /feed_consumption
* Сценарий: Реквизиты → Подписи → Экспорт
*/
(function () {
const ORG_FIELDS = [
{ id: "faOrgName", key: "organization_name", label: "Организация" },
{ id: "faOkpo", key: "okpo", label: "ОКПО" },
{ id: "faDepartment", key: "department", label: "Отделение (участок)" },
{ id: "faFarm", key: "farm_name", label: "Ферма" },
{ id: "faBrigade", key: "brigade", label: "Бригада" },
{ id: "faAnimalType", key: "animal_type", label: "Вид / группа скота" },
{ id: "faResponsible", key: "responsible_person", label: "Ответственный" },
{ id: "faZootech", key: "zootechnician", label: "Зоотехник (ФИО)" },
{ id: "faWarehouse", key: "warehouse_keeper", label: "Кладовщик (ФИО)" },
{ id: "faDocPrefix", key: "document_number_prefix", label: "Префикс номера документа" },
];
const SIGNATURES = [
{ role: "zootechnician", label: "Зоотехник", canvasId: "faSigZootech" },
{ role: "warehouse_keeper", label: "Кладовщик", canvasId: "faSigWarehouse" },
{ role: "recipient", label: "Получатель", canvasId: "faSigRecipient" },
];
const canvases = {};
let lastSignatureStatus = {};
function getExportMonth() {
const monthEl = document.getElementById("faExportMonth");
if (monthEl && monthEl.value) {
return monthEl.value;
}
const fromEl = document.getElementById("consumptionDateFrom");
if (fromEl && fromEl.value) {
return fromEl.value.slice(0, 7);
}
const now = new Date();
return now.getFullYear() + "-" + String(now.getMonth() + 1).padStart(2, "0");
}
function getDateRange() {
const fromEl = document.getElementById("consumptionDateFrom");
const toEl = document.getElementById("consumptionDateTo");
let from = fromEl && fromEl.value;
let to = toEl && toEl.value;
if (!from || !to) {
const now = new Date();
from = new Date(now.getFullYear(), now.getMonth(), 1).toISOString().slice(0, 10);
to = new Date(now.getFullYear(), now.getMonth() + 1, 0).toISOString().slice(0, 10);
}
return { from, to, month: getExportMonth() };
}
function syncExportMonthField() {
const monthEl = document.getElementById("faExportMonth");
if (!monthEl || monthEl.value) return;
monthEl.value = getExportMonth();
}
async function refreshSp20Status() {
const el = document.getElementById("faSp20Status");
if (!el) return;
const month = getExportMonth();
el.textContent = "Проверка данных за " + month + "…";
try {
const r = await fetch("/api/feed-accounting/sp20-preview?month=" + encodeURIComponent(month), {
credentials: "same-origin",
});
if (!r.ok) {
el.textContent = "Не удалось загрузить сводку по СП-20.";
return;
}
const data = await r.json();
if (!data.lines_count) {
el.textContent = "За " + month + " нет строк расхода в отчётах загрузки. Проверьте месяц или наличие отчётов.";
el.className = "text-warning small mb-3";
return;
}
let msg = "СП-20 за " + month + ": " + data.lines_count + " строк, " + data.total_kg + " кг";
if (!data.has_requisites) {
msg += ". Заполните реквизиты на вкладке «Реквизиты СП-20».";
el.className = "text-warning small mb-3";
} else {
el.className = "text-success small mb-3";
}
el.textContent = msg;
} catch (err) {
el.textContent = "Не удалось загрузить сводку по СП-20.";
el.className = "text-danger small mb-3";
}
}
function userError(message, fallback) {
const notifyApi = window.WespZootechNotify;
if (notifyApi && typeof notifyApi.userFacingMessage === "function") {
return notifyApi.userFacingMessage(message, fallback);
}
return fallback || "Не удалось выполнить операцию. Попробуйте ещё раз.";
}
function notify(msg, type) {
if (typeof notyf !== "undefined") {
if (type === "error") notyf.error(msg);
else notyf.success(msg);
}
}
async function downloadUrl(url, label) {
const fallback = "Не удалось скачать документ. Попробуйте ещё раз.";
try {
const r = await fetch(url, { credentials: "same-origin" });
if (!r.ok) {
let body = null;
try {
body = await r.json();
} catch (e) { /* ignore */ }
const notifyApi = window.WespZootechNotify;
const msg = notifyApi && typeof notifyApi.messageFromResponseBody === "function"
? notifyApi.messageFromResponseBody(body, fallback)
: userError(null, fallback);
notify(msg, "error");
return;
}
const blob = await r.blob();
const cd = r.headers.get("Content-Disposition") || "";
const m = cd.match(/filename="([^"]+)"/);
const filename = m ? m[1] : "download";
const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(function () { URL.revokeObjectURL(a.href); }, 5000);
} catch (err) {
notify(userError(null, fallback), "error");
}
}
function showTab(name) {
document.querySelectorAll(".fa-tab-panel").forEach(function (el) {
el.hidden = el.dataset.tab !== name;
});
document.querySelectorAll(".fa-tab-btn").forEach(function (btn) {
btn.classList.toggle("active", btn.dataset.tab === name);
});
if (name === "export") {
syncExportMonthField();
refreshSp20Status();
}
}
function setupCanvas(canvas) {
if (!canvas || canvases[canvas.id]) return;
const ctx = canvas.getContext("2d");
ctx.strokeStyle = "#1a1a1a";
ctx.lineWidth = 2;
ctx.lineCap = "round";
let drawing = false;
function pos(e) {
const rect = canvas.getBoundingClientRect();
const scaleX = canvas.width / rect.width;
const scaleY = canvas.height / rect.height;
const ev = e.touches ? e.touches[0] : e;
return {
x: (ev.clientX - rect.left) * scaleX,
y: (ev.clientY - rect.top) * scaleY,
};
}
function start(e) {
drawing = true;
const p = pos(e);
ctx.beginPath();
ctx.moveTo(p.x, p.y);
e.preventDefault();
}
function move(e) {
if (!drawing) return;
const p = pos(e);
ctx.lineTo(p.x, p.y);
ctx.stroke();
e.preventDefault();
}
function end() { drawing = false; }
canvas.addEventListener("mousedown", start);
canvas.addEventListener("mousemove", move);
canvas.addEventListener("mouseup", end);
canvas.addEventListener("mouseleave", end);
canvas.addEventListener("touchstart", start, { passive: false });
canvas.addEventListener("touchmove", move, { passive: false });
canvas.addEventListener("touchend", end);
canvases[canvas.id] = { canvas: canvas, ctx: ctx };
}
function clearCanvas(canvasId) {
const item = canvases[canvasId];
if (!item) return;
item.ctx.clearRect(0, 0, item.canvas.width, item.canvas.height);
}
function canvasHasInk(canvasId) {
const item = canvases[canvasId];
if (!item) return false;
const data = item.ctx.getImageData(0, 0, item.canvas.width, item.canvas.height).data;
for (let i = 3; i < data.length; i += 4) {
if (data[i] > 0) return true;
}
return false;
}
function fillFarmDatalist(farms) {
const list = document.getElementById("faFarmList");
if (!list) return;
list.innerHTML = "";
(farms || []).forEach(function (farm) {
const opt = document.createElement("option");
opt.value = farm;
list.appendChild(opt);
});
}
function isSetupComplete(settings, sigStatus) {
const hasRequisites = Boolean(
(settings.organization_name || "").trim() &&
(settings.zootechnician || "").trim() &&
(settings.warehouse_keeper || "").trim()
);
const hasSignatures = Boolean(sigStatus.zootechnician && sigStatus.warehouse_keeper);
return hasRequisites && hasSignatures;
}
async function loadOrgSettings() {
const r = await fetch("/api/feed-accounting/org-settings", { credentials: "same-origin" });
if (!r.ok) return null;
const data = await r.json();
ORG_FIELDS.forEach(function (f) {
const el = document.getElementById(f.id);
if (el) el.value = data[f.key] || "";
});
fillFarmDatalist(data.available_farms || []);
if (data.signatures) {
lastSignatureStatus = data.signatures;
}
return data;
}
async function loadSignaturesOntoCanvases() {
const roles = lastSignatureStatus;
await Promise.all(
SIGNATURES.map(function (s) {
if (!roles[s.role]) return Promise.resolve();
return new Promise(function (resolve) {
const img = new Image();
img.onload = function () {
const item = canvases[s.canvasId];
if (item) {
item.ctx.clearRect(0, 0, item.canvas.width, item.canvas.height);
item.ctx.drawImage(img, 0, 0, item.canvas.width, item.canvas.height);
}
resolve();
};
img.onerror = function () { resolve(); };
img.src = "/api/feed-accounting/signatures/" + s.role + ".png?t=" + Date.now();
});
})
);
}
async function saveOrgSettings(silent) {
const payload = {};
ORG_FIELDS.forEach(function (f) {
const el = document.getElementById(f.id);
if (el) payload[f.key] = el.value.trim();
});
const r = await fetch("/api/feed-accounting/org-settings", {
method: "PUT",
credentials: "same-origin",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
const data = await r.json();
if (!r.ok) {
notify(userError(data.message, "Не удалось сохранить реквизиты"), "error");
return false;
}
if (!silent) notify("Реквизиты сохранены");
return true;
}
async function saveSignature(role, canvasId, silent) {
const item = canvases[canvasId];
if (!item || !canvasHasInk(canvasId)) return true;
const dataUrl = item.canvas.toDataURL("image/png");
const r = await fetch("/api/feed-accounting/signatures/" + role, {
method: "POST",
credentials: "same-origin",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ png_base64: dataUrl }),
});
const data = await r.json();
if (!r.ok) {
notify(userError(data.message, "Не удалось сохранить подпись"), "error");
return false;
}
if (data.roles) lastSignatureStatus = data.roles;
if (!silent) notify("Подпись сохранена");
return true;
}
async function saveAll() {
const btn = document.getElementById("faSaveAllBtn");
if (btn) btn.disabled = true;
try {
const orgOk = await saveOrgSettings(true);
if (!orgOk) return;
for (const s of SIGNATURES) {
const ok = await saveSignature(s.role, s.canvasId, true);
if (!ok) return;
}
notify("Реквизиты и подписи сохранены");
const settings = await loadOrgSettings();
if (settings && isSetupComplete(settings, lastSignatureStatus)) {
showTab("export");
}
} finally {
if (btn) btn.disabled = false;
}
}
window.openFeedAccountingModal = async function () {
const modal = document.getElementById("feedAccountingModal");
if (!modal) return;
modal.style.display = "flex";
document.body.style.overflow = "hidden";
SIGNATURES.forEach(function (s) {
setupCanvas(document.getElementById(s.canvasId));
});
const settings = await loadOrgSettings();
await loadSignaturesOntoCanvases();
syncExportMonthField();
const complete = settings && isSetupComplete(settings, lastSignatureStatus);
showTab(complete ? "export" : "requisites");
};
window.closeFeedAccountingModal = function () {
const modal = document.getElementById("feedAccountingModal");
if (!modal) return;
modal.style.display = "none";
document.body.style.overflow = "";
};
document.addEventListener("DOMContentLoaded", function () {
const openBtn = document.getElementById("openFeedAccountingBtn");
if (openBtn) openBtn.addEventListener("click", function () { openFeedAccountingModal(); });
document.querySelectorAll(".fa-tab-btn").forEach(function (btn) {
btn.addEventListener("click", function () {
showTab(btn.dataset.tab);
});
});
const exportMonthEl = document.getElementById("faExportMonth");
if (exportMonthEl) {
exportMonthEl.addEventListener("change", refreshSp20Status);
}
const saveAllBtn = document.getElementById("faSaveAllBtn");
if (saveAllBtn) saveAllBtn.addEventListener("click", saveAll);
SIGNATURES.forEach(function (s) {
const clearBtn = document.getElementById("clear_" + s.canvasId);
if (clearBtn) clearBtn.addEventListener("click", function () { clearCanvas(s.canvasId); });
});
const exports = [
{ id: "faExportConsumptionXlsx", path: function (d) { return "/api/feed-accounting/consumption.xlsx?date_from=" + encodeURIComponent(d.from) + "&date_to=" + encodeURIComponent(d.to); }, label: "Потребление Excel" },
{ id: "faExportConsumptionPdf", path: function (d) { return "/api/feed-accounting/consumption.pdf?date_from=" + encodeURIComponent(d.from) + "&date_to=" + encodeURIComponent(d.to); }, label: "Потребление PDF" },
{ id: "faExportStock", path: function (d) { return "/api/feed-accounting/stock-balances.xlsx?date_from=" + encodeURIComponent(d.from) + "&date_to=" + encodeURIComponent(d.to); }, label: "Остатки" },
{ id: "faExportSp20Xlsx", path: function (d) { return "/api/feed-accounting/sp20.xlsx?month=" + encodeURIComponent(d.month); }, label: "СП-20 Excel" },
{ id: "faExportSp20Pdf", path: function (d) { return "/api/feed-accounting/sp20.pdf?month=" + encodeURIComponent(d.month); }, label: "СП-20 PDF" },
{ id: "faExportJournalXlsx", path: function (d) { return "/api/feed-accounting/journal.xlsx?month=" + encodeURIComponent(d.month); }, label: "Журнал Excel" },
{ id: "faExportJournalPdf", path: function (d) { return "/api/feed-accounting/journal.pdf?month=" + encodeURIComponent(d.month); }, label: "Журнал PDF" },
{ id: "faExportZip", path: function (d) { return "/api/feed-accounting/documents.zip?date_from=" + encodeURIComponent(d.from) + "&date_to=" + encodeURIComponent(d.to) + "&month=" + encodeURIComponent(d.month); }, label: "ZIP" },
];
exports.forEach(function (ex) {
const btn = document.getElementById(ex.id);
if (!btn) return;
btn.addEventListener("click", function () {
const d = getDateRange();
downloadUrl(ex.path(d), ex.label);
});
});
const modal = document.getElementById("feedAccountingModal");
if (modal) {
modal.addEventListener("click", function (e) {
if (e.target === modal) closeFeedAccountingModal();
});
}
});
})();
@@ -0,0 +1,170 @@
/**
* Модальное окно «Калибровка»: iframe с /calibration?embed=1.
*/
(function () {
const LOG = "[WespKioskCalibration]";
const MENU_OVERLAY_ID = "wespKioskMenuOverlay";
function ensureStylesheet() {
if (document.getElementById("kioskCalibrationModalCss")) return;
const link = document.createElement("link");
link.id = "kioskCalibrationModalCss";
link.rel = "stylesheet";
link.href = "/static/css/kiosk-calibration-modal.css";
document.head.appendChild(link);
}
function resizeFrame(iframe) {
if (!iframe || !iframe.contentDocument) return;
try {
const doc = iframe.contentDocument;
const height = Math.max(
doc.body?.scrollHeight || 0,
doc.documentElement?.scrollHeight || 0,
320,
);
const max = Math.floor(window.innerHeight * 0.82) - 64;
iframe.style.height = `${Math.min(height + 4, max)}px`;
} catch (e) {
console.warn(LOG, "iframe resize", e);
}
}
function createModal(options) {
ensureStylesheet();
const opts = options || {};
const zIndex = Number(opts.zIndex) > 0 ? Number(opts.zIndex) : 5000;
const light = opts.theme === "light";
const panelTheme = light ? "light" : "dark";
const modal = document.createElement("div");
modal.id = "kioskCalibrationModal";
modal.className = "kiosk-calibration-backdrop";
modal.setAttribute("aria-hidden", "true");
modal.style.zIndex = String(zIndex);
modal.innerHTML = `
<div class="kiosk-calibration-panel kiosk-calibration-panel--${panelTheme}" role="dialog" aria-modal="true" aria-labelledby="kioskCalibrationTitle">
<div class="kiosk-calibration-head">
<h2 class="kiosk-calibration-title" id="kioskCalibrationTitle">Калибровка весов</h2>
<button type="button" class="kiosk-calibration-close" id="kioskCalibrationClose">Закрыть</button>
</div>
<iframe
id="kioskCalibrationFrame"
class="kiosk-calibration-frame"
title="Калибровка весов"
></iframe>
</div>
`;
document.body.appendChild(modal);
const iframe = modal.querySelector("#kioskCalibrationFrame");
const closeBtn = modal.querySelector("#kioskCalibrationClose");
const panel = modal.querySelector(".kiosk-calibration-panel");
function closeModal() {
modal.classList.remove("is-open");
modal.setAttribute("aria-hidden", "true");
try {
iframe.src = "about:blank";
iframe.style.height = "";
} catch (e) {
console.warn(LOG, "iframe reset", e);
}
document.removeEventListener("keydown", onKey);
}
function onKey(e) {
if (e.key === "Escape") {
e.preventDefault();
closeModal();
}
}
function openModal() {
const menu = document.getElementById(MENU_OVERLAY_ID);
if (menu) menu.style.display = "none";
iframe.onload = function () {
window.setTimeout(function () {
resizeFrame(iframe);
}, 50);
};
iframe.src = "/calibration?embed=1";
modal.classList.add("is-open");
modal.setAttribute("aria-hidden", "false");
document.addEventListener("keydown", onKey);
}
closeBtn.addEventListener("click", closeModal);
modal.addEventListener("click", function (e) {
if (e.target === modal) closeModal();
});
if (panel) {
panel.addEventListener("click", function (e) {
e.stopPropagation();
});
}
return { modal, iframe, openModal, closeModal, resizeFrame };
}
function ensureModal(options) {
const opts = options || {};
if (!window.__wespKioskCalibrationApi) {
window.__wespKioskCalibrationApi = createModal(opts);
} else if (Number(opts.zIndex) > 0) {
window.__wespKioskCalibrationApi.modal.style.zIndex = String(opts.zIndex);
}
return window.__wespKioskCalibrationApi;
}
function openModal(options) {
const api = ensureModal(options);
const panel = api.modal.querySelector(".kiosk-calibration-panel");
if (panel && options && options.theme) {
const dark = options.theme === "dark";
panel.classList.toggle("kiosk-calibration-panel--dark", dark);
panel.classList.toggle("kiosk-calibration-panel--light", !dark);
}
api.openModal();
return api;
}
function setup(opts) {
const options = opts || {};
const linkSel = options.menuLinkSelector || "#calibrationLink";
if (typeof window.WespKioskMenu !== "undefined" && typeof window.WespKioskMenu.ensureOverlay === "function") {
window.WespKioskMenu.ensureOverlay();
}
const link = document.querySelector(linkSel);
if (!link) {
console.warn(LOG, "ссылка меню не найдена после ensureOverlay:", linkSel);
return {};
}
const api = ensureModal(options);
if (link.dataset.wespCalibrationBound === "1") {
return { openModal: api.openModal, closeModal: api.closeModal };
}
link.dataset.wespCalibrationBound = "1";
link.addEventListener(
"click",
function (e) {
if (e.ctrlKey || e.metaKey || e.shiftKey || e.button !== 0) return;
if (window.frameElement) return;
e.preventDefault();
e.stopPropagation();
api.openModal();
},
true,
);
return { openModal: api.openModal, closeModal: api.closeModal };
}
window.WespKioskCalibration = { setup, ensureModal, openModal };
})();
+147
View File
@@ -0,0 +1,147 @@
/**
* Модальное окно «Проверка БД» в стиле киоска (как привязка терминала):
* тёмная рамка, iframe с /db_check?embed=1.
*/
(function () {
const LOG = "[WespKioskDbCheck]";
const MENU_OVERLAY_ID = "wespKioskMenuOverlay";
function createModal() {
const modal = document.createElement("div");
modal.id = "kioskDbCheckModal";
modal.setAttribute("aria-hidden", "true");
modal.style.cssText =
"display:none;position:fixed;inset:0;background:rgba(0,0,0,.55);z-index:5000;" +
"align-items:center;justify-content:center;padding:12px;box-sizing:border-box;";
modal.innerHTML = `
<div class="kiosk-db-check-panel" style="
background:#1f1f1f;
color:#fff;
border-radius:14px;
border:1px solid #2f2f2f;
box-shadow:0 0 20px rgba(0,0,0,.45);
width:min(920px,100%);
max-height:min(92vh,900px);
display:flex;
flex-direction:column;
overflow:hidden;
">
<div style="
display:flex;
align-items:center;
justify-content:space-between;
gap:12px;
padding:14px 16px;
border-bottom:1px solid #2f2f2f;
flex-shrink:0;
">
<h2 style="margin:0;font-size:1.125rem;font-weight:600;line-height:1.3">Проверка баз данных</h2>
<button type="button" id="kioskDbCheckClose" style="
padding:10px 16px;
border-radius:8px;
border:1px solid #7a7a7a;
background:#3a3a3a;
color:#fff;
cursor:pointer;
font-size:15px;
flex-shrink:0;
">Закрыть</button>
</div>
<iframe
id="kioskDbCheckFrame"
title="Проверка баз данных"
style="border:0;width:100%;flex:1;min-height:min(70vh,560px);background:#f9fafb;"
></iframe>
</div>
`;
document.body.appendChild(modal);
const iframe = modal.querySelector("#kioskDbCheckFrame");
const closeBtn = modal.querySelector("#kioskDbCheckClose");
const panel = modal.querySelector(".kiosk-db-check-panel");
function closeModal() {
modal.style.display = "none";
modal.setAttribute("aria-hidden", "true");
try {
iframe.src = "about:blank";
} catch (e) {
console.warn(LOG, "iframe reset", e);
}
document.removeEventListener("keydown", onKey);
}
function onKey(e) {
if (e.key === "Escape") {
e.preventDefault();
closeModal();
}
}
function openModal() {
const menu = document.getElementById(MENU_OVERLAY_ID);
if (menu) menu.style.display = "none";
iframe.src = "/db_check?embed=1";
modal.style.display = "flex";
modal.setAttribute("aria-hidden", "false");
document.addEventListener("keydown", onKey);
}
closeBtn.addEventListener("click", closeModal);
modal.addEventListener("click", function (e) {
if (e.target === modal) closeModal();
});
if (panel) {
panel.addEventListener("click", function (e) {
e.stopPropagation();
});
}
return { modal, openModal, closeModal };
}
function setup(opts) {
const options = opts || {};
const linkSel = options.menuLinkSelector || "#dbCheckLink";
if (typeof window.WespKioskMenu !== "undefined" && typeof window.WespKioskMenu.ensureOverlay === "function") {
window.WespKioskMenu.ensureOverlay();
}
const link = document.querySelector(linkSel);
if (!link) {
console.warn(LOG, "ссылка меню не найдена после ensureOverlay:", linkSel);
return {};
}
let api;
if (!window.__wespKioskDbCheckApi) {
api = createModal();
window.__wespKioskDbCheckApi = api;
} else {
api = window.__wespKioskDbCheckApi;
}
if (link.dataset.wespDbCheckBound === "1") {
return { openModal: api.openModal, closeModal: api.closeModal };
}
link.dataset.wespDbCheckBound = "1";
link.addEventListener(
"click",
function (e) {
if (e.ctrlKey || e.metaKey || e.shiftKey || e.button !== 0) return;
if (window.frameElement) return;
e.preventDefault();
e.stopPropagation();
api.openModal();
},
true
);
return { openModal: api.openModal, closeModal: api.closeModal };
}
window.WespKioskDbCheck = { setup };
})();
+271
View File
@@ -0,0 +1,271 @@
/**
* Диалоги для страниц киоска / разгрузки / отбора (терминал). Не подключать на зоотех-оболочку — там wesp-dialog.js (WespDialog).
* API: WespKioskDialog.alert(msg, opts?) -> Promise<void>
* WespKioskDialog.confirm(msg, opts?) -> Promise<boolean>
* opts.timerOnly — закрытие только через WespKioskDialog.closeActive() (таймер); без кнопки, без Escape и клика по фону.
*/
(function (global) {
var OVERLAY_ID = "wespKioskDialogOverlay";
var STYLE_ID = "wesp-kiosk-dialog-styles";
function injectStyles() {
if (document.getElementById(STYLE_ID)) return;
var s = document.createElement("style");
s.id = STYLE_ID;
s.textContent =
"#" +
OVERLAY_ID +
"{" +
"position:fixed;inset:0;z-index:5000;" +
"display:none;align-items:center;justify-content:center;" +
"padding:16px;background:rgba(0,0,0,.55);" +
"box-sizing:border-box;" +
"}" +
"#" +
OVERLAY_ID +
'[aria-hidden="false"]{display:flex;}' +
".wesp-kiosk-dialog{" +
"width:min(420px,100%);max-height:min(80vh,520px);overflow:auto;" +
"background:#fff;color:#111827;border-radius:8px;" +
"border:1px solid #e5e7eb;box-shadow:0 8px 32px rgba(0,0,0,.18);" +
"padding:20px 22px;font-family:Inter,system-ui,sans-serif;" +
"text-align:left;" +
"}" +
".wesp-kiosk-dialog-title{" +
"margin:0 0 12px 0;font-size:1.125rem;font-weight:600;color:#111827;" +
"}" +
".wesp-kiosk-dialog-body{" +
"margin:0;font-size:0.95rem;line-height:1.5;color:#374151;white-space:pre-wrap;word-break:break-word;" +
"}" +
".wesp-kiosk-dialog-actions{" +
"display:flex;justify-content:flex-end;flex-wrap:wrap;gap:10px;margin-top:20px;" +
"}" +
".wesp-kiosk-dialog-btn{" +
"min-width:120px;padding:10px 18px;border-radius:6px;font-size:0.95rem;font-weight:500;" +
"cursor:pointer;font-family:inherit;border:1px solid #e5e7eb;background:#fff;color:#374151;" +
"transition:background .2s,border-color .2s,box-shadow .2s;" +
"}" +
".wesp-kiosk-dialog-btn:hover{filter:brightness(.97);}" +
".wesp-kiosk-dialog-btn-primary{" +
"background:#2563eb;border-color:#2563eb;color:#fff;" +
"}" +
".wesp-kiosk-dialog-btn-primary:hover{box-shadow:0 4px 12px rgba(37,99,235,.25);}" +
".wesp-kiosk-dialog-btn-danger{" +
"background:#ef4444;border-color:#ef4444;color:#fff;" +
"}" +
".wesp-kiosk-dialog--danger{border-top:4px solid #ef4444;}" +
".wesp-kiosk-dialog--success{border-top:4px solid #10b981;}" +
"html[data-theme=dark] #" +
OVERLAY_ID +
" .wesp-kiosk-dialog{" +
"background:#242424;color:#e6e6e6;border-color:#3a3a3a;" +
"}" +
"html[data-theme=dark] #" +
OVERLAY_ID +
" .wesp-kiosk-dialog-title{color:#f3f4f6;}" +
"html[data-theme=dark] #" +
OVERLAY_ID +
" .wesp-kiosk-dialog-body{color:#d1d5db;}" +
"html[data-theme=dark] #" +
OVERLAY_ID +
" .wesp-kiosk-dialog-btn{" +
"background:#2d2d2d;border-color:#3a3a3a;color:#e6e6e6;" +
"}" +
"html[data-theme=dark] #" +
OVERLAY_ID +
" .wesp-kiosk-dialog-btn-primary{background:#2563eb;border-color:#2563eb;color:#fff;}" +
"html[data-theme=dark] #" +
OVERLAY_ID +
" .wesp-kiosk-dialog-btn-danger{background:#dc2626;border-color:#dc2626;color:#fff;}" +
"";
document.head.appendChild(s);
}
function ensureDom() {
injectStyles();
var el = document.getElementById(OVERLAY_ID);
if (el) return el;
el = document.createElement("div");
el.id = OVERLAY_ID;
el.setAttribute("aria-hidden", "true");
el.innerHTML =
'<div class="wesp-kiosk-dialog" role="dialog" aria-modal="true" aria-labelledby="wespKioskDialogTitle">' +
'<h2 class="wesp-kiosk-dialog-title" id="wespKioskDialogTitle"></h2>' +
'<p class="wesp-kiosk-dialog-body" id="wespKioskDialogBody"></p>' +
'<div class="wesp-kiosk-dialog-actions" id="wespKioskDialogActions"></div>' +
"</div>";
el.addEventListener("click", function (e) {
if (e.target === el) {
backdropDismiss();
}
});
document.body.appendChild(el);
return el;
}
var pendingResolve = null;
var pendingMode = null;
/** true: alert с opts.timerOnly — пользователь не закрывает окно вручную */
var blockUserDismiss = false;
function backdropDismiss() {
if (blockUserDismiss) return;
if (pendingMode === "confirm" && pendingResolve) {
var fn = pendingResolve;
pendingResolve = null;
pendingMode = null;
hide();
fn(false);
} else if (pendingMode === "alert" && pendingResolve) {
var fn2 = pendingResolve;
pendingResolve = null;
pendingMode = null;
hide();
fn2();
}
}
function hide() {
var el = document.getElementById(OVERLAY_ID);
if (!el) return;
var dlg = el.querySelector(".wesp-kiosk-dialog");
if (dlg) dlg.removeAttribute("tabindex");
blockUserDismiss = false;
el.setAttribute("aria-hidden", "true");
document.removeEventListener("keydown", onKey);
}
/** Закрыть активный alert/confirm без клика (таймер, программное закрытие). */
function closeActive() {
if (pendingMode === "confirm" && pendingResolve) {
var fnC = pendingResolve;
pendingResolve = null;
pendingMode = null;
hide();
fnC(false);
} else if (pendingMode === "alert" && pendingResolve) {
var fnA = pendingResolve;
pendingResolve = null;
pendingMode = null;
hide();
fnA();
}
}
function onKey(e) {
if (e.key === "Escape") {
e.preventDefault();
if (blockUserDismiss) return;
backdropDismiss();
}
}
function show() {
var el = document.getElementById(OVERLAY_ID);
if (!el) return;
el.setAttribute("aria-hidden", "false");
document.addEventListener("keydown", onKey);
}
function setDialogVariant(dialogEl, variant) {
dialogEl.classList.remove("wesp-kiosk-dialog--danger", "wesp-kiosk-dialog--success");
if (variant === "danger") dialogEl.classList.add("wesp-kiosk-dialog--danger");
else if (variant === "success") dialogEl.classList.add("wesp-kiosk-dialog--success");
}
function displayDialogMessage(message, opts) {
const variant = opts && opts.variant;
const fallback = opts && opts.fallback;
if (variant === "danger" && global.WespUserMessages?.userFacingMessage) {
return global.WespUserMessages.userFacingMessage(message, fallback || "Не удалось выполнить операцию. Попробуйте ещё раз.");
}
return message == null ? "" : String(message);
}
function alert(message, opts) {
opts = opts || {};
return new Promise(function (resolve) {
var overlay = ensureDom();
var dialog = overlay.querySelector(".wesp-kiosk-dialog");
var titleEl = document.getElementById("wespKioskDialogTitle");
var bodyEl = document.getElementById("wespKioskDialogBody");
var actions = document.getElementById("wespKioskDialogActions");
titleEl.textContent = opts.title != null ? String(opts.title) : "Сообщение";
bodyEl.textContent = displayDialogMessage(message, opts);
setDialogVariant(dialog, opts.variant || "info");
actions.innerHTML = "";
blockUserDismiss = !!opts.timerOnly;
pendingMode = "alert";
pendingResolve = resolve;
if (!opts.timerOnly) {
var ok = document.createElement("button");
ok.type = "button";
ok.className = "wesp-kiosk-dialog-btn wesp-kiosk-dialog-btn-primary";
ok.textContent = opts.okText || "Понятно";
ok.addEventListener("click", function () {
pendingResolve = null;
pendingMode = null;
hide();
resolve();
});
actions.appendChild(ok);
show();
ok.focus();
} else {
dialog.setAttribute("tabindex", "-1");
show();
dialog.focus();
}
});
}
function confirm(message, opts) {
opts = opts || {};
return new Promise(function (resolve) {
blockUserDismiss = false;
var overlay = ensureDom();
var dialog = overlay.querySelector(".wesp-kiosk-dialog");
var titleEl = document.getElementById("wespKioskDialogTitle");
var bodyEl = document.getElementById("wespKioskDialogBody");
var actions = document.getElementById("wespKioskDialogActions");
titleEl.textContent = opts.title != null ? String(opts.title) : "Подтверждение";
bodyEl.textContent = message == null ? "" : String(message);
setDialogVariant(dialog, opts.variant || "info");
actions.innerHTML = "";
var cancel = document.createElement("button");
cancel.type = "button";
cancel.className = "wesp-kiosk-dialog-btn";
cancel.textContent = opts.cancelText || "Отмена";
var ok = document.createElement("button");
ok.type = "button";
ok.className =
"wesp-kiosk-dialog-btn " +
(opts.danger ? "wesp-kiosk-dialog-btn-danger" : "wesp-kiosk-dialog-btn-primary");
ok.textContent = opts.okText || "Да";
cancel.addEventListener("click", function () {
pendingResolve = null;
pendingMode = null;
hide();
resolve(false);
});
ok.addEventListener("click", function () {
pendingResolve = null;
pendingMode = null;
hide();
resolve(true);
});
actions.appendChild(cancel);
actions.appendChild(ok);
pendingMode = "confirm";
pendingResolve = resolve;
show();
ok.focus();
});
}
global.WespKioskDialog = {
alert: alert,
confirm: confirm,
closeActive: closeActive,
};
})(typeof window !== "undefined" ? window : globalThis);
+169
View File
@@ -0,0 +1,169 @@
(function () {
const STYLE_ID = "wespKioskMenuStyles";
const OVERLAY_ID = "wespKioskMenuOverlay";
function ensureStyles() {
if (document.getElementById(STYLE_ID)) return;
const style = document.createElement("style");
style.id = STYLE_ID;
style.textContent = `
#${OVERLAY_ID} {
display: none;
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.62);
z-index: 4300;
align-items: center;
justify-content: center;
padding: 16px;
}
#${OVERLAY_ID} .wesp-kiosk-menu-panel {
width: min(420px, 96vw);
background: #1f1f1f;
border: 1px solid #2f2f2f;
border-radius: 14px;
box-shadow: 0 0 24px rgba(0, 0, 0, 0.45);
color: #fff;
padding: 16px;
}
#${OVERLAY_ID} .wesp-kiosk-menu-logo-wrap {
text-align: center;
margin-bottom: 12px;
}
#${OVERLAY_ID} .wesp-kiosk-menu-logo {
height: 32px;
width: auto;
}
#${OVERLAY_ID} .wesp-kiosk-menu-links {
display: flex;
flex-direction: column;
gap: 8px;
}
#${OVERLAY_ID} .wesp-kiosk-menu-link {
display: block;
text-decoration: none;
color: #fff;
background: #2b2b2b;
border: 1px solid #3a3a3a;
border-radius: 10px;
padding: 12px 14px;
font-size: 15px;
}
#${OVERLAY_ID} .wesp-kiosk-menu-link:hover {
background: #1b66a8;
border-color: #4ea8ff;
}
#${OVERLAY_ID} .wesp-kiosk-menu-close {
margin-top: 10px;
width: 100%;
border: 1px solid #7a7a7a;
border-radius: 10px;
background: #3a3a3a;
color: #fff;
padding: 10px 12px;
font-size: 15px;
cursor: pointer;
}
#${OVERLAY_ID} .wesp-kiosk-menu-close:hover {
background: #505050;
}
.wesp-kiosk-menu-button {
position: fixed !important;
top: 16px !important;
right: 16px !important;
z-index: 4250 !important;
width: 56px !important;
height: 56px !important;
border-radius: 50% !important;
border: 1px solid #1f6fd6 !important;
background: #1f6fd6 !important;
color: #fff !important;
display: inline-flex !important;
align-items: center !important;
justify-content: center !important;
padding: 0 !important;
cursor: pointer !important;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.35) !important;
font-size: 30px !important;
line-height: 1 !important;
}
.wesp-kiosk-menu-button:hover {
background: #1859a8 !important;
transform: scale(1.04);
}
.wesp-kiosk-menu-button img {
display: none !important;
}
@media (max-width: 768px) {
.wesp-kiosk-menu-button {
top: 12px !important;
right: 12px !important;
width: 50px !important;
height: 50px !important;
font-size: 28px !important;
}
}
`;
document.head.appendChild(style);
}
function ensureOverlay() {
ensureStyles();
let overlay = document.getElementById(OVERLAY_ID);
if (overlay) return overlay;
overlay = document.createElement("div");
overlay.id = OVERLAY_ID;
overlay.innerHTML = `
<div class="wesp-kiosk-menu-panel" role="dialog" aria-modal="true" aria-label="Меню киоска">
<div class="wesp-kiosk-menu-logo-wrap">
<img src="/static/logo2.png" alt="WESP" class="wesp-kiosk-menu-logo">
</div>
<div class="wesp-kiosk-menu-links">
<a class="wesp-kiosk-menu-link" href="/scales">Главная</a>
<a class="wesp-kiosk-menu-link" href="#" id="calibrationLink">Калибровка</a>
<a class="wesp-kiosk-menu-link" href="#" id="dbCheckLink">Проверка БД</a>
<a class="wesp-kiosk-menu-link" href="#" id="pairTerminalLink">Привязать терминал</a>
</div>
<button type="button" class="wesp-kiosk-menu-close" id="wespKioskMenuClose">Закрыть</button>
</div>
`;
document.body.appendChild(overlay);
const panel = overlay.querySelector(".wesp-kiosk-menu-panel");
const closeBtn = overlay.querySelector("#wespKioskMenuClose");
const close = () => {
overlay.style.display = "none";
};
closeBtn.addEventListener("click", close);
overlay.addEventListener("click", (e) => {
if (e.target === overlay) close();
});
panel.addEventListener("click", (e) => e.stopPropagation());
return overlay;
}
function setup(opts) {
const options = opts || {};
const selector =
options.menuButtonSelector || "#kioskMenuButton, .kiosk-menu-btn, .nav-button";
const menuButton = document.querySelector(selector);
if (!menuButton) return;
ensureStyles();
const overlay = ensureOverlay();
if (menuButton.dataset.wespMenuBound === "1") return;
menuButton.classList.add("wesp-kiosk-menu-button");
menuButton.setAttribute("title", "Меню");
menuButton.setAttribute("aria-label", "Открыть меню");
menuButton.textContent = "☰";
menuButton.dataset.wespMenuBound = "1";
menuButton.removeAttribute("onclick");
menuButton.addEventListener("click", function (e) {
e.preventDefault();
overlay.style.display = "flex";
});
}
window.WespKioskMenu = { setup, ensureOverlay };
})();
+285
View File
@@ -0,0 +1,285 @@
(function () {
const LOG = "[WespKioskPairing]";
const API = {
status: "/api/kiosk/status",
accessLink: "/api/kiosk/access-link",
setupAvailable: "/api/kiosk/setup-available",
};
const LOCALHOST_HOSTS = new Set(["localhost", "127.0.0.1", "::1"]);
function isLocalhostContext() {
return LOCALHOST_HOSTS.has((window.location.hostname || "").toLowerCase());
}
function ensureModalStyles() {
const criticalId = "wespKioskPairCriticalCss";
if (!document.getElementById(criticalId)) {
const style = document.createElement("style");
style.id = criticalId;
style.textContent =
"#kioskPairModal{display:none;position:fixed;inset:0;z-index:5000;padding:8px;background:rgba(0,0,0,.55);overflow:hidden}" +
"#kioskPairModal.kiosk-pair-open{display:flex;align-items:stretch;justify-content:center;height:100vh;height:100dvh}" +
"body.kiosk-pair-modal-open{overflow:hidden!important}" +
"#kioskPairModal .kiosk-pair-panel{display:flex;flex-direction:column;width:min(480px,100%);height:100%;max-height:100%;min-height:0;overflow:hidden;box-sizing:border-box}" +
"#kioskPairModal .kiosk-pair-body{flex:1 1 auto;min-height:0;overflow-y:auto;-webkit-overflow-scrolling:touch}" +
"#kioskPairModal .kiosk-pair-actions{flex:0 0 auto;display:flex;flex-wrap:wrap;gap:6px}";
document.head.appendChild(style);
}
if (document.getElementById("wespKioskModalsCss")) return;
if (window.WespKioskTheme && typeof window.WespKioskTheme.ensureModalStyles === "function") {
window.WespKioskTheme.ensureModalStyles();
return;
}
const link = document.createElement("link");
link.id = "wespKioskModalsCss";
link.rel = "stylesheet";
link.href = "/static/css/kiosk-modals.css";
document.head.appendChild(link);
}
function createModal() {
ensureModalStyles();
const modal = document.createElement("div");
modal.id = "kioskPairModal";
modal.innerHTML = `
<div class="kiosk-pair-panel" role="dialog" aria-modal="true" aria-labelledby="kioskPairTitle">
<div class="kiosk-pair-header">
<h3 id="kioskPairTitle">Привязать терминал</h3>
<p class="kiosk-pair-subtitle">Start URL для Fully Kiosk Browser</p>
</div>
<div class="kiosk-pair-body">
<div class="kiosk-pair-steps">
1) Скопируйте ссылку или отсканируйте QR.<br/>
2) Fully Kiosk → Settings → Start URL → вставьте ссылку → Save.<br/>
3) Перезапустите Fully Kiosk.
</div>
<div id="kioskPairLoading" class="kiosk-pair-loading">
<span class="kiosk-pair-spinner"></span>
<span>Загрузка ссылки...</span>
</div>
<div class="kiosk-pair-qr-wrap">
<img id="kioskPairQr" class="kiosk-pair-qr" alt="QR Start URL" />
</div>
<p id="kioskPairUrl" class="kiosk-pair-url"></p>
<p id="kioskPairStatus" class="kiosk-pair-status"></p>
</div>
<div class="kiosk-pair-actions">
<button id="kioskPairCopy" type="button" class="kiosk-pair-btn kiosk-pair-btn--primary">Скопировать ссылку</button>
<button id="kioskPairRefresh" type="button" class="kiosk-pair-btn kiosk-pair-btn--secondary">Обновить ссылку</button>
<button id="kioskPairClose" type="button" class="kiosk-pair-btn kiosk-pair-btn--ghost">Закрыть</button>
</div>
</div>
`;
document.body.appendChild(modal);
return modal;
}
function createBlockOverlay() {
const el = document.createElement("div");
el.id = "kioskBlockedOverlay";
el.style.cssText =
"display:none;position:fixed;inset:0;background:rgba(10,10,10,.78);z-index:4500;color:#fff;align-items:center;justify-content:center;text-align:center;padding:20px;";
el.innerHTML = `
<div style="max-width:520px">
<h2 style="margin:0 0 10px 0">Терминал не привязан</h2>
<p style="margin:0;line-height:1.4">Обратитесь к администратору для настройки терминала.</p>
</div>
`;
document.body.appendChild(el);
return el;
}
async function fetchJson(url, options) {
const response = await fetch(url, options);
const data = await response.json().catch(() => ({}));
if (!response.ok) {
console.warn(LOG, url, response.status, data.message || data);
throw new Error(data.message || `HTTP ${response.status}`);
}
return data;
}
function setup(opts) {
const options = opts || {};
const modal = createModal();
const block = createBlockOverlay();
const qrImg = modal.querySelector("#kioskPairQr");
const qrWrap = modal.querySelector(".kiosk-pair-qr-wrap");
const loadingEl = modal.querySelector("#kioskPairLoading");
const urlEl = modal.querySelector("#kioskPairUrl");
const statusEl = modal.querySelector("#kioskPairStatus");
const copyBtn = modal.querySelector("#kioskPairCopy");
const refreshBtn = modal.querySelector("#kioskPairRefresh");
const closeBtn = modal.querySelector("#kioskPairClose");
let currentStartUrl = "";
let canIssueAccessLink = isLocalhostContext();
function isEnforcePairedOnly(data) {
if (!data || typeof data !== "object") return true;
return data.enforce_paired_only !== false;
}
async function refreshSetupAvailability() {
if (isLocalhostContext()) {
canIssueAccessLink = true;
return true;
}
try {
const data = await fetchJson(API.setupAvailable);
canIssueAccessLink = !!data.can_issue_access_link;
} catch (e) {
console.warn(LOG, "setup-available error", e);
canIssueAccessLink = false;
}
if (options.menuLinkSelector) {
const menuLink = document.querySelector(options.menuLinkSelector);
if (menuLink) {
menuLink.style.display = canIssueAccessLink ? "" : "none";
}
}
return canIssueAccessLink;
}
async function checkStatus() {
if (isLocalhostContext()) {
block.style.display = "none";
if (typeof options.onPaired === "function") options.onPaired({ paired: true, localhost: true });
return true;
}
try {
const data = await fetchJson(API.status);
const enforce = isEnforcePairedOnly(data);
const paired = !!data.paired || !enforce;
console.debug(LOG, "/api/kiosk/status", {
paired: !!data.paired,
enforce_paired_only: enforce,
device_id: data.device_id,
status: data.status,
});
block.style.display = paired ? "none" : "flex";
if (paired && typeof options.onPaired === "function") options.onPaired(data);
if (!paired && typeof options.onUnpaired === "function") options.onUnpaired(data);
return paired;
} catch (e) {
console.warn(LOG, "checkStatus error", e);
block.style.display = "flex";
if (typeof options.onUnpaired === "function") options.onUnpaired({});
return false;
}
}
async function loadAccessLink(refresh) {
if (!canIssueAccessLink) {
statusEl.textContent = "Выдача Start URL доступна только администратору или с localhost.";
statusEl.style.color = "#ff9f9f";
return;
}
loadingEl.style.display = "flex";
qrWrap.classList.remove("kiosk-pair-qr-visible");
qrImg.removeAttribute("src");
statusEl.textContent = refresh ? "Обновление ссылки..." : "Загрузка ссылки...";
try {
const data = await fetchJson(API.accessLink, {
method: refresh ? "POST" : "GET",
headers: refresh ? { "Content-Type": "application/json" } : undefined,
body: refresh ? JSON.stringify({ refresh: true }) : undefined,
});
currentStartUrl = data.start_url || data.pair_url || "";
console.info(LOG, "access-link OK", { start_url: currentStartUrl.slice(0, 80) });
const qrSrc = data.qr_image_url || "";
await new Promise((resolve) => {
const done = () => {
qrImg.onload = null;
qrImg.onerror = null;
resolve();
};
qrImg.onload = done;
qrImg.onerror = done;
qrImg.src = qrSrc;
});
loadingEl.style.display = "none";
qrWrap.classList.add("kiosk-pair-qr-visible");
qrImg.style.display = "block";
urlEl.textContent = currentStartUrl;
statusEl.textContent = "Постоянная ссылка. Вставьте её в Fully Kiosk → Start URL.";
statusEl.style.color = "";
} catch (e) {
loadingEl.style.display = "none";
statusEl.textContent = globalThis.WespUserMessages?.userFacingMessage?.(e.message, "Не удалось получить ссылку") || "Не удалось получить ссылку";
statusEl.style.color = "#ff9f9f";
}
}
async function openModal() {
if (!canIssueAccessLink) {
window.alert("Выдача Start URL доступна только администратору или при открытии с localhost.");
return;
}
modal.classList.add("kiosk-pair-open");
document.body.classList.add("kiosk-pair-modal-open");
loadAccessLink(false);
}
function closeModal() {
modal.classList.remove("kiosk-pair-open");
document.body.classList.remove("kiosk-pair-modal-open");
}
async function copyStartUrl() {
if (!currentStartUrl) {
statusEl.textContent = "Сначала дождитесь загрузки ссылки";
return;
}
try {
if (navigator.clipboard && navigator.clipboard.writeText) {
await navigator.clipboard.writeText(currentStartUrl);
} else {
const ta = document.createElement("textarea");
ta.value = currentStartUrl;
ta.style.position = "fixed";
ta.style.left = "-9999px";
document.body.appendChild(ta);
ta.select();
document.execCommand("copy");
document.body.removeChild(ta);
}
statusEl.textContent = "Ссылка скопирована";
statusEl.style.color = "#9fd89f";
} catch (e) {
statusEl.textContent = "Не удалось скопировать — выделите ссылку вручную";
statusEl.style.color = "#ff9f9f";
}
}
async function refreshLink() {
if (!window.confirm("Старая ссылка перестанет работать. Обновить Start URL?")) {
return;
}
await loadAccessLink(true);
}
copyBtn.addEventListener("click", copyStartUrl);
refreshBtn.addEventListener("click", refreshLink);
closeBtn.addEventListener("click", closeModal);
modal.addEventListener("click", (e) => {
if (e.target === modal) closeModal();
});
if (options.menuLinkSelector) {
const menuLink = document.querySelector(options.menuLinkSelector);
if (menuLink) {
menuLink.addEventListener("click", (e) => {
e.preventDefault();
openModal();
});
}
}
refreshSetupAvailability().then(function () {
checkStatus();
});
return { checkStatus, openModal };
}
window.WespKioskPairing = { setup };
})();
+153
View File
@@ -0,0 +1,153 @@
(function () {
const KEY = "theme";
const STYLE_ID = "wespKioskThemeStyles";
const TOGGLE_ID = "wespThemeToggleWrap";
function savedTheme() {
const t = (localStorage.getItem(KEY) || "").trim().toLowerCase();
return t === "light" ? "light" : "dark";
}
function applyTheme(theme) {
const next = theme === "dark" ? "dark" : "light";
document.documentElement.setAttribute("data-theme", next);
localStorage.setItem(KEY, next);
}
function injectModalChromeStyles() {
const id = "wespKioskModalsCss";
if (document.getElementById(id)) return;
const link = document.createElement("link");
link.id = id;
link.rel = "stylesheet";
link.href = "/static/css/kiosk-modals.css";
document.head.appendChild(link);
}
function ensureModalStyles() {
injectModalChromeStyles();
}
function injectStyles() {
if (document.getElementById(STYLE_ID)) return;
const style = document.createElement("style");
style.id = STYLE_ID;
style.textContent = `
[data-theme="dark"] body {
background: #1a1a1a !important;
color: #e6e6e6 !important;
}
[data-theme="dark"] .card,
[data-theme="dark"] .data-container,
[data-theme="dark"] .admin-panel,
[data-theme="dark"] .modal-content,
[data-theme="dark"] .modal,
[data-theme="dark"] .stat-card,
[data-theme="dark"] .table-section,
[data-theme="dark"] .loading-content,
[data-theme="dark"] .error-modal {
background: #242424 !important;
color: #f0f0f0 !important;
border-color: #3a3a3a !important;
}
[data-theme="dark"] .subtitle,
[data-theme="dark"] .meta,
[data-theme="dark"] .stat-label,
[data-theme="dark"] .text-muted {
color: #bdbdbd !important;
}
[data-theme="dark"] .btn,
[data-theme="dark"] .control-button,
[data-theme="dark"] .numpad-btn {
color: #fff !important;
}
[data-theme="dark"] pre {
background: #181818 !important;
color: #dcdcdc !important;
border: 1px solid #353535 !important;
}
#${TOGGLE_ID} {
position: fixed;
bottom: 12px;
right: 16px;
z-index: 4249;
display: inline-flex;
align-items: center;
user-select: none;
background: transparent;
border: none;
padding: 0;
}
#${TOGGLE_ID} input {
--s: 40px;
height: var(--s);
aspect-ratio: 2.5;
width: auto;
border-radius: var(--s);
padding: calc(var(--s) / 10);
margin: 0;
cursor: pointer;
background:
radial-gradient(farthest-side, #15202a 96%, #0000)
var(--_p, 0%) / var(--s) content-box no-repeat,
var(--_c, #ff7a7a);
box-sizing: content-box;
transform-origin: calc(3 * var(--s) / 5) 50%;
transition:
transform cubic-bezier(0, 300, 1, 300) .5s,
background .3s .1s ease-in;
-webkit-appearance: none;
-moz-appearance: none;
appearance: none;
border: 1px solid rgba(255, 255, 255, 0.18);
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.35);
}
#${TOGGLE_ID} input:checked {
--_c: #85ff7a;
--_p: 100%;
transform-origin: calc(100% - 3 * var(--s) / 5) 50%;
transform: rotate(0.1deg);
}
@media (max-width: 768px) {
#${TOGGLE_ID} {
bottom: 12px;
right: 12px;
transform: scale(0.9);
transform-origin: bottom right;
}
}
`;
document.head.appendChild(style);
}
function ensureToggle() {
let wrap = document.getElementById(TOGGLE_ID);
if (wrap) return wrap;
wrap = document.createElement("button");
wrap.id = TOGGLE_ID;
wrap.type = "button";
wrap.innerHTML = `<input id="wespThemeSwitch" type="checkbox" aria-label="Тёмная тема" title="Выключено — светлая тема. Включено — тёмная.">`;
const input = wrap.querySelector("#wespThemeSwitch");
input.addEventListener("change", function () {
const next = input.checked ? "dark" : "light";
applyTheme(next);
});
document.body.appendChild(wrap);
return wrap;
}
function setup(opts) {
const options = opts || {};
const theme = savedTheme();
injectModalChromeStyles();
injectStyles();
applyTheme(theme);
if (options.withToggle !== false) {
const t = ensureToggle();
const input = t.querySelector("#wespThemeSwitch");
if (input) input.checked = theme === "dark";
}
}
window.WespKioskTheme = { setup, applyTheme, ensureModalStyles };
})();
@@ -72,11 +72,36 @@
function load(root, enterpriseId) {
root.innerHTML = '<p class="text-muted small">Загрузка конфликтов…</p>';
const orch = global.__WESP_ORCH__ || {};
if (orch.configured === false) {
root.innerHTML =
'<p class="text-muted small">Orchestrator не настроен. Укажите upstream, hub_site_id и api_key в админке.</p>';
return;
}
if (orch.reachable === false) {
const host = orch.upstream ? ` (${orch.upstream})` : "";
root.innerHTML =
`<p class="text-warning small">Orchestrator недоступен${escapeHtml(host)}. Проверьте upstream URL и что сервис запущен.</p>`;
return;
}
fetch(`${API}?enterprise_id=${encodeURIComponent(enterpriseId)}`)
.then((r) => r.json())
.then((items) => renderList(root, items || [], enterpriseId))
.catch(() => {
root.innerHTML = '<p class="text-danger small">Не удалось загрузить конфликты.</p>';
.then((r) => {
if (!r.ok) {
return r.json().catch(() => ({})).then((body) => {
throw new Error(body.message || `HTTP ${r.status}`);
});
}
return r.json();
})
.then((items) => {
if (items && items.error) {
throw new Error(items.message || "Orchestrator error");
}
renderList(root, Array.isArray(items) ? items : [], enterpriseId);
})
.catch((err) => {
const msg = err && err.message ? err.message : "Не удалось загрузить конфликты.";
root.innerHTML = `<p class="text-danger small">${escapeHtml(msg)}</p>`;
});
}
@@ -151,44 +151,11 @@ export function createRecipesAuthSettingsController({ notyf }) {
}
function setupMobileMenu() {
const hamburgerMenu = document.getElementById("hamburgerMenu");
const mobileMenuOverlay = document.getElementById("mobileMenuOverlay");
const closeMenu = document.getElementById("closeMenu");
const mobileUserInfo = document.getElementById("mobileUserInfo");
if (!hamburgerMenu || !mobileMenuOverlay || !closeMenu) return;
hamburgerMenu.addEventListener("click", () => {
mobileMenuOverlay.classList.add("active");
hamburgerMenu.classList.add("active");
document.body.style.overflow = "hidden";
const userInfo = document.getElementById("userInfo");
if (userInfo && mobileUserInfo) {
mobileUserInfo.textContent = userInfo.textContent || "";
}
});
const closeMobileMenu = () => {
mobileMenuOverlay.classList.remove("active");
hamburgerMenu.classList.remove("active");
document.body.style.overflow = "";
};
closeMenu.addEventListener("click", closeMobileMenu);
mobileMenuOverlay.addEventListener("click", (e) => {
if (e.target === mobileMenuOverlay) closeMobileMenu();
});
document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && mobileMenuOverlay.classList.contains("active")) {
closeMobileMenu();
}
});
const mobileMenuItems = document.querySelectorAll(".mobile-menu-item");
mobileMenuItems.forEach((item) => {
item.addEventListener("click", () => {
setTimeout(closeMobileMenu, 300);
});
});
const userInfo = document.getElementById("userInfo");
if (userInfo && mobileUserInfo) {
mobileUserInfo.textContent = userInfo.textContent?.trim() || "";
}
}
async function loadSyncClientsSettings() {
@@ -24,6 +24,8 @@
let activePreset = "day";
let panelEl = null;
let triggerEl = null;
let isOpen = false;
let suppressOutsideCloseUntil = 0;
function pad(n) {
return String(n).padStart(2, "0");
@@ -90,7 +92,7 @@
function setPresetActive(preset) {
activePreset = preset || "";
document.querySelectorAll("[data-drp-preset]").forEach((btn) => {
panelEl?.querySelectorAll("[data-drp-preset]").forEach((btn) => {
btn.classList.toggle("active", btn.getAttribute("data-drp-preset") === activePreset);
});
}
@@ -131,7 +133,7 @@
}
function positionPanel() {
if (!triggerEl || !panelEl || panelEl.hidden) return;
if (!triggerEl || !panelEl || !isOpen) return;
const rect = triggerEl.getBoundingClientRect();
const width = Math.max(rect.width, 304);
let left = rect.left;
@@ -149,28 +151,51 @@
}
function closePanel() {
if (panelEl) panelEl.hidden = true;
isOpen = false;
suppressOutsideCloseUntil = 0;
if (panelEl) {
panelEl.hidden = true;
panelEl.setAttribute("hidden", "");
panelEl.classList.remove("reports-drp__panel--open");
panelEl.style.left = "";
panelEl.style.top = "";
panelEl.style.width = "";
}
if (triggerEl) triggerEl.setAttribute("aria-expanded", "false");
window.removeEventListener("resize", positionPanel);
window.removeEventListener("scroll", positionPanel, true);
}
function isPanelTarget(target) {
if (!target || !panelEl || !triggerEl) return false;
return triggerEl.contains(target) || panelEl.contains(target);
}
function openPanel() {
const committed = readCommittedRange();
draftFrom = committed.from || today();
draftTo = committed.to || draftFrom;
viewMonth = new Date(draftFrom.getFullYear(), draftFrom.getMonth(), 1);
renderCalendar();
if (panelEl) panelEl.hidden = false;
global.WespReportsFilterDropdown?.closeAll?.();
global.WespReportsExport?.close?.();
isOpen = true;
suppressOutsideCloseUntil = Date.now() + 180;
if (panelEl) {
panelEl.removeAttribute("hidden");
panelEl.hidden = false;
panelEl.classList.add("reports-drp__panel--open");
}
if (triggerEl) triggerEl.setAttribute("aria-expanded", "true");
positionPanel();
requestAnimationFrame(() => positionPanel());
window.addEventListener("resize", positionPanel);
window.addEventListener("scroll", positionPanel, true);
}
function togglePanel() {
if (panelEl?.hidden) openPanel();
else closePanel();
if (isOpen) closePanel();
else openPanel();
}
function inRange(d, a, b) {
@@ -246,6 +271,8 @@
triggerEl = document.getElementById("reportsDateRangeTrigger");
panelEl = document.getElementById("reportsDateRangePanel");
if (!triggerEl || !panelEl) return;
if (triggerEl.dataset.drpWired === "1") return;
triggerEl.dataset.drpWired = "1";
if (panelEl.parentElement !== document.body) {
document.body.appendChild(panelEl);
@@ -254,7 +281,7 @@
panelEl.addEventListener("mousedown", (e) => e.stopPropagation());
panelEl.addEventListener("click", (e) => e.stopPropagation());
document.querySelectorAll("[data-drp-preset]").forEach((btn) => {
panelEl.querySelectorAll("[data-drp-preset]").forEach((btn) => {
btn.addEventListener("click", (e) => {
e.stopPropagation();
setPeriod(btn.getAttribute("data-drp-preset") || "day");
@@ -276,22 +303,36 @@
positionPanel();
});
triggerEl.addEventListener("click", (e) => {
e.stopPropagation();
togglePanel();
});
triggerEl.addEventListener(
"pointerdown",
(e) => {
if (e.button !== 0) return;
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
togglePanel();
},
true
);
document.addEventListener("click", () => {
if (!panelEl.hidden) closePanel();
});
document.addEventListener(
"pointerdown",
(e) => {
if (Date.now() < suppressOutsideCloseUntil) return;
if (!isOpen) return;
if (isPanelTarget(e.target)) return;
closePanel();
},
true
);
document.addEventListener("keydown", (e) => {
if (e.key === "Escape") closePanel();
if (e.key === "Escape" && isOpen) closePanel();
});
const urlParams = new URLSearchParams(window.location.search);
const highlightReportId = urlParams.get("report");
if highlightReportId) {
if (highlightReportId) {
global.__wespHighlightReportId = highlightReportId;
setPeriod("month");
} else {
@@ -305,6 +346,7 @@
init,
setPeriod,
commit: commitDraft,
close: closePanel,
updateTriggerLabel,
formatLabel,
parseIso,
+100 -24
View File
@@ -2,6 +2,11 @@
* Экспорт /reports — выбор раздела и формата (Excel / PDF).
*/
(function (global) {
let btnEl = null;
let menuEl = null;
let isOpen = false;
let suppressOutsideCloseUntil = 0;
function appendFilterContext(params) {
const farms = global.WespReportsFilters?.getSelectedFarmNames?.() || [];
const dispensers = global.WespReportsFilters?.getSelectedDispenserNames?.() || [];
@@ -34,34 +39,89 @@
window.location.href = buildExportUrl(format, section);
}
function isMenuTarget(target) {
if (!target || !btnEl || !menuEl) return false;
return btnEl.contains(target) || menuEl.contains(target);
}
function positionMenu() {
if (!btnEl || !menuEl || !isOpen) return;
const rect = btnEl.getBoundingClientRect();
let top = rect.bottom + 6;
const menuHeight = menuEl.offsetHeight || 320;
if (top + menuHeight > window.innerHeight - 12) {
top = Math.max(12, rect.top - menuHeight - 6);
}
menuEl.style.top = `${top}px`;
menuEl.style.right = `${Math.max(12, window.innerWidth - rect.right)}px`;
menuEl.style.left = "auto";
menuEl.style.width = "";
menuEl.style.transform = "";
}
function closeMenu() {
const menu = document.getElementById("reportsExportMenu");
const btn = document.getElementById("reportsExportBtn");
if (menu) menu.hidden = true;
if (btn) btn.setAttribute("aria-expanded", "false");
isOpen = false;
suppressOutsideCloseUntil = 0;
if (menuEl) {
menuEl.hidden = true;
menuEl.setAttribute("hidden", "");
menuEl.classList.remove("reports-export-dropdown__menu--open");
menuEl.style.top = "";
menuEl.style.right = "";
menuEl.style.left = "";
menuEl.style.width = "";
menuEl.style.transform = "";
}
if (btnEl) btnEl.setAttribute("aria-expanded", "false");
window.removeEventListener("resize", positionMenu);
window.removeEventListener("scroll", positionMenu, true);
}
function openMenu() {
global.WespReportsDateRange?.close?.();
global.WespReportsFilterDropdown?.closeAll?.();
isOpen = true;
suppressOutsideCloseUntil = Date.now() + 180;
if (menuEl) {
menuEl.removeAttribute("hidden");
menuEl.hidden = false;
menuEl.classList.add("reports-export-dropdown__menu--open");
}
if (btnEl) btnEl.setAttribute("aria-expanded", "true");
positionMenu();
requestAnimationFrame(() => positionMenu());
window.addEventListener("resize", positionMenu);
window.addEventListener("scroll", positionMenu, true);
}
function toggleMenu() {
const menu = document.getElementById("reportsExportMenu");
const btn = document.getElementById("reportsExportBtn");
if (!menu || !btn) return;
const open = menu.hidden;
menu.hidden = !open;
btn.setAttribute("aria-expanded", open ? "true" : "false");
if (isOpen) closeMenu();
else openMenu();
}
function init() {
const btn = document.getElementById("reportsExportBtn");
const menu = document.getElementById("reportsExportMenu");
if (!btn || !menu || btn.dataset.bound) return;
btn.dataset.bound = "1";
btnEl = document.getElementById("reportsExportBtn");
menuEl = document.getElementById("reportsExportMenu");
if (!btnEl || !menuEl || btnEl.dataset.bound) return;
btnEl.dataset.bound = "1";
btn.addEventListener("click", (e) => {
e.stopPropagation();
toggleMenu();
});
if (menuEl.parentElement !== document.body) {
document.body.appendChild(menuEl);
}
menu.querySelectorAll("[data-reports-export-format]").forEach((item) => {
btnEl.addEventListener(
"pointerdown",
(e) => {
if (e.button !== 0) return;
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
toggleMenu();
},
true
);
menuEl.querySelectorAll("[data-reports-export-format]").forEach((item) => {
item.addEventListener("click", (e) => {
e.preventDefault();
e.stopPropagation();
@@ -73,14 +133,30 @@
});
});
menu.addEventListener("mousedown", (e) => e.stopPropagation());
menu.addEventListener("click", (e) => e.stopPropagation());
menuEl.addEventListener("mousedown", (e) => e.stopPropagation());
menuEl.addEventListener("click", (e) => e.stopPropagation());
document.addEventListener(
"pointerdown",
(e) => {
if (Date.now() < suppressOutsideCloseUntil) return;
if (!isOpen) return;
if (isMenuTarget(e.target)) return;
closeMenu();
},
true
);
document.addEventListener("click", () => closeMenu());
document.addEventListener("keydown", (e) => {
if (e.key === "Escape") closeMenu();
if (e.key === "Escape" && isOpen) closeMenu();
});
}
global.WespReportsExport = { init, buildExportUrl, download, appendFilterContext };
global.WespReportsExport = {
init,
buildExportUrl,
download,
appendFilterContext,
close: closeMenu,
};
})(typeof window !== "undefined" ? window : globalThis);
@@ -217,6 +217,7 @@
trigger.addEventListener("click", (e) => {
e.stopPropagation();
const willOpen = menu.hidden;
global.WespReportsDateRange?.close?.();
closeAll(willOpen ? menu : null);
menu.hidden = !willOpen;
trigger.setAttribute("aria-expanded", willOpen ? "true" : "false");
@@ -250,11 +251,25 @@
return api;
}
function isReportsPopoverTarget(target) {
if (!target) return false;
const roots = [
"reportsDateRangeTrigger",
"reportsDateRangePanel",
"reportsExportBtn",
"reportsExportMenu",
];
return roots.some((id) => document.getElementById(id)?.contains(target));
}
function init() {
bindSelect("farm-select");
bindSelect("dispenser-select");
bindSelect("recipe-select");
document.addEventListener("click", () => closeAll());
document.addEventListener("click", (e) => {
if (isReportsPopoverTarget(e.target)) return;
closeAll();
});
document.addEventListener("keydown", (e) => {
if (e.key === "Escape") closeAll();
});
@@ -278,5 +293,6 @@
bindSelect,
getMultiSelectValues,
setMultiSelectValues,
closeAll,
};
})(typeof window !== "undefined" ? window : globalThis);
@@ -132,6 +132,15 @@
function buildAlertsParams() {
const params = new URLSearchParams(getDateParams());
const recipeIds = getRecipeIds();
if (recipeIds.length === 1) {
params.set("recipe_id", recipeIds[0]);
return params;
}
if (recipeIds.length > 1) {
params.set("recipe_ids", recipeIds.join(","));
return params;
}
const dispenserIds = getDispenserIds();
if (dispenserIds.length === 1) {
params.set("dispenser_id", dispenserIds[0]);
@@ -0,0 +1,82 @@
/** Progress 0100 for logo loader during setup wizard. */
const STEP_WEIGHTS = {
theme: 10,
welcome: 5,
device: 12,
network: 18,
sync: 18,
users: 22,
hardware: 10,
kiosk: 10,
bootstrap: 25,
done: 8,
};
const FLOWS = {
server: ["theme", "welcome", "device", "network", "users", "done"],
client: ["theme", "welcome", "device", "sync", "hardware", "kiosk", "bootstrap", "done"],
};
export function getFlow(deviceRole) {
return FLOWS[deviceRole] || FLOWS.server;
}
export function createSetupOrchestrator() {
let deviceRole = "server";
let currentStep = "welcome";
let extraPercent = 0;
function flow() {
return getFlow(deviceRole);
}
function basePercent() {
const steps = flow();
const idx = steps.indexOf(currentStep);
if (idx < 0) return 0;
let total = 0;
for (const s of steps) total += STEP_WEIGHTS[s] || 10;
let done = 0;
for (let i = 0; i < idx; i += 1) done += STEP_WEIGHTS[steps[i]] || 10;
const curWeight = STEP_WEIGHTS[currentStep] || 10;
return Math.min(100, Math.round(((done + curWeight * 0.35) / total) * 100));
}
return {
get percent() {
return Math.min(100, Math.max(basePercent(), extraPercent));
},
get currentStep() {
return currentStep;
},
get deviceRole() {
return deviceRole;
},
setDeviceRole(role) {
deviceRole = role;
},
setStep(step) {
currentStep = step;
extraPercent = 0;
},
bumpExtra(delta) {
extraPercent = Math.min(100, extraPercent + delta);
},
setBootstrapProgress(p) {
const steps = flow();
const idx = steps.indexOf("bootstrap");
if (idx < 0) return;
let total = 0;
let before = 0;
for (let i = 0; i < steps.length; i += 1) {
const w = STEP_WEIGHTS[steps[i]] || 10;
if (i < idx) before += w;
total += w;
}
const w = STEP_WEIGHTS.bootstrap || 25;
extraPercent = Math.round(((before + w * Math.min(1, p)) / total) * 100);
},
flow,
};
}
@@ -0,0 +1,204 @@
/**
* On-screen keyboard for setup wizard on touch kiosks (no system IME).
* Uses wesp-setup CSS variables for light/dark theme.
*/
const LAYOUTS = {
hostname: [
["1", "2", "3", "4", "5", "6", "7", "8", "9", "0"],
["q", "w", "e", "r", "t", "y", "u", "i", "o", "p"],
[
"a",
"s",
"d",
"f",
"g",
"h",
"j",
"k",
"l",
{ key: "backspace", label: "⌫", span: 2 },
],
["z", "x", "c", "v", "b", "n", "m", "_", "-", "."],
],
url: [
["1", "2", "3", "4", "5", "6", "7", "8", "9", "0"],
["q", "w", "e", "r", "t", "y", "u", "i", "o", "p"],
[
"a",
"s",
"d",
"f",
"g",
"h",
"j",
"k",
"l",
{ key: "backspace", label: "⌫", span: 2 },
],
["z", "x", "c", "v", "b", "n", "m", "_", "-", "."],
[
{ key: "http://", label: "http://", span: 3 },
{ key: ":", label: ":" },
{ key: "/", label: "/" },
{ key: "backspace", label: "⌫", span: 2 },
],
],
};
const HOSTNAME_CHAR = /[a-z0-9._-]/;
const URL_CHAR = /[a-z0-9.:/_-]/;
function allowedChar(ch, layout) {
if (layout === "url") return URL_CHAR.test(ch);
return HOSTNAME_CHAR.test(ch);
}
function normalizeInsert(ch, layout) {
const lower = ch.toLowerCase();
if (ch.length === 1 && !allowedChar(lower, layout)) return "";
return lower;
}
export class SetupTouchKeyboard {
#panel;
#root = null;
#grid = null;
#activeInput = null;
#activeLayout = "hostname";
#bindings = new Map();
#docClickHandler = null;
#maxLength = 200;
constructor(panel) {
this.#panel = panel;
}
attach(input, { layout = "hostname", maxLength = 200 } = {}) {
if (!input || input.disabled) return;
this.#bindings.set(input, { layout, maxLength });
input.readOnly = true;
input.inputMode = "none";
input.autocomplete = "off";
input.autocorrect = "off";
input.spellcheck = false;
input.classList.add("wesp-setup__input--touch");
const onFocus = () => this.#showFor(input);
input.addEventListener("focus", onFocus);
input.addEventListener("click", onFocus);
}
mount() {
if (this.#root) return;
this.#root = document.createElement("div");
this.#root.className = "wesp-setup__keyboard";
this.#root.setAttribute("role", "group");
this.#root.setAttribute("aria-label", "Экранная клавиатура");
this.#grid = document.createElement("div");
this.#grid.className = "wesp-setup__keyboard-grid";
this.#root.appendChild(this.#grid);
const footer = this.#panel.querySelector(".wesp-setup__footer");
if (footer) {
this.#panel.insertBefore(this.#root, footer);
} else {
this.#panel.appendChild(this.#root);
}
this.#docClickHandler = (e) => {
if (!this.#root?.classList.contains("is-visible")) return;
const t = e.target;
if (this.#root.contains(t)) return;
if (this.#activeInput && (t === this.#activeInput || this.#activeInput.contains?.(t))) return;
for (const input of this.#bindings.keys()) {
if (t === input || input.contains?.(t)) return;
}
this.hide();
};
document.addEventListener("pointerdown", this.#docClickHandler, true);
}
hide() {
this.#root?.classList.remove("is-visible");
this.#activeInput = null;
}
destroy() {
if (this.#docClickHandler) {
document.removeEventListener("pointerdown", this.#docClickHandler, true);
this.#docClickHandler = null;
}
this.#root?.remove();
this.#root = null;
this.#grid = null;
this.#bindings.clear();
this.#activeInput = null;
}
#showFor(input) {
const binding = this.#bindings.get(input);
if (!binding) return;
this.mount();
this.#activeInput = input;
this.#activeLayout = binding.layout;
this.#maxLength = binding.maxLength;
this.#renderKeys(binding.layout);
this.#root.classList.add("is-visible");
window.requestAnimationFrame(() => {
input.scrollIntoView({ block: "nearest", behavior: "smooth" });
});
}
#renderKeys(layoutName) {
const rows = LAYOUTS[layoutName] || LAYOUTS.hostname;
this.#grid.replaceChildren();
rows.forEach((row) => {
row.forEach((item) => {
const spec = typeof item === "string" ? { key: item, label: item } : item;
const btn = document.createElement("button");
btn.type = "button";
btn.className = "wesp-setup__keyboard-key";
if (spec.key === "backspace") btn.classList.add("wesp-setup__keyboard-key--wide");
if (spec.key === "http://") btn.classList.add("wesp-setup__keyboard-key--action");
btn.textContent = spec.label || spec.key;
const span = spec.span || 1;
if (span > 1) btn.style.gridColumn = `span ${span}`;
btn.addEventListener("pointerdown", (e) => {
e.preventDefault();
this.#onKey(spec.key);
});
this.#grid.appendChild(btn);
});
});
}
#onKey(key) {
const input = this.#activeInput;
if (!input) return;
if (key === "backspace") {
input.value = input.value.slice(0, -1);
input.dispatchEvent(new Event("input", { bubbles: true }));
return;
}
let insert = "";
if (key === "http://") {
if (this.#activeLayout === "url") insert = "http://";
} else {
insert = normalizeInsert(key, this.#activeLayout);
}
if (!insert) return;
if (input.value.length >= this.#maxLength) return;
const start = input.selectionStart ?? input.value.length;
const end = input.selectionEnd ?? start;
input.value = input.value.slice(0, start) + insert + input.value.slice(end);
const pos = start + insert.length;
try {
input.setSelectionRange(pos, pos);
} catch {
/* readonly selection may fail in some browsers */
}
input.dispatchEvent(new Event("input", { bubbles: true }));
}
}
+339
View File
@@ -0,0 +1,339 @@
/**
* Диалоги alert/confirm для веб-оболочки зоотехника (не скрипты страниц киоска).
* API: WespDialog.alert(msg, opts?) -> Promise<void>
* WespDialog.confirm(msg, opts?) -> Promise<boolean>
* opts.timerOnly — закрытие только через WespDialog.closeActive() (таймер); без кнопки, без Escape и клика по фону.
*/
(function (global) {
var OVERLAY_ID = "wespShellDialogOverlay";
var STYLE_ID = "wesp-shell-dialog-styles";
function injectStyles() {
if (document.getElementById(STYLE_ID)) return;
var s = document.createElement("style");
s.id = STYLE_ID;
s.textContent =
"#" +
OVERLAY_ID +
"{" +
"position:fixed;inset:0;z-index:5000;" +
"display:none;align-items:center;justify-content:center;" +
"padding:16px;background:rgba(0,0,0,.55);" +
"box-sizing:border-box;" +
"opacity:0;transition:opacity .22s cubic-bezier(.22,1,.36,1);" +
"backdrop-filter:blur(4px);-webkit-backdrop-filter:blur(4px);" +
"}" +
"#" +
OVERLAY_ID +
'[aria-hidden="false"]{display:flex;opacity:1;}' +
"#" +
OVERLAY_ID +
".wesp-shell-dialog-overlay-closing{opacity:0;}" +
".wesp-shell-dialog{" +
"width:min(420px,100%);max-height:min(80vh,520px);overflow:auto;" +
"background:#fff;color:#111827;border-radius:8px;" +
"border:1px solid #e5e7eb;box-shadow:0 8px 32px rgba(0,0,0,.18);" +
"padding:20px 22px;font-family:var(--zootech-font,Inter,system-ui,sans-serif);" +
"-webkit-font-smoothing:antialiased;" +
"text-align:left;" +
"opacity:0;transform:scale(.97) translateY(8px);" +
"transition:opacity .22s cubic-bezier(.22,1,.36,1),transform .22s cubic-bezier(.22,1,.36,1);" +
"}" +
"#" +
OVERLAY_ID +
'[aria-hidden="false"]:not(.wesp-shell-dialog-overlay-closing) .wesp-shell-dialog{' +
"opacity:1;transform:scale(1) translateY(0);" +
"}" +
"#" +
OVERLAY_ID +
".wesp-shell-dialog-overlay-closing .wesp-shell-dialog{" +
"opacity:0;transform:scale(.97) translateY(8px);" +
"}" +
"@media (prefers-reduced-motion:reduce){" +
"#" +
OVERLAY_ID +
",.wesp-shell-dialog{transition:none!important;transform:none!important;}" +
"#" +
OVERLAY_ID +
"{backdrop-filter:none;-webkit-backdrop-filter:none;}" +
".wesp-shell-dialog{opacity:1!important;}" +
"}" +
".wesp-shell-dialog-title{" +
"margin:0 0 12px 0;font-size:1.125rem;font-weight:600;color:#111827;" +
"}" +
".wesp-shell-dialog-body{" +
"margin:0;font-size:0.95rem;line-height:1.5;color:#374151;white-space:pre-wrap;word-break:break-word;" +
"}" +
".wesp-shell-dialog-actions{" +
"display:flex;justify-content:flex-end;flex-wrap:wrap;gap:10px;margin-top:20px;" +
"}" +
".wesp-shell-dialog-btn{" +
"min-width:120px;padding:10px 18px;border-radius:6px;font-size:0.95rem;font-weight:500;" +
"cursor:pointer;font-family:inherit;border:1px solid #e5e7eb;background:#fff;color:#374151;" +
"transition:background .2s,border-color .2s,box-shadow .2s;" +
"}" +
".wesp-shell-dialog-btn:hover{filter:brightness(.97);}" +
".wesp-shell-dialog-btn-primary{" +
"background:#2563eb;border-color:#2563eb;color:#fff;" +
"}" +
".wesp-shell-dialog-btn-primary:hover{box-shadow:0 4px 12px rgba(37,99,235,.25);}" +
".wesp-shell-dialog-btn-danger{" +
"background:#ef4444;border-color:#ef4444;color:#fff;" +
"}" +
".wesp-shell-dialog--danger{border-top:4px solid #ef4444;}" +
".wesp-shell-dialog--success{border-top:4px solid #10b981;}" +
"html[data-theme=dark] #" +
OVERLAY_ID +
" .wesp-shell-dialog{" +
"background:var(--dm-surface,#2c3338);color:var(--dm-text,#f5f5f5);border-color:var(--dm-border,rgba(255,255,255,.12));" +
"}" +
"html[data-theme=dark] #" +
OVERLAY_ID +
" .wesp-shell-dialog-title{color:var(--dm-text,#f5f5f5);}" +
"html[data-theme=dark] #" +
OVERLAY_ID +
" .wesp-shell-dialog-body{color:var(--dm-text,#f5f5f5);}" +
"html[data-theme=dark] #" +
OVERLAY_ID +
" .wesp-shell-dialog-btn{" +
"background:var(--dm-field,#3e444a);border-color:var(--dm-border,rgba(255,255,255,.12));color:var(--dm-text,#f5f5f5);" +
"}" +
"html[data-theme=dark] #" +
OVERLAY_ID +
" .wesp-shell-dialog-btn-primary{background:#2563eb;border-color:#2563eb;color:#fff;}" +
"html[data-theme=dark] #" +
OVERLAY_ID +
" .wesp-shell-dialog-btn-danger{background:#dc2626;border-color:#dc2626;color:#fff;}" +
"";
document.head.appendChild(s);
}
function ensureDom() {
injectStyles();
var el = document.getElementById(OVERLAY_ID);
if (el) return el;
el = document.createElement("div");
el.id = OVERLAY_ID;
el.setAttribute("aria-hidden", "true");
el.innerHTML =
'<div class="wesp-shell-dialog" role="dialog" aria-modal="true" aria-labelledby="wespShellDialogTitle">' +
'<h2 class="wesp-shell-dialog-title" id="wespShellDialogTitle"></h2>' +
'<p class="wesp-shell-dialog-body" id="wespShellDialogBody"></p>' +
'<div class="wesp-shell-dialog-actions" id="wespShellDialogActions"></div>' +
"</div>";
el.addEventListener("click", function (e) {
if (e.target === el) {
backdropDismiss();
}
});
document.body.appendChild(el);
return el;
}
var pendingResolve = null;
var pendingMode = null;
var blockUserDismiss = false;
function backdropDismiss() {
if (blockUserDismiss) return;
if (pendingMode === "confirm" && pendingResolve) {
var fn = pendingResolve;
pendingResolve = null;
pendingMode = null;
hide();
fn(false);
} else if (pendingMode === "alert" && pendingResolve) {
var fn2 = pendingResolve;
pendingResolve = null;
pendingMode = null;
hide();
fn2();
}
}
function prefersReducedMotion() {
return (
global.matchMedia &&
global.matchMedia("(prefers-reduced-motion: reduce)").matches
);
}
function hide() {
var el = document.getElementById(OVERLAY_ID);
if (!el || el.getAttribute("aria-hidden") === "true") return;
var dlg = el.querySelector(".wesp-shell-dialog");
if (dlg) dlg.removeAttribute("tabindex");
blockUserDismiss = false;
document.removeEventListener("keydown", onKey);
if (prefersReducedMotion()) {
el.classList.remove("wesp-shell-dialog-overlay-closing");
el.setAttribute("aria-hidden", "true");
return;
}
el.classList.add("wesp-shell-dialog-overlay-closing");
var done = false;
var finish = function () {
if (done) return;
done = true;
el.classList.remove("wesp-shell-dialog-overlay-closing");
el.setAttribute("aria-hidden", "true");
};
el.addEventListener(
"transitionend",
function (e) {
if (e.target === el) finish();
},
{ once: true }
);
global.setTimeout(finish, 280);
}
function closeActive() {
if (pendingMode === "confirm" && pendingResolve) {
var fnC = pendingResolve;
pendingResolve = null;
pendingMode = null;
hide();
fnC(false);
} else if (pendingMode === "alert" && pendingResolve) {
var fnA = pendingResolve;
pendingResolve = null;
pendingMode = null;
hide();
fnA();
}
}
function onKey(e) {
if (e.key === "Escape") {
e.preventDefault();
if (blockUserDismiss) return;
backdropDismiss();
}
}
function show() {
var el = document.getElementById(OVERLAY_ID);
if (!el) return;
el.classList.remove("wesp-shell-dialog-overlay-closing");
el.setAttribute("aria-hidden", "false");
document.addEventListener("keydown", onKey);
if (!prefersReducedMotion()) {
var dlg = el.querySelector(".wesp-shell-dialog");
if (dlg) {
dlg.style.opacity = "0";
dlg.style.transform = "scale(0.97) translateY(8px)";
requestAnimationFrame(function () {
requestAnimationFrame(function () {
dlg.style.opacity = "";
dlg.style.transform = "";
});
});
}
}
}
function setDialogVariant(dialogEl, variant) {
dialogEl.classList.remove("wesp-shell-dialog--danger", "wesp-shell-dialog--success");
if (variant === "danger") dialogEl.classList.add("wesp-shell-dialog--danger");
else if (variant === "success") dialogEl.classList.add("wesp-shell-dialog--success");
}
function displayDialogMessage(message, opts) {
const variant = opts && opts.variant;
const fallback = opts && opts.fallback;
if (variant === "danger" && global.WespUserMessages?.userFacingMessage) {
return global.WespUserMessages.userFacingMessage(message, fallback || "Не удалось выполнить операцию. Попробуйте ещё раз.");
}
return message == null ? "" : String(message);
}
function alert(message, opts) {
opts = opts || {};
return new Promise(function (resolve) {
var overlay = ensureDom();
var dialog = overlay.querySelector(".wesp-shell-dialog");
var titleEl = document.getElementById("wespShellDialogTitle");
var bodyEl = document.getElementById("wespShellDialogBody");
var actions = document.getElementById("wespShellDialogActions");
titleEl.textContent = opts.title != null ? String(opts.title) : "Сообщение";
bodyEl.textContent = displayDialogMessage(message, opts);
setDialogVariant(dialog, opts.variant || "info");
actions.innerHTML = "";
blockUserDismiss = !!opts.timerOnly;
pendingMode = "alert";
pendingResolve = resolve;
if (!opts.timerOnly) {
var ok = document.createElement("button");
ok.type = "button";
ok.className = "wesp-shell-dialog-btn wesp-shell-dialog-btn-primary";
ok.textContent = opts.okText || "Понятно";
ok.addEventListener("click", function () {
pendingResolve = null;
pendingMode = null;
hide();
resolve();
});
actions.appendChild(ok);
show();
ok.focus();
} else {
dialog.setAttribute("tabindex", "-1");
show();
dialog.focus();
}
});
}
function confirm(message, opts) {
opts = opts || {};
return new Promise(function (resolve) {
blockUserDismiss = false;
var overlay = ensureDom();
var dialog = overlay.querySelector(".wesp-shell-dialog");
var titleEl = document.getElementById("wespShellDialogTitle");
var bodyEl = document.getElementById("wespShellDialogBody");
var actions = document.getElementById("wespShellDialogActions");
titleEl.textContent = opts.title != null ? String(opts.title) : "Подтверждение";
bodyEl.textContent = message == null ? "" : String(message);
setDialogVariant(dialog, opts.variant || "info");
actions.innerHTML = "";
var cancel = document.createElement("button");
cancel.type = "button";
cancel.className = "wesp-shell-dialog-btn";
cancel.textContent = opts.cancelText || "Отмена";
var ok = document.createElement("button");
ok.type = "button";
ok.className =
"wesp-shell-dialog-btn " +
(opts.danger ? "wesp-shell-dialog-btn-danger" : "wesp-shell-dialog-btn-primary");
ok.textContent = opts.okText || "Да";
cancel.addEventListener("click", function () {
pendingResolve = null;
pendingMode = null;
hide();
resolve(false);
});
ok.addEventListener("click", function () {
pendingResolve = null;
pendingMode = null;
hide();
resolve(true);
});
actions.appendChild(cancel);
actions.appendChild(ok);
pendingMode = "confirm";
pendingResolve = resolve;
show();
ok.focus();
});
}
global.WespDialog = {
alert: alert,
confirm: confirm,
closeActive: closeActive,
};
})(typeof window !== "undefined" ? window : globalThis);
@@ -0,0 +1,40 @@
/**
* Видимость Lab-элементов на zootech-страницах (components и др.).
*/
(function (global) {
const HIDDEN_CLASS = "wesp-lab-access-hidden";
function ensureStyle() {
if (document.getElementById("wespLabAccessStyle")) return;
const style = document.createElement("style");
style.id = "wespLabAccessStyle";
style.textContent = `.${HIDDEN_CLASS}{display:none !important;}`;
document.head.appendChild(style);
}
function setHidden(el, hidden) {
if (!el) return;
el.classList.toggle(HIDDEN_CLASS, hidden);
if (hidden) {
el.setAttribute("aria-hidden", "true");
} else {
el.removeAttribute("aria-hidden");
}
}
function apply(canLab) {
ensureStyle();
global.WESP_CAN_LAB = Boolean(canLab);
document.documentElement.classList.toggle("wesp-no-lab", !canLab);
document.querySelectorAll("[data-wesp-lab-gated]").forEach((el) => {
setHidden(el, !canLab);
});
if (canLab) {
global.WespLabComponentsNutrients?.init?.();
}
}
global.WespLabAccess = { apply };
})(window);
+869
View File
@@ -0,0 +1,869 @@
const DEFAULT_BLUE_SRC = "/static/logo2.png";
const LOGO_SRC_WIDTH = 2222;
const LOGO_SRC_HEIGHT = 1024;
/** ~28 строк сетки, как у старого logo2 (305px @ cell 11). */
const INITIAL_CELL = 37;
const DURATION_MS = 4750;
const WAVE2_START_MS = 0.36 * 4500;
const WAVE2_SPAN_MS = 0.34 * 6800;
const WAVE2_START = WAVE2_START_MS / DURATION_MS;
const WAVE2_SPAN = WAVE2_SPAN_MS / DURATION_MS;
const WAVE2_END = (WAVE2_START_MS + WAVE2_SPAN_MS) / DURATION_MS;
const HOLD_END = 0.94;
const PIXEL_SCALE = WAVE2_START / 0.36;
const WAVE0_START = 0.04 * PIXEL_SCALE;
const WAVE0_SPAN = 0.20 * PIXEL_SCALE;
const WAVE1_START = WAVE0_START + WAVE0_SPAN - 0.04 * PIXEL_SCALE;
const WAVE1_SPAN = 0.20 * PIXEL_SCALE;
const PIXEL_FADE = 0.012;
const PIXEL_GAP = 1;
const ASSEMBLY_FEATHER = 10;
const COLOR_CROSSFADE = 0.024;
const SHIMMER_MS = 1400;
const SHIMMER_PROGRESS_START = 88;
const BRAND_BLUE = [44, 123, 229];
const BRAND_ORGANIC = [72, 129, 109];
const BRAND_ORGANIC_HC = [47, 107, 87];
const BRAND_WHITE = [238, 240, 243];
const PIXEL_GRAY = [148, 152, 158];
const BG = [246, 246, 244];
const GRID_STEP = 10;
function shimmerDurationMs() {
return SHIMMER_MS;
}
function wave2EndMs(durationMs) {
return WAVE2_END * durationMs;
}
function animationEndMs(durationMs) {
return Math.max(durationMs, wave2EndMs(durationMs) + SHIMMER_MS + 180);
}
/** Фактическая длительность проигрывания (с учётом totalDurationMs). */
function resolvePlaybackEndMs(durationMs, totalDurationMs) {
const natural = animationEndMs(durationMs);
if (totalDurationMs == null || !Number.isFinite(totalDurationMs)) {
return natural;
}
return Math.min(natural, Math.max(400, totalDurationMs));
}
export const LOGO_LOADER_DURATION_MS = DURATION_MS;
export { animationEndMs, resolvePlaybackEndMs, drawShimmer, LOGO_SRC_WIDTH, LOGO_SRC_HEIGHT };
/** Буфер логотипа для navbar-shimmer (выравнивание слева, как object-position: left). */
export function createNavLogoShimmerBuffer(img, slotW, slotH, inkRgb = [255, 255, 255]) {
const srcW = img.naturalWidth || LOGO_SRC_WIDTH;
const srcH = img.naturalHeight || LOGO_SRC_HEIGHT;
const scale = Math.min(slotW / srcW, slotH / srcH);
const drawWidth = srcW * scale;
const drawHeight = srcH * scale;
const layout = {
offsetX: 0,
offsetY: (slotH - drawHeight) / 2,
drawWidth,
drawHeight,
};
const canvas = document.createElement("canvas");
canvas.width = slotW;
canvas.height = slotH;
const ctx = canvas.getContext("2d");
ctx.clearRect(0, 0, slotW, slotH);
ctx.drawImage(img, layout.offsetX, layout.offsetY, drawWidth, drawHeight);
knockoutBackgroundFromLogo(canvas, slotW, slotH);
if (inkRgb) recolorVisiblePixels(canvas, slotW, slotH, inkRgb);
return { logoCanvas: canvas, layout };
}
function prefersReducedMotion() {
return globalThis.matchMedia?.("(prefers-reduced-motion: reduce)")?.matches ?? false;
}
/** light | organic | dark — для цвета логотипа в анимации загрузки */
function resolveLoaderTheme(options = {}) {
const explicit = options.theme;
if (explicit === "light" || explicit === "organic" || explicit === "dark") {
return explicit;
}
const docTheme = document.documentElement.getAttribute("data-theme");
if (docTheme === "light" || docTheme === "organic" || docTheme === "dark") {
return docTheme;
}
try {
const saved = localStorage.getItem("theme");
if (saved === "light" || saved === "organic" || saved === "dark") return saved;
} catch {
/* ignore */
}
if (options.darkTheme) return "dark";
return "light";
}
function prefersHighContrast() {
const attr = document.documentElement.getAttribute("data-high-contrast");
if (attr === "true") return true;
if (attr === "false") return false;
return globalThis.matchMedia?.("(prefers-contrast: more)")?.matches ?? false;
}
function loaderAccentRgb(theme) {
if (theme === "dark") return BRAND_WHITE;
if (theme === "organic") return prefersHighContrast() ? BRAND_ORGANIC_HC : BRAND_ORGANIC;
return BRAND_BLUE;
}
function loaderUsesDarkShimmer(theme) {
return theme === "dark";
}
function loadImage(src) {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = () => reject(new Error(`Failed to load image: ${src}`));
img.src = src;
});
}
function isLogoInk(data, index) {
const r = data[index];
const g = data[index + 1];
const b = data[index + 2];
const a = data[index + 3];
if (a < 20) return false;
if (r + g + b < 80) return false;
return b > 70 && b >= r && b >= g * 0.85;
}
function isLogoBlueFringe(data, index) {
const r = data[index];
const g = data[index + 1];
const b = data[index + 2];
const a = data[index + 3];
if (a < 1) return false;
if (isLogoInk(data, index)) return true;
return b > r + 5 && b > g - 2 && b > 68 && r + g + b < 700;
}
function knockoutBackgroundFromLogo(canvas, width, height) {
const ctx = canvas.getContext("2d");
const imageData = ctx.getImageData(0, 0, width, height);
const data = imageData.data;
for (let i = 0; i < data.length; i += 4) {
if (data[i + 3] < 1) continue;
if (isLogoBlueFringe(data, i)) continue;
data[i + 3] = 0;
}
ctx.putImageData(imageData, 0, 0);
}
function recolorVisiblePixels(canvas, width, height, rgb) {
const ctx = canvas.getContext("2d");
const imageData = ctx.getImageData(0, 0, width, height);
const data = imageData.data;
for (let i = 0; i < data.length; i += 4) {
if (data[i + 3] < 1) continue;
data[i] = rgb[0];
data[i + 1] = rgb[1];
data[i + 2] = rgb[2];
}
ctx.putImageData(imageData, 0, 0);
}
function cloneLogoBuffer(sourceCanvas, width, height, rgb = null) {
const canvas = document.createElement("canvas");
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext("2d");
ctx.drawImage(sourceCanvas, 0, 0);
if (rgb) recolorVisiblePixels(canvas, width, height, rgb);
return canvas;
}
function drawLogoToBuffer(img, width, height, knockoutBackground = false) {
const canvas = document.createElement("canvas");
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext("2d");
ctx.clearRect(0, 0, width, height);
const scale = Math.min(width / img.naturalWidth, height / img.naturalHeight);
const drawWidth = img.naturalWidth * scale;
const drawHeight = img.naturalHeight * scale;
const offsetX = (width - drawWidth) / 2;
const offsetY = (height - drawHeight) / 2;
ctx.drawImage(img, offsetX, offsetY, drawWidth, drawHeight);
if (knockoutBackground) knockoutBackgroundFromLogo(canvas, width, height);
return canvas;
}
function hashCell(col, row) {
return ((col * 92837111) ^ (row * 689287499)) >>> 0;
}
function easeOutQuad(t) {
return 1 - (1 - t) * (1 - t);
}
function easeInOutQuad(t) {
return t < 0.5 ? 2 * t * t : 1 - (-2 * t + 2) ** 2 / 2;
}
function rgbaString(r, g, b, a) {
return `rgba(${r | 0}, ${g | 0}, ${b | 0}, ${a})`;
}
function desaturateColor(r, g, b, amount = 0.72) {
const gray = (r + g + b) / 3;
return [
r + (gray - r) * amount,
g + (gray - g) * amount,
b + (gray - b) * amount,
];
}
function lerpColor(c1, c2, t) {
return [
c1[0] + (c2[0] - c1[0]) * t,
c1[1] + (c2[1] - c1[1]) * t,
c1[2] + (c2[2] - c1[2]) * t,
];
}
function colorCss(color) {
return `rgb(${color[0] | 0}, ${color[1] | 0}, ${color[2] | 0})`;
}
function displayProgress(t, elapsedMs = null, durationMs = DURATION_MS) {
const shimmerStartMs = wave2EndMs(durationMs);
const shimmerEndMs = shimmerStartMs + shimmerDurationMs();
if (elapsedMs != null && elapsedMs >= shimmerStartMs) {
if (elapsedMs >= shimmerEndMs) return 100;
const shimmerP = (elapsedMs - shimmerStartMs) / shimmerDurationMs();
return Math.round(
SHIMMER_PROGRESS_START + shimmerP * (100 - SHIMMER_PROGRESS_START),
);
}
if (t >= WAVE2_END) return SHIMMER_PROGRESS_START;
if (t >= WAVE2_START) {
const p = (t - WAVE2_START) / (WAVE2_END - WAVE2_START);
return Math.round(
65 + Math.min(1, p) * (SHIMMER_PROGRESS_START - 65),
);
}
if (t >= WAVE1_START) {
const wave1End = WAVE1_START + WAVE1_SPAN;
const p = Math.min(1, (t - WAVE1_START) / Math.max(0.001, wave1End - WAVE1_START));
return Math.round(35 + p * 30);
}
if (t >= WAVE0_START) {
const wave0End = WAVE0_START + WAVE0_SPAN;
const p = Math.min(1, (t - WAVE0_START) / Math.max(0.001, wave0End - WAVE0_START));
return Math.round(p * 35);
}
return 0;
}
function progressBarColor(progress, loaderTheme = "light") {
const accent = loaderAccentRgb(loaderTheme);
if (progress >= 65) return colorCss(accent);
if (progress >= 35) {
const p = (progress - 35) / 30;
return colorCss(lerpColor(PIXEL_GRAY, accent, p));
}
return colorCss(PIXEL_GRAY);
}
function withLogoTransform(ctx, layout, t, drawFn) {
drawFn();
}
/** Фаза 0 — серые пиксели; фаза 1 — синие/белые; обе слева направо. */
function buildLogoCells(logoData, logoWidth, logoHeight, cell, accentColor = null) {
const cols = Math.ceil(logoWidth / cell);
const rows = Math.ceil(logoHeight / cell);
const colDenom = Math.max(1, cols - 1);
const cells = [];
for (let row = 0; row < rows; row += 1) {
for (let col = 0; col < cols; col += 1) {
const cx = Math.min(logoWidth - 1, col * cell + Math.floor(cell / 2));
const cy = Math.min(logoHeight - 1, row * cell + Math.floor(cell / 2));
const index = (cy * logoWidth + cx) * 4;
if (!isLogoInk(logoData, index)) continue;
const r = logoData[index];
const g = logoData[index + 1];
const b = logoData[index + 2];
const noise = (hashCell(col, row) % 1000) / 1000;
const sweep = Math.max(0, Math.min(1, col / colDenom + (noise - 0.5) * 0.028));
const color = accentColor ?? [r, g, b];
cells.push({
col,
row,
wave0At: WAVE0_START + sweep * WAVE0_SPAN,
wave1At: WAVE1_START + sweep * WAVE1_SPAN,
color,
gray: desaturateColor(r, g, b),
});
}
}
return cells;
}
function wave2FrontX(t, layout) {
if (t < WAVE2_START) {
return layout.offsetX;
}
const progress = easeInOutQuad(Math.min(1, (t - WAVE2_START) / WAVE2_SPAN));
return layout.offsetX + layout.drawWidth * progress;
}
function drawBackground(ctx, width, height, layout, t, plain = false, transparent = false) {
if (transparent) {
ctx.clearRect(0, 0, width, height);
return;
}
if (plain) {
ctx.fillStyle = "#ffffff";
ctx.fillRect(0, 0, width, height);
return;
}
const cx = width / 2;
const cy = layout ? layout.offsetY + layout.drawHeight / 2 : height * 0.44;
const bgGrad = ctx.createRadialGradient(cx, cy, 0, cx, cy, Math.max(width, height) * 0.72);
bgGrad.addColorStop(0, "#FAFAF8");
bgGrad.addColorStop(1, "#F0F0EE");
ctx.fillStyle = bgGrad;
ctx.fillRect(0, 0, width, height);
const animating = t > 0 && t < HOLD_END;
const logoCx = layout ? layout.offsetX + layout.drawWidth / 2 : cx;
const logoCy = layout ? layout.offsetY + layout.drawHeight / 2 : cy;
const focusRadius = layout
? Math.max(layout.drawWidth, layout.drawHeight) * 0.72
: Math.min(width, height) * 0.35;
for (let x = GRID_STEP / 2; x < width; x += GRID_STEP) {
for (let y = GRID_STEP / 2; y < height; y += GRID_STEP) {
let alpha = 0.055;
if (layout && animating) {
const dist = Math.hypot(x - logoCx, y - logoCy);
if (dist < focusRadius) {
alpha += 0.045 * (1 - dist / focusRadius);
}
}
ctx.fillStyle = `rgba(26, 30, 28, ${alpha})`;
ctx.fillRect(x, y, 1, 1);
}
}
const vig = ctx.createRadialGradient(cx, cy, Math.min(width, height) * 0.28, cx, cy, Math.max(width, height) * 0.82);
vig.addColorStop(0, "rgba(26, 30, 28, 0)");
vig.addColorStop(1, "rgba(26, 30, 28, 0.07)");
ctx.fillStyle = vig;
ctx.fillRect(0, 0, width, height);
}
function ensureLoaderChrome(root, hideChrome = false) {
let canvas = root.querySelector(".wesp-logo-loader__canvas");
let footer = root.querySelector(".wesp-logo-loader__footer");
if (!canvas) {
canvas = document.createElement("canvas");
canvas.className = "wesp-logo-loader__canvas";
root.appendChild(canvas);
}
if (!footer && !hideChrome) {
footer = document.createElement("div");
footer.className = "wesp-logo-loader__footer";
footer.innerHTML = `
<div class="wesp-logo-loader__footer-row">
<span class="wesp-logo-loader__label">Загрузка</span>
<span class="wesp-logo-loader__percent">00%</span>
</div>
<div class="wesp-logo-loader__bar">
<div class="wesp-logo-loader__bar-fill"></div>
</div>
<p class="wesp-logo-loader__tagline" aria-live="polite"></p>`;
root.appendChild(footer);
}
return {
canvas,
percentEl: footer?.querySelector(".wesp-logo-loader__percent") ?? null,
barFill: footer?.querySelector(".wesp-logo-loader__bar-fill") ?? null,
taglineEl: footer?.querySelector(".wesp-logo-loader__tagline") ?? null,
};
}
function computeLogoLayout(viewport, logoWidth, logoHeight, embedded = false) {
const maxWidth = viewport.w * (embedded ? 0.94 : 0.88);
const maxHeight = viewport.h * (embedded ? 0.88 : 0.34);
const scale = Math.min(maxWidth / logoWidth, maxHeight / logoHeight);
const drawWidth = logoWidth * scale;
const drawHeight = logoHeight * scale;
const offsetX = (viewport.w - drawWidth) / 2;
const offsetY = embedded
? (viewport.h - drawHeight) / 2
: viewport.h * 0.44 - drawHeight / 2;
return { scale, drawWidth, drawHeight, offsetX, offsetY };
}
function drawSharpLogo(ctx, offBlue, layout, alpha = 1) {
if (alpha <= 0) return;
ctx.save();
ctx.imageSmoothingEnabled = true;
ctx.globalAlpha = alpha;
ctx.drawImage(offBlue, layout.offsetX, layout.offsetY, layout.drawWidth, layout.drawHeight);
ctx.restore();
}
function pixelGrid(layout, cell, logoWidth, logoHeight) {
const cols = Math.ceil(logoWidth / cell);
const rows = Math.ceil(logoHeight / cell);
return {
originX: layout.offsetX,
originY: layout.offsetY,
cellW: layout.drawWidth / cols,
cellH: layout.drawHeight / rows,
};
}
function cellRect(grid, col, row) {
const x0 = grid.originX + col * grid.cellW;
const y0 = grid.originY + row * grid.cellH;
const x1 = grid.originX + (col + 1) * grid.cellW;
const y1 = grid.originY + (row + 1) * grid.cellH;
return {
x: Math.round(x0),
y: Math.round(y0),
w: Math.max(1, Math.round(x1) - Math.round(x0)),
h: Math.max(1, Math.round(y1) - Math.round(y0)),
};
}
function drawPixelBlock(ctx, rect, color, alpha) {
if (alpha <= 0) return;
const gap = Math.min(PIXEL_GAP, Math.max(0, rect.w - 1), Math.max(0, rect.h - 1));
const inset = gap * 0.5;
const x = rect.x + inset;
const y = rect.y + inset;
const w = Math.max(1, rect.w - gap);
const h = Math.max(1, rect.h - gap);
ctx.fillStyle = rgbaString(color[0], color[1], color[2], alpha);
ctx.fillRect(x, y, w, h);
}
function pixelAppear(t, startAt) {
const appear = Math.min(1, (t - startAt) / PIXEL_FADE);
return appear >= 0.7 ? 1 : appear;
}
function resolvePixelColor(item, t) {
if (t < item.wave1At) return item.gray;
const blend = Math.min(1, (t - item.wave1At) / COLOR_CROSSFADE);
if (blend >= 1) return item.color;
return lerpColor(item.gray, item.color, blend);
}
function resolvePixelAlpha(item, t) {
if (t < item.wave0At) return 0;
let alpha = pixelAppear(t, item.wave0At);
if (t >= item.wave1At) {
alpha = Math.max(alpha, pixelAppear(t, item.wave1At));
}
return alpha;
}
function featherPixelAlpha(alpha, cellMid, frontX, feather) {
if (cellMid <= frontX || cellMid >= frontX + feather) return alpha;
return alpha * ((cellMid - frontX) / feather);
}
function drawSharpLogoFeathered(ctx, logoCanvas, layout, frontX, feather) {
const left = layout.offsetX;
const top = layout.offsetY;
const width = layout.drawWidth;
const height = layout.drawHeight;
const softStart = Math.max(left, frontX - feather);
if (softStart > left + 0.5) {
ctx.save();
ctx.beginPath();
ctx.rect(left, top, softStart - left, height);
ctx.clip();
drawSharpLogo(ctx, logoCanvas, layout, 1);
ctx.restore();
}
const bandLeft = softStart;
const bandRight = Math.min(left + width, frontX);
if (bandRight <= bandLeft + 0.5) return;
ctx.save();
ctx.beginPath();
ctx.rect(bandLeft, top, bandRight - bandLeft, height);
ctx.clip();
drawSharpLogo(ctx, logoCanvas, layout, 1);
ctx.globalCompositeOperation = "destination-in";
const grad = ctx.createLinearGradient(bandLeft, 0, bandRight, 0);
grad.addColorStop(0, "rgba(255,255,255,1)");
grad.addColorStop(1, "rgba(255,255,255,0)");
ctx.fillStyle = grad;
ctx.fillRect(bandLeft, top, bandRight - bandLeft, height);
ctx.restore();
}
function drawShimmer(ctx, layout, logoCanvas, shimmerT, loaderTheme = "light") {
const darkShimmer = loaderUsesDarkShimmer(loaderTheme);
const bandW = layout.drawWidth * (darkShimmer ? 0.36 : 0.14);
const x = layout.offsetX + (layout.drawWidth + bandW) * shimmerT - bandW;
const left = layout.offsetX;
const top = layout.offsetY;
const width = layout.drawWidth;
const height = layout.drawHeight;
ctx.save();
ctx.beginPath();
ctx.rect(left, top, width, height);
ctx.clip();
if (darkShimmer) {
const [gr, gg, gb] = PIXEL_GRAY;
const grayWave = ctx.createLinearGradient(x, 0, x + bandW, 0);
grayWave.addColorStop(0, "rgba(255,255,255,0)");
grayWave.addColorStop(0.22, "rgba(255,255,255,0)");
grayWave.addColorStop(0.5, `rgba(${gr},${gg},${gb},0.95)`);
grayWave.addColorStop(0.78, "rgba(255,255,255,0)");
grayWave.addColorStop(1, "rgba(255,255,255,0)");
ctx.globalCompositeOperation = "source-atop";
ctx.fillStyle = grayWave;
ctx.fillRect(left, top, width, height);
ctx.restore();
return;
}
const peak = 0.72;
const highlight = ctx.createLinearGradient(x, 0, x + bandW, 0);
highlight.addColorStop(0, "rgba(255,255,255,0)");
highlight.addColorStop(0.3, "rgba(255,255,255,0)");
highlight.addColorStop(0.5, `rgba(255,255,255,${peak})`);
highlight.addColorStop(0.7, "rgba(255,255,255,0)");
highlight.addColorStop(1, "rgba(255,255,255,0)");
ctx.globalCompositeOperation = "lighter";
ctx.fillStyle = highlight;
ctx.fillRect(left, top, width, height);
ctx.globalCompositeOperation = "destination-in";
ctx.drawImage(logoCanvas, left, top, width, height);
ctx.restore();
}
/** Фаза 0 — серые; фаза 1 — цветные; фаза 2 — плавная сборка по контуру надписи. */
function drawWaves(ctx, layout, cell, cells, t, logoCanvas, logoWidth, logoHeight) {
const frontX = wave2FrontX(t, layout);
const sharpRight = layout.offsetX + layout.drawWidth;
const grid = pixelGrid(layout, cell, logoWidth, logoHeight);
const inWave2 = t >= WAVE2_START;
if (inWave2 && frontX > layout.offsetX + 1) {
drawSharpLogoFeathered(ctx, logoCanvas, layout, frontX, ASSEMBLY_FEATHER);
}
if (inWave2 && frontX >= sharpRight - 1) {
return;
}
for (const item of cells) {
if (t < item.wave0At) continue;
const rect = cellRect(grid, item.col, item.row);
const cellMid = rect.x + rect.w * 0.5;
if (inWave2 && cellMid < frontX - ASSEMBLY_FEATHER) continue;
let alpha = resolvePixelAlpha(item, t);
if (inWave2) {
alpha = featherPixelAlpha(alpha, cellMid, frontX, ASSEMBLY_FEATHER);
}
if (alpha <= 0) continue;
drawPixelBlock(ctx, rect, resolvePixelColor(item, t), alpha);
}
}
function updateProgressChrome(
percentEl,
barFill,
t,
getExternalProgress,
loaderTheme,
elapsedMs = null,
durationMs = DURATION_MS,
) {
let progress = displayProgress(t, elapsedMs, durationMs);
const external = getExternalProgress();
if (external != null) {
progress = Math.max(progress, Math.min(100, Math.round(external * 100)));
}
if (percentEl) percentEl.textContent = `${String(progress).padStart(2, "0")}%`;
if (barFill) {
barFill.style.width = `${progress}%`;
barFill.style.background = progressBarColor(progress, loaderTheme);
}
return progress;
}
export async function mountWespLogoLoader(root, options = {}) {
if (!root) {
throw new Error("mountWespLogoLoader: root element is required");
}
const blueSrc = options.blueSrc ?? options.whiteSrc ?? DEFAULT_BLUE_SRC;
const initialCell = options.cell ?? options.pixel ?? INITIAL_CELL;
const loop = options.loop !== false;
const durationMs = options.durationMs ?? DURATION_MS;
const totalDurationMs = options.totalDurationMs ?? null;
const playbackEndMs = resolvePlaybackEndMs(durationMs, totalDurationMs);
const embedded = !!options.embedded;
const plainBackground = !!options.plainBackground;
const transparentBackground = !!options.transparentBackground;
const knockoutBackground = !!options.knockoutBackground;
const loaderTheme = resolveLoaderTheme(options);
const staticLogo = !!options.staticLogo;
const hideChrome = !!options.hideChrome;
const onComplete = typeof options.onComplete === "function" ? options.onComplete : null;
const getExternalProgress = typeof options.progress === "function"
? options.progress
: () => (options.progress ?? null);
root.classList.add("wesp-logo-loader");
if (embedded) root.classList.add("wesp-logo-loader--embedded");
if (plainBackground) root.classList.add("wesp-logo-loader--plain");
if (transparentBackground) root.classList.add("wesp-logo-loader--transparent");
if (hideChrome) root.classList.add("wesp-logo-loader--no-chrome");
const { canvas, percentEl, barFill } = ensureLoaderChrome(root, hideChrome);
const ctx = canvas.getContext("2d", { willReadFrequently: true });
if (transparentBackground) {
canvas.style.background = "transparent";
}
const blueImg = await loadImage(blueSrc);
const logoWidth = blueImg.naturalWidth || LOGO_SRC_WIDTH;
const logoHeight = blueImg.naturalHeight || LOGO_SRC_HEIGHT;
const offBlue = drawLogoToBuffer(
blueImg,
logoWidth,
logoHeight,
knockoutBackground,
);
const accentRgb = loaderAccentRgb(loaderTheme);
const displayLogo = loaderTheme === "light"
? offBlue
: cloneLogoBuffer(offBlue, logoWidth, logoHeight, accentRgb);
const waveAccent = loaderTheme === "light" ? null : accentRgb;
const logoData = offBlue.getContext("2d").getImageData(0, 0, logoWidth, logoHeight).data;
const logoCells = buildLogoCells(
logoData,
logoWidth,
logoHeight,
initialCell,
waveAccent,
);
let viewport = { w: 0, h: 0, dpr: 1 };
let layout = null;
let rafId = 0;
let start = performance.now();
let disposed = false;
let completedFired = false;
function finishIfDone(elapsedMs) {
if (loop || elapsedMs < playbackEndMs || completedFired || !onComplete) return;
completedFired = true;
window.setTimeout(() => onComplete(), 120);
}
function readViewportSize() {
if (embedded) {
const rect = root.getBoundingClientRect();
return {
w: Math.max(1, rect.width || root.clientWidth || 1),
h: Math.max(1, rect.height || root.clientHeight || 1),
};
}
return { w: globalThis.innerWidth, h: globalThis.innerHeight };
}
function resize() {
const dpr = Math.min(globalThis.devicePixelRatio || 1, 2);
const size = readViewportSize();
viewport = { w: size.w, h: size.h, dpr };
canvas.width = viewport.w * dpr;
canvas.height = viewport.h * dpr;
canvas.style.width = `${viewport.w}px`;
canvas.style.height = `${viewport.h}px`;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.imageSmoothingEnabled = false;
layout = computeLogoLayout(viewport, logoWidth, logoHeight, embedded);
}
function drawStaticLogo() {
resize();
drawBackground(ctx, viewport.w, viewport.h, layout, 1, plainBackground, transparentBackground);
if (layout) drawSharpLogo(ctx, displayLogo, layout, 1);
updateProgressChrome(
percentEl,
barFill,
1,
getExternalProgress,
loaderTheme,
animationEndMs(durationMs),
durationMs,
);
finishIfDone(playbackEndMs);
}
function drawAnimatedFrame(t, elapsedMs) {
const shimmerMs = shimmerDurationMs();
const shimmerStartMs = wave2EndMs(durationMs);
withLogoTransform(ctx, layout, t, () => {
if (t >= WAVE2_END) {
drawSharpLogo(ctx, displayLogo, layout, 1);
if (
elapsedMs >= shimmerStartMs
&& elapsedMs < shimmerStartMs + shimmerMs
) {
drawShimmer(
ctx,
layout,
displayLogo,
(elapsedMs - shimmerStartMs) / shimmerMs,
loaderTheme,
);
}
return;
}
drawWaves(ctx, layout, initialCell, logoCells, t, displayLogo, logoWidth, logoHeight);
});
}
function drawStaticIntroFrame(t) {
drawBackground(ctx, viewport.w, viewport.h, layout, t, plainBackground, transparentBackground);
if (!layout) return;
const alpha = easeOutQuad(Math.min(1, t / 0.82));
drawSharpLogo(ctx, displayLogo, layout, alpha);
}
function cycleElapsedMs(elapsed) {
if (!loop) return elapsed;
return elapsed % durationMs;
}
function drawFrame(now) {
if (disposed) return;
const elapsed = now - start;
const cycleMs = cycleElapsedMs(elapsed);
const t = loop
? cycleMs / durationMs
: Math.min(1, elapsed / durationMs);
const shimmerMs = shimmerDurationMs();
const shimmerStartMs = wave2EndMs(durationMs);
const inShimmer =
cycleMs >= shimmerStartMs && cycleMs < shimmerStartMs + shimmerMs;
updateProgressChrome(
percentEl,
barFill,
t,
getExternalProgress,
loaderTheme,
cycleMs,
durationMs,
);
if (staticLogo) {
drawStaticIntroFrame(t);
finishIfDone(elapsed);
if (loop || elapsed < playbackEndMs) {
rafId = requestAnimationFrame(drawFrame);
}
return;
}
drawBackground(ctx, viewport.w, viewport.h, layout, t, plainBackground, transparentBackground);
if (!layout) return;
if (t < HOLD_END || inShimmer) {
drawAnimatedFrame(t, cycleMs);
} else if (loop) {
withLogoTransform(ctx, layout, t, () => {
const fade = 1 - easeOutQuad((t - HOLD_END) / (1 - HOLD_END));
drawSharpLogo(ctx, displayLogo, layout, fade);
});
} else {
withLogoTransform(ctx, layout, t, () => {
drawSharpLogo(ctx, displayLogo, layout, 1);
});
}
finishIfDone(elapsed);
if (loop || elapsed < playbackEndMs) {
rafId = requestAnimationFrame(drawFrame);
}
}
resize();
globalThis.addEventListener("resize", resize);
if (prefersReducedMotion() || staticLogo) {
if (staticLogo) {
rafId = requestAnimationFrame(drawFrame);
} else {
drawStaticLogo();
}
} else {
rafId = requestAnimationFrame(drawFrame);
}
return function disposeWespLogoLoader() {
if (disposed) return;
disposed = true;
cancelAnimationFrame(rafId);
globalThis.removeEventListener("resize", resize);
root.classList.add("wesp-logo-loader--exiting");
window.setTimeout(() => {
root.classList.add("wesp-logo-loader--hidden");
window.setTimeout(() => {
if (root.isConnected) root.remove();
}, 480);
}, 520);
};
}
export async function showWespLogoLoader(options = {}) {
const root = document.createElement("div");
root.id = options.id ?? "wespBootLoader";
root.className = "wesp-logo-loader";
root.setAttribute("aria-busy", "true");
root.setAttribute("aria-label", options.label ?? "Загрузка");
document.body.appendChild(root);
return mountWespLogoLoader(root, options);
}
@@ -0,0 +1,152 @@
/**
* Блик по буквам логотипа в navbar (тёмная тема) — тот же drawShimmer, что в wesp-logo-loader.
*/
(function () {
const LOGO_SRC = "/static/logo2.png";
const CYCLE_MS = 3000;
const SHIMMER_MS = 1000;
const START_DELAY_MS = 350;
const mounts = new WeakMap();
function isDarkTheme() {
return document.documentElement.getAttribute("data-theme") === "dark";
}
function prefersReducedMotion() {
return globalThis.matchMedia?.("(prefers-reduced-motion: reduce)")?.matches ?? false;
}
function loadImage(src) {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = () => reject(new Error(`Failed to load: ${src}`));
img.src = src;
});
}
function disposeMount(link) {
const state = mounts.get(link);
if (!state) return;
state.disposed = true;
if (state.rafId) cancelAnimationFrame(state.rafId);
state.canvas?.remove();
link.classList.remove("nav-logo-link--canvas-shimmer");
mounts.delete(link);
}
async function mountCanvasShimmer(link) {
if (!isDarkTheme() || prefersReducedMotion()) return;
disposeMount(link);
let logoModule;
try {
logoModule = await import("/static/js/wesp-logo-loader.js?v=2");
} catch {
return;
}
const { createNavLogoShimmerBuffer, drawShimmer } = logoModule;
let img;
try {
img = await loadImage(LOGO_SRC);
} catch {
return;
}
const canvas = document.createElement("canvas");
canvas.className = "nav-logo__shimmer-canvas";
canvas.setAttribute("aria-hidden", "true");
link.classList.add("nav-logo-link--canvas-shimmer");
link.appendChild(canvas);
const ctx = canvas.getContext("2d");
const state = {
disposed: false,
rafId: 0,
canvas,
logoCanvas: null,
layout: null,
startAt: performance.now() + START_DELAY_MS,
lastW: 0,
lastH: 0,
};
mounts.set(link, state);
function rebuildBuffer() {
const w = Math.max(1, Math.round(link.clientWidth));
const h = Math.max(1, Math.round(link.clientHeight));
if (w === state.lastW && h === state.lastH && state.logoCanvas) return;
state.lastW = w;
state.lastH = h;
const dpr = Math.min(globalThis.devicePixelRatio || 1, 2);
canvas.width = Math.round(w * dpr);
canvas.height = Math.round(h * dpr);
canvas.style.width = `${w}px`;
canvas.style.height = `${h}px`;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
const built = createNavLogoShimmerBuffer(img, w, h, [255, 255, 255]);
state.logoCanvas = built.logoCanvas;
state.layout = built.layout;
}
function tick(now) {
if (state.disposed) return;
if (!isDarkTheme()) {
disposeMount(link);
return;
}
rebuildBuffer();
const w = state.lastW;
const h = state.lastH;
ctx.clearRect(0, 0, w, h);
const elapsed = now - state.startAt;
if (elapsed >= 0 && state.logoCanvas && state.layout) {
const cycle = elapsed % CYCLE_MS;
if (cycle < SHIMMER_MS) {
const shimmerT = cycle / SHIMMER_MS;
ctx.drawImage(state.logoCanvas, 0, 0, w, h);
drawShimmer(ctx, state.layout, state.logoCanvas, shimmerT, false);
}
}
state.rafId = requestAnimationFrame(tick);
}
rebuildBuffer();
state.rafId = requestAnimationFrame(tick);
}
function init() {
if (prefersReducedMotion()) return;
document
.querySelectorAll(".mobile-navbar .nav-logo-link, .mobile-brand .nav-logo-link")
.forEach((link) => {
if (isDarkTheme()) {
mountCanvasShimmer(link);
} else {
disposeMount(link);
}
});
}
function onThemeChange() {
init();
}
window.WespNavLogoShimmer = { init, disposeMount };
const themeObserver = new MutationObserver(onThemeChange);
themeObserver.observe(document.documentElement, {
attributes: true,
attributeFilter: ["data-theme"],
});
/* init вызывается из wesp-zootech-nav.js после вставки navbar и из wesp-page-enter после enter-done */
})();
@@ -0,0 +1,68 @@
export const PRELOAD_FEED_DISPENSERS_KEY = "wesp_preload_feed_dispensers_v1";
export const PRELOAD_FEED_DISPENSERS_MAX_AGE_MS = 120_000;
export const LOGIN_TRANSITION_KEY = "wesp_login_transition_v1";
export const LOGIN_TRANSITION_MAX_AGE_MS = 120_000;
function readStoredEntry(key, maxAgeMs) {
try {
const raw = sessionStorage.getItem(key);
if (!raw) return null;
const parsed = JSON.parse(raw);
if (!parsed || Date.now() - parsed.storedAt > maxAgeMs) {
sessionStorage.removeItem(key);
return null;
}
return parsed;
} catch {
return null;
}
}
export function markLoginTransition() {
try {
sessionStorage.setItem(LOGIN_TRANSITION_KEY, JSON.stringify({
storedAt: Date.now(),
}));
} catch {
/* ignore */
}
}
export function storePreloadedApiPayload(key, payload) {
try {
sessionStorage.setItem(key, JSON.stringify({
storedAt: Date.now(),
payload,
}));
} catch {
/* ignore */
}
}
export function peekPreloadedApiPayload(key, maxAgeMs = PRELOAD_FEED_DISPENSERS_MAX_AGE_MS) {
const entry = readStoredEntry(key, maxAgeMs);
return entry?.payload ?? null;
}
export function clearPreloadedApiPayload(key) {
try {
sessionStorage.removeItem(key);
} catch {
/* ignore */
}
}
export function clearRecipesBootOverlay() {
try {
sessionStorage.removeItem(LOGIN_TRANSITION_KEY);
} catch {
/* ignore */
}
document.documentElement.classList.remove("wesp-login-boot-pending");
const overlay = document.getElementById("wesp-recipes-boot");
if (overlay) overlay.remove();
window.WespPageEnter?.reveal?.();
}
+135
View File
@@ -0,0 +1,135 @@
/**
* Мягкая поэтапная анимация zootech-страниц: navbar → main → блоки.
* html.wesp-page-enter-pending ставится inline в <head> до первого paint.
*/
(function () {
const MAX_ITEMS = 8;
let held = false;
let revealed = false;
function prefersReducedMotion() {
return globalThis.matchMedia?.("(prefers-reduced-motion: reduce)")?.matches ?? false;
}
function isGridListPage() {
const body = document.body;
if (!body) return false;
return (
body.classList.contains("components-page") ||
body.classList.contains("feed-dispensers-page") ||
body.classList.contains("reports-page")
);
}
function markEnterItems() {
const html = document.documentElement;
if (isGridListPage()) {
html.classList.add("wesp-page-enter-grid-page");
return;
}
html.classList.remove("wesp-page-enter-grid-page");
const root = document.querySelector(".wesp-page-main");
if (!root) return;
let items = Array.from(root.querySelectorAll("[data-wesp-enter]"));
if (!items.length) {
items = Array.from(root.children).filter((el) => el.nodeType === 1);
}
items.slice(0, MAX_ITEMS).forEach((el, i) => {
el.classList.add("wesp-page-enter-item");
el.style.setProperty("--wesp-enter-i", String(i));
});
document.querySelectorAll("[data-wesp-enter].app-footer, footer[data-wesp-enter]").forEach((el, i) => {
if (el.classList.contains("wesp-page-enter-item")) return;
el.classList.add("wesp-page-enter-item");
el.style.setProperty("--wesp-enter-i", String(items.length + i));
});
}
function finalizeReveal() {
if (revealed) return;
revealed = true;
const html = document.documentElement;
html.classList.remove("wesp-page-enter-pending");
html.classList.add("wesp-page-enter-ready");
let settled = false;
let fallbackTimer = 0;
const settleDone = () => {
if (settled || !html.classList.contains("wesp-page-enter-ready")) return;
settled = true;
if (fallbackTimer) window.clearTimeout(fallbackTimer);
requestAnimationFrame(() => {
html.classList.remove("wesp-page-enter-ready");
html.classList.add("wesp-page-enter-done");
requestAnimationFrame(() => {
globalThis.WespNavLogoShimmer?.init?.();
});
});
};
const items = document.querySelectorAll(".wesp-page-enter-item");
let pending = items.length;
if (pending === 0) {
settleDone();
return;
}
const onAnimEnd = (event) => {
if (event.animationName !== "wesp-enter-fade") return;
pending -= 1;
if (pending <= 0) settleDone();
};
items.forEach((el) => {
el.addEventListener("animationend", onAnimEnd, { once: true });
});
const fallbackMs =
80 + (MAX_ITEMS - 1) * 55 + 350 + 80;
fallbackTimer = window.setTimeout(settleDone, fallbackMs);
}
function revealPageMain() {
if (revealed || held) return;
requestAnimationFrame(() => {
requestAnimationFrame(() => {
if (!held && !revealed) finalizeReveal();
});
});
}
function holdPageMain() {
held = true;
}
function releaseAndReveal() {
held = false;
if (!revealed) revealPageMain();
}
function init() {
markEnterItems();
if (prefersReducedMotion()) {
finalizeReveal();
document.documentElement.classList.add("wesp-page-enter-done");
return;
}
if (!held) {
revealPageMain();
}
}
window.WespPageEnter = {
init,
hold: holdPageMain,
reveal: releaseAndReveal,
};
})();
@@ -0,0 +1,45 @@
/* Синхронная ранняя установка data-theme + data-high-contrast + критические стили. */
(function () {
var KEY = "theme";
var HC_KEY = "wespHighContrast";
var saved = localStorage.getItem(KEY);
var theme =
saved === "dark" || saved === "light" || saved === "organic" ? saved : "dark";
var root = document.documentElement;
root.dataset.theme = theme;
var hcSaved = localStorage.getItem(HC_KEY);
var highContrast =
hcSaved === "1"
? true
: hcSaved === "0"
? false
: !!(window.matchMedia && window.matchMedia("(prefers-contrast: more)").matches);
root.dataset.highContrast = highContrast ? "true" : "false";
var ORGANIC_LOGO_FILTER =
"brightness(0) saturate(100%) invert(43%) sepia(19%) saturate(747%) " +
"hue-rotate(118deg) brightness(92%) contrast(87%)";
var ORGANIC_LOGO_FILTER_HC =
"brightness(0) saturate(100%) invert(28%) sepia(22%) saturate(900%) " +
"hue-rotate(118deg) brightness(88%) contrast(96%)";
var organicLogoFilter =
theme === "organic" && highContrast ? ORGANIC_LOGO_FILTER_HC : ORGANIC_LOGO_FILTER;
var css =
'html{background:#f5f7fa}' +
'html[data-theme="dark"]{background:#12181b}' +
'html[data-theme="organic"]{background:#f6f6f4}' +
'html[data-theme="organic"] .nav-brand .logo.logo--light-theme,' +
'html[data-theme="organic"] .mobile-brand .logo.logo--light-theme{' +
"filter:" +
organicLogoFilter +
"!important}";
var el = document.createElement("style");
el.id = "wesp-theme-critical";
el.textContent = css;
document.head.appendChild(el);
})();
+143
View File
@@ -0,0 +1,143 @@
/** Общая тема зоотех-страниц: localStorage + переключатель #wespThemePicker */
export const THEME_KEY = "theme";
export const HIGH_CONTRAST_KEY = "wespHighContrast";
export const THEMES = ["light", "organic", "dark"];
/** @deprecated Админка всегда ultra-dark; ключ оставлен для совместимости zootech-тем. */
export const ADMIN_ULTRA_DARK_KEY = "wespAdminUltraDark";
const THEME_LABELS = {
light: "Светлая",
organic: "Зелёная",
dark: "Тёмная",
};
export function getTheme() {
const saved = localStorage.getItem(THEME_KEY);
if (THEMES.includes(saved)) return saved;
return "dark";
}
/** Ручной выбор в настройках; иначе — системный prefers-contrast. */
export function resolveHighContrast() {
try {
const saved = localStorage.getItem(HIGH_CONTRAST_KEY);
if (saved === "1") return true;
if (saved === "0") return false;
} catch {
/* ignore */
}
return globalThis.matchMedia?.("(prefers-contrast: more)")?.matches ?? false;
}
export function getHighContrast() {
return resolveHighContrast();
}
export function applyHighContrast(on) {
document.documentElement.dataset.highContrast = on ? "true" : "false";
}
export function setHighContrast(on) {
try {
localStorage.setItem(HIGH_CONTRAST_KEY, on ? "1" : "0");
} catch {
/* ignore */
}
applyHighContrast(!!on);
syncHighContrastUI();
}
export function applyTheme(theme) {
document.documentElement.dataset.theme = theme;
}
export function setTheme(theme) {
if (!THEMES.includes(theme)) return;
if (theme === "light" || theme === "organic") {
try {
localStorage.removeItem(ADMIN_ULTRA_DARK_KEY);
} catch (e) {
/* ignore */
}
}
localStorage.setItem(THEME_KEY, theme);
applyTheme(theme);
syncThemePickerUI();
}
function syncThemePickerUI() {
const mount = document.getElementById("wespThemePicker");
if (!mount) return;
const current = getTheme();
mount.querySelectorAll("[data-theme-value]").forEach((btn) => {
const active = btn.dataset.themeValue === current;
btn.classList.toggle("is-active", active);
btn.setAttribute("aria-checked", active ? "true" : "false");
});
}
function syncHighContrastUI() {
const el = document.getElementById("wespHighContrastToggle");
if (!el) return;
const on = getHighContrast();
el.checked = on;
el.setAttribute("aria-checked", on ? "true" : "false");
}
/** Сегментированный переключатель тем (3 варианта). */
export function initThemePicker() {
const mount = document.getElementById("wespThemePicker");
if (!mount) return;
if (mount.dataset.bound !== "1") {
mount.dataset.bound = "1";
mount.classList.add("theme-picker");
mount.innerHTML = THEMES.map(
(id) =>
`<button type="button" class="theme-picker__btn" data-theme-value="${id}" role="radio" aria-checked="false">${THEME_LABELS[id]}</button>`
).join("");
mount.addEventListener("click", (e) => {
const btn = e.target.closest("[data-theme-value]");
if (!btn) return;
setTheme(btn.dataset.themeValue);
});
}
syncThemePickerUI();
}
/** Переключатель усиленного контраста (под выбором темы). */
export function initHighContrastToggle() {
const el = document.getElementById("wespHighContrastToggle");
if (!el) return;
if (el.dataset.bound !== "1") {
el.dataset.bound = "1";
el.addEventListener("change", () => setHighContrast(el.checked));
}
syncHighContrastUI();
}
/** @deprecated Используйте initThemePicker. Оставлен для обратной совместимости. */
export function initThemeToggle() {
if (document.getElementById("wespThemePicker")) {
initThemePicker();
return;
}
const el = document.getElementById("darkThemeToggle");
if (!el) return;
if (el.dataset.bound === "1") {
el.checked = document.documentElement.dataset.theme === "dark";
return;
}
el.dataset.bound = "1";
el.checked = document.documentElement.dataset.theme === "dark";
el.addEventListener("change", () => {
setTheme(el.checked ? "dark" : "light");
});
}
@@ -0,0 +1,406 @@
/**
* Фоновый poll статуса обновлений + баннер «Обновить / Позже».
* WespUpdateNotifier.init({ dialog: WespDialog | WespKioskDialog });
*/
(function (global) {
var DISMISS_KEY = "wesp_update_dismissed";
var DEFAULT_POLL_MS = 300000;
var PROGRESS_POLL_MS = 500;
var opts = {};
var pollTimer = null;
var progressTimer = null;
var lastStatus = null;
var lastProgress = null;
function ensureDom() {
if (document.getElementById("wespUpdateBanner")) return;
var banner = document.createElement("div");
banner.id = "wespUpdateBanner";
banner.setAttribute("role", "region");
banner.setAttribute("aria-label", "Доступно обновление");
banner.innerHTML =
'<div class="wesp-update-banner-inner">' +
'<div class="wesp-update-banner-text" id="wespUpdateBannerText"></div>' +
'<div class="wesp-update-banner-changelog" id="wespUpdateBannerChangelog"></div>' +
'<div class="wesp-update-banner-actions">' +
'<button type="button" class="wesp-update-banner-btn" id="wespUpdateBannerDetails">Подробнее</button>' +
'<button type="button" class="wesp-update-banner-btn" id="wespUpdateBannerLater">Позже</button>' +
'<button type="button" class="wesp-update-banner-btn wesp-update-banner-btn--primary" id="wespUpdateBannerInstall">Обновить</button>' +
"</div></div>";
document.body.appendChild(banner);
var overlay = document.createElement("div");
overlay.id = "wespUpdateOverlay";
overlay.setAttribute("aria-hidden", "true");
overlay.innerHTML =
'<div class="wesp-update-overlay-panel" role="alertdialog" aria-modal="true">' +
'<h2 class="wesp-update-overlay-title" id="wespUpdateOverlayTitle">Идёт обновление</h2>' +
'<p class="wesp-update-overlay-body" id="wespUpdateOverlayBody">Подготовка…</p>' +
'<div class="wesp-update-progress-wrap" id="wespUpdateProgressWrap">' +
'<div class="wesp-update-progress-track">' +
'<div class="wesp-update-progress-bar" id="wespUpdateProgressBar"></div>' +
"</div>" +
'<div class="wesp-update-progress-meta">' +
'<span class="wesp-update-progress-percent" id="wespUpdateProgressPercent">0%</span>' +
'<span class="wesp-update-progress-detail" id="wespUpdateProgressDetail"></span>' +
"</div>" +
"</div>" +
"</div>";
document.body.appendChild(overlay);
document.getElementById("wespUpdateBannerLater").addEventListener("click", dismissBanner);
document.getElementById("wespUpdateBannerDetails").addEventListener("click", toggleDetails);
document.getElementById("wespUpdateBannerInstall").addEventListener("click", onInstallClick);
}
function isDismissed(version) {
try {
return sessionStorage.getItem(DISMISS_KEY) === String(version || "");
} catch (e) {
return false;
}
}
function dismissBanner() {
if (lastStatus && lastStatus.pending_version) {
try {
sessionStorage.setItem(DISMISS_KEY, String(lastStatus.pending_version));
} catch (e) {}
}
hideBanner();
}
function hideBanner() {
var el = document.getElementById("wespUpdateBanner");
if (el) {
el.classList.remove("wesp-update-banner--visible", "wesp-update-banner--expanded");
}
}
function showBanner(status) {
if (!status || !status.update_available || !status.pending_version) {
hideBanner();
return;
}
if (isDismissed(status.pending_version)) {
hideBanner();
return;
}
ensureDom();
var el = document.getElementById("wespUpdateBanner");
var text = document.getElementById("wespUpdateBannerText");
var changelog = document.getElementById("wespUpdateBannerChangelog");
if (!el || !text) return;
text.innerHTML =
"Доступна версия <strong>" +
escapeHtml(status.pending_version) +
"</strong>" +
(status.pending_name ? " — " + escapeHtml(status.pending_name) : "") +
". Сейчас: <strong>" +
escapeHtml(status.current_version || "—") +
"</strong>.";
if (changelog) {
changelog.textContent = status.pending_body || "";
changelog.style.display = status.pending_body ? "" : "none";
}
var detailsBtn = document.getElementById("wespUpdateBannerDetails");
if (detailsBtn) {
detailsBtn.style.display = status.pending_body ? "" : "none";
}
el.classList.add("wesp-update-banner--visible");
}
function toggleDetails() {
var el = document.getElementById("wespUpdateBanner");
if (el) el.classList.toggle("wesp-update-banner--expanded");
}
function showInstallingOverlay(message, progress) {
ensureDom();
var overlay = document.getElementById("wespUpdateOverlay");
if (overlay) {
overlay.classList.add("wesp-update-overlay--visible");
overlay.setAttribute("aria-hidden", "false");
}
if (message) {
var bodyEl = document.getElementById("wespUpdateOverlayBody");
if (bodyEl) bodyEl.textContent = message;
}
applyProgressUi(progress);
hideBanner();
}
function hideInstallingOverlay() {
var overlay = document.getElementById("wespUpdateOverlay");
if (overlay) {
overlay.classList.remove("wesp-update-overlay--visible");
overlay.setAttribute("aria-hidden", "true");
}
}
function applyProgressUi(progress) {
if (!progress || typeof progress !== "object") return;
lastProgress = progress;
var titleEl = document.getElementById("wespUpdateOverlayTitle");
var bodyEl = document.getElementById("wespUpdateOverlayBody");
var barEl = document.getElementById("wespUpdateProgressBar");
var pctEl = document.getElementById("wespUpdateProgressPercent");
var detailEl = document.getElementById("wespUpdateProgressDetail");
var pct = Number(progress.percent);
if (!Number.isFinite(pct)) pct = 0;
pct = Math.max(0, Math.min(100, Math.round(pct)));
if (titleEl && progress.target_version) {
titleEl.textContent = "Обновление до версии " + progress.target_version;
}
if (bodyEl && progress.message) {
bodyEl.textContent = progress.message;
}
if (barEl) {
barEl.style.width = pct + "%";
barEl.setAttribute("aria-valuenow", String(pct));
}
if (pctEl) pctEl.textContent = pct + "%";
if (detailEl) {
detailEl.textContent = progress.detail || "";
detailEl.style.display = progress.detail ? "" : "none";
}
}
function stopProgressPolling() {
if (progressTimer) {
clearInterval(progressTimer);
progressTimer = null;
}
}
function startProgressPolling() {
stopProgressPolling();
progressTimer = setInterval(function () {
fetchStatus().then(function (status) {
if (!status) return;
if (status.update_progress) {
applyProgressUi(status.update_progress);
} else if (status.is_updating) {
applyProgressUi({
message: "Выполняется обновление…",
percent: lastProgress ? lastProgress.percent : 5,
});
}
});
}, PROGRESS_POLL_MS);
}
function escapeHtml(s) {
return String(s)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function fetchStatus() {
var url = opts.statusUrl || "/api/updates/status";
return fetch(url, { credentials: "same-origin", cache: "no-store" }).then(function (r) {
if (r.status === 401 || r.status === 403) return null;
return r.json().catch(function () {
return null;
});
});
}
function applyStatus(status) {
if (!status || typeof status !== "object") return;
lastStatus = status;
if (status.update_available && status.pending_version) {
try {
var dismissed = sessionStorage.getItem(DISMISS_KEY);
if (dismissed && dismissed !== String(status.pending_version)) {
sessionStorage.removeItem(DISMISS_KEY);
}
} catch (e) {}
}
showBanner(status);
}
function pollOnce() {
return fetchStatus().then(function (status) {
applyStatus(status);
});
}
function onInstallClick() {
var dialog = opts.dialog;
if (!dialog || typeof dialog.confirm !== "function") {
if (!global.confirm("Система перезапустится. Продолжить?")) return;
doInstall();
return;
}
var msg =
"Будет загружена и установлена версия " +
(lastStatus && lastStatus.pending_version ? lastStatus.pending_version : "") +
".\n\nСистема перезапустится. Продолжить?";
dialog
.confirm(msg, {
title: "Обновление WESP",
okText: "Обновить",
cancelText: "Отмена",
danger: true,
})
.then(function (ok) {
if (ok) doInstall();
});
}
function waitForServerAndReload() {
var url = opts.statusUrl || "/api/updates/status";
var attempts = 0;
var maxAttempts = 90;
var intervalMs = 2000;
showInstallingOverlay(
"Обновление установлено. Ожидаем перезапуск сервера — страница обновится автоматически.",
{ message: "Ожидание перезапуска сервера…", percent: 100, detail: null }
);
function scheduleNext() {
if (attempts >= maxAttempts) {
var bodyEl = document.getElementById("wespUpdateOverlayBody");
if (bodyEl) {
bodyEl.innerHTML =
"Сервер долго не отвечает. Если перезапуск уже завершился, " +
'<button type="button" class="wesp-update-banner-btn wesp-update-banner-btn--primary" id="wespUpdateManualReload">Обновить страницу</button>';
var btn = document.getElementById("wespUpdateManualReload");
if (btn) btn.addEventListener("click", function () { global.location.reload(); });
}
return;
}
setTimeout(tick, intervalMs);
}
function showRollbackOverlay(state) {
var ver = (state && (state.previous_version || state.message)) || "—";
var msg =
(state && state.message) ||
"Обновление отменено. Восстановлена предыдущая версия.";
showInstallingOverlay(msg, {
message: msg,
percent: 100,
target_version: ver,
});
var bar = document.getElementById("wespUpdateProgressBar");
var panel = document.querySelector("#wespUpdateOverlay .wesp-update-overlay-panel");
if (bar) bar.classList.add("wesp-update-progress-bar--error");
if (panel) panel.classList.add("wesp-update-overlay-panel--rollback");
var bodyEl = document.getElementById("wespUpdateOverlayBody");
if (bodyEl) {
bodyEl.innerHTML =
escapeHtml(msg) +
' <button type="button" class="wesp-update-banner-btn wesp-update-banner-btn--primary" id="wespUpdateManualReload">Обновить страницу</button>';
var btn = document.getElementById("wespUpdateManualReload");
if (btn) btn.addEventListener("click", function () { global.location.reload(); });
}
}
function tick() {
attempts += 1;
applyProgressUi({
message: "Ожидание перезапуска сервера…",
percent: 100,
detail: "~" + attempts * 2 + " с",
});
fetch(url, { credentials: "same-origin", cache: "no-store" })
.then(function (r) {
return r.json().catch(function () { return null; }).then(function (body) {
return { ok: r.ok, status: r.status, body: body };
});
})
.then(function (res) {
var st = res.body && res.body.last_update_state;
if (st && st.status === "rolled_back") {
showRollbackOverlay(st);
return;
}
if (res.ok || res.status === 401 || res.status === 403) {
global.location.reload();
return;
}
scheduleNext();
})
.catch(function () {
scheduleNext();
});
}
setTimeout(tick, 3000);
}
function doInstall() {
var targetVersion = lastStatus && lastStatus.pending_version ? lastStatus.pending_version : "";
showInstallingOverlay("Подготовка к обновлению…", {
message: "Подготовка к обновлению…",
percent: 0,
target_version: targetVersion,
});
startProgressPolling();
var url = opts.installUrl || "/api/updates/install";
fetch(url, { method: "POST", credentials: "same-origin", headers: { "Content-Type": "application/json" }, body: "{}" })
.then(function (r) {
return r.json().catch(function () {
return {};
}).then(function (body) {
return { ok: r.ok, body: body };
});
})
.then(function (res) {
stopProgressPolling();
if (!res.ok) {
hideInstallingOverlay();
var errMsg = (res.body && (res.body.error || res.body.message)) || "Не удалось установить обновление";
if (opts.dialog && opts.dialog.alert) {
opts.dialog.alert(errMsg, { title: "Ошибка обновления", variant: "danger" });
} else {
global.alert(errMsg);
}
pollOnce();
return;
}
waitForServerAndReload();
})
.catch(function () {
stopProgressPolling();
hideInstallingOverlay();
if (opts.dialog && opts.dialog.alert) {
opts.dialog.alert("Сетевая ошибка при установке обновления.", { title: "Ошибка", variant: "danger" });
}
});
}
function init(options) {
opts = options || {};
if (!opts.dialog) {
opts.dialog = global.WespDialog || global.WespKioskDialog || null;
}
ensureDom();
pollOnce();
var interval = Number(opts.pollIntervalMs);
if (!Number.isFinite(interval) || interval < 60000) interval = DEFAULT_POLL_MS;
if (pollTimer) clearInterval(pollTimer);
pollTimer = setInterval(pollOnce, interval);
}
global.WespUpdateNotifier = {
init: init,
pollOnce: pollOnce,
dismissBanner: dismissBanner,
};
})(typeof window !== "undefined" ? window : globalThis);
@@ -0,0 +1,61 @@
/** Правила пароля — как в админке и setup wizard. Логин без ограничений по символам. */
export const DEFAULT_PASSWORD_MIN_LEN = 8;
export const LOGIN_MAX_LEN = 64;
export const PASSWORD_COMPLEXITY_MSG =
"Пароль не должен состоять только из цифр или только из букв. Смешайте буквы и цифры.";
export function validateNewUserCredentials({
login,
password,
confirmPassword,
passwordMinLen = DEFAULT_PASSWORD_MIN_LEN,
label = "",
}) {
const prefix = label ? `${label}: ` : "";
const trimmedLogin = String(login || "").trim();
if (!trimmedLogin) return `${prefix}укажите логин.`;
if (trimmedLogin.length > LOGIN_MAX_LEN) {
return `${prefix}логин не длиннее ${LOGIN_MAX_LEN} символов.`;
}
if (!password) return `${prefix}укажите пароль.`;
if (password !== confirmPassword) return `${prefix}пароли не совпадают.`;
if (password.length < passwordMinLen) {
return `${prefix}пароль не короче ${passwordMinLen} символов.`;
}
if (/^\d+$/.test(password) || /^[a-zA-Z]+$/.test(password)) {
return `${prefix}${PASSWORD_COMPLEXITY_MSG}`;
}
return null;
}
export function validateCredentialChange({
oldLogin,
oldPassword,
newLogin,
newPassword,
confirmPassword,
passwordMinLen = DEFAULT_PASSWORD_MIN_LEN,
}) {
if (!oldLogin || !oldPassword) {
return "Укажите текущий логин и пароль";
}
return validateNewUserCredentials({
login: newLogin,
password: newPassword,
confirmPassword,
passwordMinLen,
});
}
export function ensureCredentialsFormHints() {}
if (typeof globalThis !== "undefined") {
globalThis.WespUserCredentials = {
DEFAULT_PASSWORD_MIN_LEN,
LOGIN_MAX_LEN,
PASSWORD_COMPLEXITY_MSG,
validateCredentialChange,
validateNewUserCredentials,
ensureCredentialsFormHints,
};
}
@@ -0,0 +1,89 @@
/**
* Безопасные тексты для пользователя: без HTTP-кодов, stack trace и прочей техники.
*/
(function (global) {
const DEFAULT_ERROR_MESSAGE = "Не удалось выполнить операцию. Попробуйте ещё раз.";
const TECHNICAL_MESSAGE_PATTERNS = [
/\bHTTP\s*\d{3}\b/i,
/^\d{3}$/,
/ошибка\s*\d{3}/i,
/status:\s*\d+/i,
/Failed to fetch/i,
/NetworkError/i,
/Load failed/i,
/SyntaxError/i,
/TypeError/i,
/ReferenceError/i,
/RangeError/i,
/Traceback/i,
/\bException\b/i,
/UnicodeEncodeError/i,
/Internal Server Error/i,
/<html[\s>]/i,
/<!DOCTYPE/i,
/^Error:/i,
/\.py:\d+/i,
/\.js:\d+/i,
/at\s+.+\(.+:\d+:\d+\)/,
/Unexpected token/i,
/SQLALCHEMY|werkzeug|flask/i,
/date_from|date_to|YYYY-MM-DD/i,
/\bapi\/[a-z0-9_/-]+/i,
/zootechnician|warehouse_keeper|recipient/i,
/Content-Disposition/i,
/latin-1/i,
/ordinal not in range/i,
/GET\s+\/api\//i,
/sync-diagnostics/i,
/\bfail\b$/i,
/chat fail|ping fail|summary fail|diagnostics fail|requeue fail|restart fail|runtime fail|push fail/i,
/status fail/i,
/no response/i,
];
function plainText(message) {
if (message instanceof Error) return "";
if (message && typeof message === "object") {
if (Array.isArray(message.parts)) {
return message.parts.map((p) => (p && p.text != null ? String(p.text) : "")).join("");
}
if (message.text != null) return String(message.text);
}
return message == null ? "" : String(message);
}
function isTechnicalMessage(message) {
const text = plainText(message).trim();
if (!text) return true;
return TECHNICAL_MESSAGE_PATTERNS.some((pattern) => pattern.test(text));
}
function userFacingMessage(message, fallback) {
const fb = (fallback && String(fallback).trim()) || DEFAULT_ERROR_MESSAGE;
if (message instanceof Error) return fb;
const text = plainText(message).trim();
if (!text || isTechnicalMessage(text)) return fb;
return text;
}
function messageFromResponseBody(body, fallback) {
if (!body || typeof body !== "object") {
return userFacingMessage(null, fallback);
}
return userFacingMessage(body.message || body.error || null, fallback);
}
function safe(message, fallback) {
return userFacingMessage(message, fallback);
}
global.WespUserMessages = {
DEFAULT_ERROR_MESSAGE,
plainText,
isTechnicalMessage,
userFacingMessage,
messageFromResponseBody,
safe,
};
})(typeof window !== "undefined" ? window : globalThis);
@@ -96,14 +96,31 @@
const mobileMenuOverlay = document.getElementById("mobileMenuOverlay");
const closeMenu = document.getElementById("closeMenu");
if (!hamburgerMenu || !mobileMenuOverlay) return;
if (mobileMenuOverlay.dataset.menuWired === "1") return;
mobileMenuOverlay.dataset.menuWired = "1";
if (mobileMenuOverlay.parentElement !== document.body) {
document.body.appendChild(mobileMenuOverlay);
}
const syncMobileUserInfo = () => {
const userInfo = document.getElementById("userInfo");
const mobileUserInfo = document.getElementById("mobileUserInfo");
if (userInfo && mobileUserInfo) {
mobileUserInfo.textContent = userInfo.textContent?.trim() || "";
}
};
const open = () => {
syncMobileUserInfo();
mobileMenuOverlay.classList.add("active");
hamburgerMenu.classList.add("active");
document.body.classList.add("mobile-menu-open");
};
const close = () => {
mobileMenuOverlay.classList.remove("active");
hamburgerMenu.classList.remove("active");
document.body.classList.remove("mobile-menu-open");
};
hamburgerMenu.addEventListener("click", open);
@@ -111,6 +128,14 @@
mobileMenuOverlay.addEventListener("click", (e) => {
if (e.target === mobileMenuOverlay) close();
});
document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && mobileMenuOverlay.classList.contains("active")) close();
});
document.querySelectorAll(".mobile-menu-item").forEach((item) => {
item.addEventListener("click", () => setTimeout(close, 300));
});
window.WespZootechNavCloseMobileMenu = close;
}
async function fetchCanLab() {
@@ -15,6 +15,31 @@
let hubEl = null;
let planMountEl = null;
let multiserverMountEl = null;
let orchContextLoaded = false;
async function ensureOrchContext() {
if (orchContextLoaded || global.__WESP_ORCH__?.enterpriseId) {
orchContextLoaded = true;
return;
}
try {
const resp = await fetch("/api/v1/orchestrator/context");
if (resp.ok) {
const data = await resp.json();
global.__WESP_ORCH__ = Object.assign(global.__WESP_ORCH__ || {}, {
enterpriseId: data.enterpriseId || "",
configured: !!data.configured,
reachable: !!data.reachable,
upstream: data.upstream || "",
hubSiteId: data.hubSiteId || "",
});
}
} catch (_err) {
/* hub may run without orchestrator */
} finally {
orchContextLoaded = true;
}
}
let titleEl = null;
let readAllBtn = null;
let backBtn = null;
@@ -789,7 +814,7 @@
showModal();
}
function openMultiserver() {
async function openMultiserver() {
ensureModal();
updateHeader("multiserver");
hubEl.hidden = true;
@@ -797,6 +822,7 @@
planMountEl.hidden = true;
multiserverMountEl.hidden = false;
destroyDailyPlanPanel();
await ensureOrchContext();
const enterpriseId = global.__WESP_ORCH__?.enterpriseId || "";
if (global.WespMultiserverPanel?.mount) {
global.WespMultiserverPanel.mount(multiserverMountEl, enterpriseId);
File diff suppressed because one or more lines are too long
+54
View File
@@ -0,0 +1,54 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Вход</title>
<script src="/static/js/wesp-theme-boot.js"></script>
<link href="/static/css/wesp-zootech-layout.css?v=16" rel="stylesheet"> <link href="/static/css/inter-fonts.css" rel="stylesheet">
<link rel="stylesheet" href="/static/css/zootechnician-login.css">
<link rel="stylesheet" href="/static/css/wesp-logo-loader.css">
<link href="/static/css/wesp-zootech-theme-organic.css?v=6" rel="stylesheet">
</head>
<body class="zootech-login-body">
<div class="z-login-page">
<div class="z-login-card">
<div class="z-login-form-panel">
<h1 class="z-login-title">Вход</h1>
<div id="errorMessage" class="z-login-msg z-login-msg--error" role="alert"></div>
<form class="z-login-form" id="loginForm">
<div class="z-login-field z-login-field--with-icon">
<span class="z-login-field-icon" aria-hidden="true">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
</span>
<input type="text" id="loginInput" class="z-login-input" placeholder="Логин"
autocomplete="username" required aria-label="Логин">
</div>
<div class="z-login-field z-login-field--with-icon">
<span class="z-login-field-icon" aria-hidden="true">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
</span>
<input type="password" id="passwordInput" class="z-login-input" placeholder="Пароль"
autocomplete="current-password" required aria-label="Пароль">
</div>
<button type="submit" id="authButton" class="z-login-submit">
<span id="buttonText">Войти</span>
<div id="loadingSpinner" class="z-login-spinner" aria-hidden="true"></div>
</button>
<label class="z-login-remember">
<input type="checkbox" id="rememberInput" class="z-login-switch-input">
<span class="z-login-switch-ui" aria-hidden="true"></span>
<span class="z-login-remember-text">Запомнить вход</span>
</label>
</form>
</div>
</div>
</div>
<script type="module" src="/static/js/pages/login-page.js?v=4"></script>
</body>
</html>
@@ -0,0 +1,44 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Комптон — demo загрузки</title>
<link rel="stylesheet" href="/static/css/wesp-logo-loader.css">
</head>
<body class="wesp-logo-loader-demo">
<p class="wesp-logo-loader-demo__hint">Серые пиксели → синие → сборка · прогресс по фазам</p>
<div class="wesp-logo-loader-demo__panel">
<button type="button" class="wesp-logo-loader-demo__btn" id="replayBtn">Показать снова</button>
<button type="button" class="wesp-logo-loader-demo__btn" id="hideBtn">Скрыть (exit-анимация)</button>
</div>
<script type="module">
import { showWespLogoLoader } from "/static/js/wesp-logo-loader.js";
let dispose = null;
async function play() {
if (dispose) {
dispose();
dispose = null;
}
dispose = await showWespLogoLoader({ id: "wespBootLoaderDemo" });
}
document.getElementById("replayBtn").addEventListener("click", () => {
play();
});
document.getElementById("hideBtn").addEventListener("click", () => {
if (dispose) {
dispose();
dispose = null;
}
});
play();
</script>
</body>
</html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 612 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

+4 -3
View File
@@ -13,7 +13,7 @@
<script>document.documentElement.classList.add('wesp-page-enter-pending');</script>
<script src="/static/js/wesp-page-enter.js?v=2"></script>
<script src="/static/js/wesp-nav-logo-shimmer.js?v=2"></script>
<script src="/static/js/wesp-zootech-nav.js?v=5"></script>
<script src="/static/js/wesp-zootech-nav.js?v=6"></script>
<style>
html { background: #f5f7fa; }
@@ -24,12 +24,12 @@
<link href="/static/css/wesp-zootech-shell.css?v=3" rel="stylesheet">
<link href="/static/css/wesp-feed-quality-settings.css?v=3" rel="stylesheet">
<link href="/static/css/inter-fonts.css" rel="stylesheet">
<link href="/static/css/wesp-zootech-layout.css?v=16" rel="stylesheet"> <link href="/static/css/wesp-zootech-components.css?v=8" rel="stylesheet">
<link href="/static/css/wesp-zootech-layout.css?v=17" rel="stylesheet"> <link href="/static/css/wesp-zootech-components.css?v=8" rel="stylesheet">
<link href="/static/css/wesp-zootech-shell-modals.css?v=4" rel="stylesheet">
<link href="/static/css/wesp-zootech-k-hub.css?v=28" rel="stylesheet">
<link href="/static/css/wesp-recipes-skeleton.css" rel="stylesheet">
<link href="/static/css/wesp-logo-loader.css" rel="stylesheet">
<link href="/static/css/wesp-recipes-mobile.css?v=15" rel="stylesheet">
<link href="/static/css/wesp-recipes-mobile.css?v=17" rel="stylesheet">
<link href="/static/css/wesp-recipes-editor.css?v=12" rel="stylesheet">
<link href="/static/css/font-awesome.min.css" rel="stylesheet">
<script src="/static/js/wesp-zootech-notification-messages.js?v=1"></script>
@@ -2451,6 +2451,7 @@
if (show) {
recipeEdit.style.display = 'block';
if (isMobile) {
window.WespZootechNavCloseMobileMenu?.();
document.body.classList.add('recipe-edit-mobile-open');
recipeEdit.classList.add('recipe-edit--mobile-active');
portalRecipeEditForMobile(recipeEdit);
File diff suppressed because it is too large Load Diff
+5 -5
View File
@@ -25,7 +25,7 @@
<link href="/static/css/font-awesome.min.css" rel="stylesheet">
<!-- Добавляем библиотеку SheetJS -->
<script src="/static/js/xlsx.full.min.js"></script>
<link href="/static/css/wesp-reports-page.css?v=17" rel="stylesheet">
<link href="/static/css/wesp-reports-page.css?v=20" rel="stylesheet">
<link href="/static/css/wesp-feed-quality-settings.css?v=3" rel="stylesheet">
<link href="/static/css/wesp-zootech-pages-polish.css?v=9" rel="stylesheet">
<style>
@@ -365,12 +365,12 @@
<script src="/static/js/pages/daily-plan-component-norms-modal.js?v=23"></script>
<script src="/static/js/wesp-zootech-notification-center.js?v=13"></script>
<script src="/static/js/pages/analytics-copy.js?v=1"></script>
<script src="/static/js/pages/reports-filters.js?v=1"></script>
<script src="/static/js/pages/reports-date-range.js?v=4"></script>
<script src="/static/js/pages/reports-filters.js?v=2"></script>
<script src="/static/js/pages/reports-date-range.js?v=7"></script>
<script src="/static/js/pages/analytics-summary.js?v=4"></script>
<script src="/static/js/pages/analytics-plan-fact.js?v=5"></script>
<script src="/static/js/pages/reports-filter-dropdown.js?v=1"></script>
<script src="/static/js/pages/reports-export.js?v=1"></script>
<script src="/static/js/pages/reports-filter-dropdown.js?v=3"></script>
<script src="/static/js/pages/reports-export.js?v=3"></script>
<script src="/static/js/pages/feed-alerts.js?v=11"></script>
<script>
window.reportsDispenserIdByName = window.reportsDispenserIdByName || {};
+146
View File
@@ -0,0 +1,146 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Выбор роли</title>
<link href="/static/css/inter-fonts.css" rel="stylesheet">
<script src="/static/js/wesp-theme-boot.js"></script>
<link href="/static/css/wesp-zootech-layout.css?v=16" rel="stylesheet"> <link href="/static/css/wesp-zootech-components.css" rel="stylesheet">
<style>
body.role-selection-body {
margin: 0;
min-height: 100vh;
display: flex;
flex-direction: column;
font-family: var(--zootech-font);
background: var(--bg-page);
color: var(--text-dark);
}
.role-selection-header {
background: var(--gradient-blue);
color: #fff;
padding: 1.25rem 1rem 1.5rem;
text-align: center;
box-shadow: var(--box-shadow-hover);
}
.role-selection-header .logo {
height: 72px;
width: auto;
filter: brightness(0) invert(1);
}
.role-selection-main {
flex: 1;
display: flex;
justify-content: center;
align-items: center;
padding: clamp(1.25rem, 4vw, 2rem);
}
.role-selection-grid {
display: flex;
gap: 1.5rem;
max-width: 480px;
width: 100%;
justify-content: center;
}
.role-card {
flex: 1;
background: var(--bg-white);
border-radius: var(--border-radius);
padding: 1.75rem 1.5rem;
text-align: center;
cursor: pointer;
transition: box-shadow var(--transition), border-color var(--transition);
box-shadow: var(--shadow-soft);
border: 1px solid var(--border-light);
}
.role-card:hover {
box-shadow: var(--shadow-medium);
}
.role-icon {
width: 96px;
height: 96px;
margin: 0 auto 1.25rem;
background: var(--bg-light);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 2.5rem;
}
.role-title {
font-size: 1.35rem;
color: var(--primary-color);
margin: 0 0 0.75rem;
font-weight: 700;
letter-spacing: -0.02em;
}
.role-description {
color: var(--text-muted);
margin: 0 0 1.25rem;
line-height: 1.55;
font-size: 0.9375rem;
}
.role-button {
background: var(--gradient-blue);
color: #fff;
border: none;
padding: 0.85rem 1.25rem;
border-radius: 10px;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: filter var(--transition), transform var(--transition);
width: 100%;
}
.role-button:hover {
filter: brightness(1.05);
transform: translateY(-1px);
}
@media (max-width: 768px) {
.role-selection-header .logo {
height: 48px;
}
}
</style>
<link href="/static/css/wesp-zootech-theme-organic.css?v=6" rel="stylesheet">
</head>
<body class="role-selection-body">
<header class="role-selection-header">
<a href="/recipes" class="nav-logo-link" aria-label="Рецепты">
<img src="/static/logo.png" alt="Комптон" class="logo">
</a>
</header>
<main class="role-selection-main">
<div class="role-selection-grid">
<div class="role-card" onclick="selectRole('zootechnician')">
<div class="role-icon" aria-hidden="true">📋</div>
<h2 class="role-title">Зоотехник</h2>
<p class="role-description">Управление рецептами и контроль кормления</p>
<button type="button" class="role-button">Войти как зоотехник</button>
</div>
</div>
</main>
<script>
function selectRole(role) {
if (role === 'zootechnician') {
window.location.href = '/recipes';
}
}
</script>
</body>
</html>
+26
View File
@@ -0,0 +1,26 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<meta name="theme-color" content="#0f1114">
<script>
(function () {
try {
var saved = localStorage.getItem("theme");
var theme = saved === "light" || saved === "dark" ? saved : "dark";
document.documentElement.setAttribute("data-theme", theme);
} catch (e) {}
})();
</script>
<title>Комптон — первоначальная настройка</title>
<link rel="stylesheet" href="/static/css/wesp-logo-loader.css">
<link rel="stylesheet" href="/static/css/wesp-setup.css">
</head>
<body>
<div id="wespSetupRoot" class="wesp-setup"></div>
<script src="/static/js/kiosk-calibration.js"></script>
<script src="/static/js/wesp-user-messages.js?v=1"></script>
<script type="module" src="/static/js/pages/setup-wizard.js"></script>
</body>
</html>
+219
View File
@@ -0,0 +1,219 @@
<!DOCTYPE html>
<html lang="ru" translate="no" class="notranslate" data-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="google" content="notranslate">
<meta name="googlebot" content="notranslate">
<meta http-equiv="Content-Language" content="ru">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<link rel="icon" href="/static/favicon.svg" type="image/svg+xml">
<link rel="icon" href="/static/favicon-32.png" type="image/png" sizes="32x32">
<link rel="apple-touch-icon" href="/static/apple-touch-icon.png">
<title>WESP — загрузка</title>
<link rel="stylesheet" href="/static/css/wesp-logo-loader.css">
<link rel="stylesheet" href="/static/css/wesp-setup.css">
<link rel="stylesheet" href="/static/css/wesp-startup.css">
<link rel="modulepreload" href="/static/js/wesp-logo-loader.js?v=startup11s">
</head>
<body class="wesp-startup-screen">
<div class="wesp-startup__stage">
<div class="wesp-setup__intro" id="startupIntro" aria-hidden="false"></div>
<p class="wesp-startup__quip" id="startupQuip" aria-live="polite"></p>
</div>
<noscript>
<p class="wesp-startup__quip wesp-startup__quip--error">
Ошибка: нужен JavaScript для экрана загрузки.
</p>
</noscript>
<script type="module">
import { mountWespLogoLoader } from "/static/js/wesp-logo-loader.js?v=startup11s";
const quipEl = document.getElementById("startupQuip");
const introEl = document.getElementById("startupIntro");
const params = new URLSearchParams(window.location.search);
let defaultReturn = "/login";
const rawNext = params.get("next");
let returnTo = defaultReturn;
if (rawNext && String(rawNext).trim().startsWith("/")) {
returnTo = String(rawNext).trim();
} else {
const stored = sessionStorage.getItem("wespStartupReturn");
if (stored && String(stored).trim().startsWith("/") && stored !== "/") {
returnTo = String(stored).trim();
}
}
sessionStorage.setItem("wespStartupReturn", returnTo);
const theme = (params.get("theme") || "dark").trim().toLowerCase();
if (theme === "light" || theme === "dark" || theme === "organic") {
document.documentElement.setAttribute("data-theme", theme);
}
const QUIPS = [
"Сортируем гранулы по размеру мысли…",
"Калибруем весы — не дышите на тару",
"Синхронизируем рецепты с ближайшей галактикой",
"Ищем соседей по сети — здравствуйте, VLAN",
"Считаем компоненты рациона (почти честно)",
"Проверяем: server и client — не один Pi",
"Чайник почти вскипел, осталось чуть-чуть",
"Подтягиваем склад: где мука, там и отчёты",
"Собираем пиксели логотипа в боевой порядок",
"Готовим весы к первому «ну почти ноль»",
"Дозатор №3 просит ещё пять грамм терпения",
"Зоотехник ещё не проснулся — мы уже работаем",
"Перемалываем корм в биты и обратно",
"Сверяем тару с совестью",
"Логотип синеет — это не баг, это бренд",
"Кормление через пять… четыре… ну почти",
"mDNS: «есть кто живой?» — ждём ответа",
"Весы молчат — значит, всё стабильно",
"Рецепт «на глаз» конвертируем в граммы",
"Проверяем, что ноль — действительно ноль",
"Комптон заряжает пиксели…",
];
const READY_QUIP = "Готово — открываем двери";
const QUIP_ROTATE_MS = 1500;
const STARTUP_MIN_MS = 10000;
/** Скорость волн/shimmer — как раньше (не растягивать на 10 с). */
const STARTUP_LOGO_ANIM_MS = 6000;
const READY_HOLD_MS = 700;
function shuffleQuips(list) {
const deck = list.slice();
for (let i = deck.length - 1; i > 0; i -= 1) {
const j = Math.floor(Math.random() * (i + 1));
[deck[i], deck[j]] = [deck[j], deck[i]];
}
return deck;
}
let quipDeck = shuffleQuips(QUIPS);
let deckPos = 0;
let quipFadeSeq = 0;
let quipRotationEnabled = true;
let pollTimer = null;
let quipTimer = null;
let serverReady = false;
let finishStarted = false;
const minLeaveAt = performance.now() + STARTUP_MIN_MS;
function pickQuip() {
if (deckPos >= quipDeck.length) {
quipDeck = shuffleQuips(QUIPS);
deckPos = 0;
}
return quipDeck[deckPos++];
}
function setQuip(text, isError = false) {
if (!quipEl) return;
const next = String(text || "").trim();
if (!next) return;
const seq = ++quipFadeSeq;
quipEl.classList.add("wesp-startup__quip--fade");
quipEl.classList.toggle("wesp-startup__quip--error", Boolean(isError));
window.setTimeout(() => {
if (seq !== quipFadeSeq) return;
quipEl.textContent = next;
quipEl.classList.remove("wesp-startup__quip--fade");
}, 200);
}
function stopQuipRotation() {
quipRotationEnabled = false;
if (quipTimer) {
clearInterval(quipTimer);
quipTimer = null;
}
}
function rotateQuip() {
if (!quipRotationEnabled || document.hidden || finishStarted) return;
setQuip(pickQuip());
}
function tryFinish() {
if (finishStarted) return;
if (!serverReady) return;
if (performance.now() < minLeaveAt) return;
finishStarted = true;
stopQuipRotation();
if (pollTimer) {
clearInterval(pollTimer);
pollTimer = null;
}
setQuip(READY_QUIP);
const target = returnTo.startsWith("/") ? returnTo : defaultReturn;
window.setTimeout(() => window.location.replace(target), READY_HOLD_MS);
}
async function pollReady() {
if (finishStarted) return;
try {
const r = await fetch("/api/startup/status", { cache: "no-store" });
const d = await r.json().catch(() => ({}));
if (d.default_next && String(d.default_next).trim().startsWith("/")) {
defaultReturn = String(d.default_next).trim();
if (!rawNext) {
returnTo = defaultReturn;
sessionStorage.setItem("wespStartupReturn", returnTo);
}
}
if (d.ready) {
serverReady = true;
tryFinish();
return;
}
if (d.phase === "error" || d.error) {
finishStarted = true;
stopQuipRotation();
if (pollTimer) clearInterval(pollTimer);
setQuip(d.error || d.message || "Ошибка инициализации", true);
return;
}
} catch {
if (quipRotationEnabled && !quipEl.textContent) {
setQuip("Ожидание сервера…");
}
}
}
try {
await mountWespLogoLoader(introEl, {
loop: false,
durationMs: STARTUP_LOGO_ANIM_MS,
embedded: true,
transparentBackground: true,
knockoutBackground: true,
theme: document.documentElement.getAttribute("data-theme") || theme,
hideChrome: false,
});
} catch (err) {
console.error("startup logo loader failed", err);
finishStarted = true;
stopQuipRotation();
if (pollTimer) clearInterval(pollTimer);
setQuip(
"Не удалось загрузить анимацию. Проверьте /static/js/wesp-logo-loader.js и логи сервера.",
true,
);
throw err;
}
if (quipEl) quipEl.classList.remove("wesp-startup__quip--error");
setQuip(pickQuip());
quipTimer = window.setInterval(rotateQuip, QUIP_ROTATE_MS);
pollReady();
pollTimer = window.setInterval(pollReady, 800);
window.setTimeout(tryFinish, STARTUP_MIN_MS);
</script>
</body>
</html>
Binary file not shown.
+80
View File
@@ -0,0 +1,80 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="utf-8">
<title>Неавторизованное устройство</title>
<style>
body { font-family: Arial, sans-serif; background:#111; color:#fff; display:flex; align-items:center; justify-content:center; min-height:100vh; margin:0; }
.card { text-align:center; background:#1f1f1f; padding:28px; border-radius:12px; box-shadow:0 0 20px rgba(0,0,0,0.4); max-width:560px; width:92%; }
h1 { margin-bottom:16px; font-size:26px; }
p { margin-bottom:18px; line-height:1.5; }
.btn { display:inline-block; margin-top:8px; padding:10px 16px; border-radius:8px; border:1px solid #4ea8ff; color:#fff; background:#1b66a8; cursor:pointer; font-size:15px; }
.btn:disabled { opacity:0.6; cursor:not-allowed; }
.hint { font-size:14px; color:#d1d1d1; margin-top:16px; text-align:left; line-height:1.5; }
.hint ol { margin:10px 0 0 0; padding-left:22px; }
.hint li { margin-bottom:8px; }
.status { margin-top:14px; min-height:22px; font-size:14px; color:#d6e8ff; }
</style>
</head>
<body>
<div class="card">
<h1>Неавторизованное устройство</h1>
<p>Этот планшет ещё не настроен для работы с весами.</p>
<div class="hint">
<strong>Что делать:</strong>
<ol>
<li>На весах нажмите "Привязать терминал".</li>
<li>Отсканируйте QR-код на экране.</li>
<li>Вставте полученную ссылку в поле <strong>Fully Kiosk Browser</strong><strong>Settings</strong><strong>Web Content Settings</strong><strong>Start URL</strong>.</li>
<li>Перезапустите Fully Kiosk Browser.</li>
</ol>
<p style="margin:12px 0 0 0;font-size:13px;color:#a8a8a8">
Ограничение доступа сделано для безопасности фермы. Вы можете получить доступ только с помощью QR-кода.
</p>
</div>
<button class="btn" id="checkBtn" type="button">Проверить снова</button>
<div class="status" id="statusText"></div>
</div>
<script>
(function () {
var checkBtn = document.getElementById("checkBtn");
var statusText = document.getElementById("statusText");
var nextParam = new URLSearchParams(window.location.search).get("next") || "/scales";
function setStatus(text, isError) {
statusText.textContent = text;
statusText.style.color = isError ? "#ff9f9f" : "#d6e8ff";
}
async function checkPairedStatus() {
checkBtn.disabled = true;
setStatus("Проверяем…");
try {
var response = await fetch("/api/kiosk/status");
var payload = await response.json().catch(function () { return {}; });
if (!response.ok) {
setStatus("Не удалось проверить. Попробуйте ещё раз.", true);
return false;
}
if (!payload.paired && payload.enforce_paired_only !== false) {
setStatus("Терминал пока не настроен. Дождитесь администратора.", true);
return false;
}
setStatus("Готово. Открываем экран весов…");
window.setTimeout(function () {
window.location.href = nextParam;
}, 600);
return true;
} catch (_e) {
setStatus("Нет связи с сервером. Проверьте Wi‑Fi фермы.", true);
return false;
} finally {
checkBtn.disabled = false;
}
}
checkBtn.addEventListener("click", checkPairedStatus);
})();
</script>
</body>
</html>
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.