Files
2026-07-17 12:57:18 +03:00

1024 lines
42 KiB
HTML

<!DOCTYPE html>
<html lang="ru">
<head>
<script>
(function () {
try {
var t = (localStorage.getItem("theme") || "").trim().toLowerCase();
document.documentElement.setAttribute("data-theme", t === "light" ? "light" : "dark");
} catch (e) {}
})();
</script>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Проверка баз данных</title>
<link rel="stylesheet" href="/static/css/inter-fonts.css">
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
:root {
--primary: #2563eb;
--primary-hover: #1d4ed8;
--success: #10b981;
--warning: #f59e0b;
--danger: #ef4444;
--info: #0ea5e9;
--gray-50: #f9fafb;
--gray-100: #f3f4f6;
--gray-200: #e5e7eb;
--gray-600: #4b5563;
--gray-900: #111827;
--white: #ffffff;
--border: var(--gray-200);
}
body {
font-family: 'Inter', system-ui, sans-serif;
background: var(--gray-50);
min-height: 100vh;
color: var(--gray-900);
padding: 20px 20px 48px;
line-height: 1.5;
}
.nav-button {
position: fixed;
top: 16px;
right: 16px;
background: transparent;
border: none;
padding: 0;
width: 56px;
height: 56px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
z-index: 1000;
transition: transform 0.2s ease;
}
.nav-button:hover {
transform: scale(1.06);
}
.nav-button img {
width: 44px;
height: 44px;
}
.page-header {
text-align: center;
margin-bottom: 28px;
padding-top: 8px;
}
.main-title {
font-size: 1.5rem;
font-weight: 600;
color: var(--gray-900);
margin-bottom: 8px;
}
.subtitle {
color: var(--gray-600);
font-size: 0.95rem;
}
.container {
max-width: 960px;
margin: 0 auto;
}
.card {
background: var(--white);
border-radius: 8px;
padding: 20px;
margin-bottom: 16px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
border: 1px solid var(--gray-200);
}
.card-header {
margin-bottom: 16px;
padding-bottom: 12px;
border-bottom: 1px solid var(--gray-200);
}
.card-title {
font-size: 1.125rem;
font-weight: 600;
color: var(--gray-900);
margin: 0;
}
.card-header-with-action {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px 16px;
flex-wrap: wrap;
}
.card-header-with-action .card-title {
flex: 1 1 auto;
min-width: 0;
}
.card-header-with-action #check-db-btn {
min-width: 0;
flex-shrink: 0;
white-space: nowrap;
}
.btn {
padding: 12px 22px;
border-radius: 6px;
border: none;
font-size: 0.95rem;
font-weight: 500;
cursor: pointer;
transition: background 0.2s, box-shadow 0.2s, transform 0.2s;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
min-width: 200px;
text-decoration: none;
}
.btn-primary {
background: var(--primary);
color: var(--white);
}
.btn-primary:hover:not(:disabled) {
background: var(--primary-hover);
box-shadow: 0 4px 12px rgba(37, 99, 235, 0.25);
}
.btn-success {
background: var(--success);
color: var(--white);
}
.btn-success:hover:not(:disabled) {
filter: brightness(0.95);
box-shadow: 0 4px 12px rgba(16, 185, 129, 0.3);
}
.btn-danger {
background: var(--danger);
color: var(--white);
}
.btn-danger:hover:not(:disabled) {
filter: brightness(0.95);
box-shadow: 0 4px 12px rgba(239, 68, 68, 0.25);
}
.btn-info {
background: var(--info);
color: var(--white);
}
.btn-info:hover:not(:disabled) {
filter: brightness(0.95);
}
.btn:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none !important;
box-shadow: none !important;
}
.action-buttons {
display: flex;
gap: 12px;
margin-top: 16px;
flex-wrap: wrap;
}
.alert {
padding: 16px 18px;
border-radius: 8px;
margin: 16px 0;
border-left: 4px solid;
background: var(--white);
border: 1px solid var(--gray-200);
}
.alert-success {
border-left-color: var(--success);
background: #ecfdf5;
color: #065f46;
}
.alert-warning {
border-left-color: var(--warning);
background: #fffbeb;
color: #92400e;
}
.alert-danger {
border-left-color: var(--danger);
background: #fef2f2;
color: #991b1b;
}
.alert-info {
border-left-color: var(--info);
background: #f0f9ff;
color: #0c4a6e;
}
.alert h6 {
font-size: 1rem;
margin-bottom: 10px;
font-weight: 600;
}
.alert p {
margin-bottom: 12px;
}
.alert p:last-child {
margin-bottom: 0;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 12px;
margin-top: 8px;
}
.stat-card {
background: var(--gray-50);
padding: 16px;
border-radius: 8px;
text-align: center;
border: 1px solid var(--gray-200);
}
.stat-value {
font-size: 1.75rem;
font-weight: 700;
color: var(--primary);
line-height: 1.2;
margin-bottom: 6px;
}
.stat-label {
color: var(--gray-600);
font-size: 0.875rem;
}
.loading-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.55);
display: none;
align-items: center;
justify-content: center;
z-index: 2000;
}
.loading-content {
background: var(--white);
padding: 28px;
border-radius: 8px;
text-align: center;
max-width: 340px;
width: 90%;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
border: 1px solid var(--gray-200);
}
.loader {
width: 44px;
height: 44px;
border: 3px solid var(--gray-200);
border-top-color: var(--primary);
border-radius: 50%;
animation: spin 0.9s linear infinite;
margin: 0 auto 16px;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.db-details {
margin: 12px 0;
max-height: 400px;
overflow-y: auto;
padding-right: 8px;
}
.table-section {
background: var(--gray-50);
border-radius: 8px;
padding: 14px 16px;
margin-bottom: 12px;
border: 1px solid var(--gray-200);
border-left: 3px solid var(--primary);
}
.table-name {
font-size: 1rem;
font-weight: 600;
margin-bottom: 10px;
color: var(--gray-900);
}
.record-list {
list-style: none;
margin: 8px 0 0 0;
}
.record-list li {
padding: 10px 12px;
background: var(--white);
border-radius: 6px;
margin-bottom: 8px;
font-size: 0.95rem;
border: 1px solid var(--gray-200);
line-height: 1.45;
}
.label-add {
color: #047857;
font-weight: 500;
margin: 10px 0 6px 0;
}
.label-remove {
color: #b91c1c;
font-weight: 500;
margin: 10px 0 6px 0;
}
.section-heading {
margin: 14px 0 8px 0;
font-weight: 600;
font-size: 0.95rem;
}
.mt-2 { margin-top: 16px; }
.text-muted { color: var(--gray-600); }
.text-center { text-align: center; }
html[data-theme="dark"] body {
background: #1a1a1a !important;
color: #e6e6e6 !important;
}
html[data-theme="dark"] .main-title,
html[data-theme="dark"] .card-title,
html[data-theme="dark"] .table-name {
color: #f3f4f6 !important;
}
html[data-theme="dark"] .subtitle {
color: #9ca3af !important;
}
html[data-theme="dark"] .card,
html[data-theme="dark"] .loading-content,
html[data-theme="dark"] .stat-card {
background: #242424 !important;
border-color: #3a3a3a !important;
color: #e6e6e6 !important;
}
html[data-theme="dark"] .card-header {
border-bottom-color: #3a3a3a !important;
}
html[data-theme="dark"] .alert {
background: #2d2d2d !important;
border-color: #3a3a3a !important;
}
html[data-theme="dark"] .alert-success {
color: #a7f3d0 !important;
border-left-color: var(--success) !important;
}
html[data-theme="dark"] .alert-warning {
color: #fde68a !important;
border-left-color: var(--warning) !important;
}
html[data-theme="dark"] .alert-danger {
color: #fecaca !important;
border-left-color: var(--danger) !important;
}
html[data-theme="dark"] .alert-info {
color: #bae6fd !important;
border-left-color: var(--info) !important;
}
html[data-theme="dark"] .table-section {
background: #1f1f1f !important;
border-color: #3a3a3a !important;
}
html[data-theme="dark"] .record-list li {
background: #2d2d2d !important;
border-color: #3a3a3a !important;
}
html[data-theme="dark"] .stat-value {
color: #60a5fa !important;
}
html[data-theme="dark"] .stat-label,
html[data-theme="dark"] .text-muted {
color: #9ca3af !important;
}
@media (max-width: 768px) {
body { padding: 16px 14px 40px; }
.main-title { font-size: 1.25rem; }
.btn { width: 100%; min-width: unset; }
.action-buttons { flex-direction: column; }
.db-details { max-height: 280px; }
}
body.db-check-embed {
padding: 12px 14px 20px;
min-height: auto;
}
body.db-check-embed .page-header {
display: none;
}
</style>
</head>
<body{% if embed %} class="db-check-embed"{% endif %}>
{% if not embed %}
<button type="button" class="nav-button" id="kioskMenuButton" title="Меню" aria-label="Меню">
<img src="/static/menu.png" alt="">
</button>
{% endif %}
<div class="page-header">
<h1 class="main-title">Проверка баз данных</h1>
<p class="subtitle">Сравнение состояния клиента с сервером и восстановление</p>
</div>
<div class="container">
<div class="card">
<div class="card-header card-header-with-action">
<h2 class="card-title">Проверка состояния</h2>
<button type="button" class="btn btn-primary" onclick="checkDatabase()" id="check-db-btn">
Запустить проверку
</button>
</div>
<div class="card-body">
<div id="db-check-result"></div>
</div>
</div>
<div class="card" id="stats-card" style="display: none;">
<div class="card-header">
<h2 class="card-title">Статистика</h2>
</div>
<div class="stats-grid" id="db-stats"></div>
</div>
</div>
<div class="loading-overlay" id="loadingOverlay" aria-hidden="true">
<div class="loading-content">
<div class="loader" role="status" aria-label="Загрузка"></div>
<p style="font-weight: 500;">Выполняется проверка…</p>
</div>
</div>
<script src="/static/js/kiosk-dialog.js"></script>
<script>
let kioskPaired = false;
const DEVICE_TOKEN_KEY = 'wespDeviceToken';
function getDeviceToken() {
const params = new URLSearchParams(window.location.search);
const tokenFromQuery = (params.get('device_token') || '').trim();
if (tokenFromQuery) {
localStorage.setItem(DEVICE_TOKEN_KEY, tokenFromQuery);
return tokenFromQuery;
}
return (localStorage.getItem(DEVICE_TOKEN_KEY) || '').trim();
}
function buildHeaders(extra = {}) {
const headers = { ...extra };
const token = getDeviceToken();
if (token) headers['X-Device-Token'] = token;
return headers;
}
/** Текст ошибки из JSON ответа API (и legacy-полей). */
function apiErrorPayload(data) {
if (!data || typeof data !== 'object') return '';
if (typeof data.message === 'string' && data.message.trim()) return data.message.trim();
if (typeof data.error === 'string' && data.error.trim()) return data.error.trim();
return '';
}
/**
* Понятное объяснение для оператора на ферме: без жаргона, без интернета,
* синхронизация только по локальной Wi‑Fi сети.
*/
function humanizeFarmDbError(context, rawText, httpStatus) {
const t = (rawText || '').trim();
const low = t.toLowerCase();
let st = httpStatus || 0;
if (!st) {
const m = /^HTTP\s+(\d{3})\b/i.exec(t);
if (m) st = parseInt(m[1], 10);
}
if (st === 401 || st === 403 || low.includes('не привязан') || low.includes('привязк')) {
return 'Терминал не подключён к серверу фермы или связь устарела.\n\n'
+ 'Откройте меню (кнопка в углу экрана) и выполните привязку терминала по инструкции. '
+ 'Интернет не нужен — достаточно, чтобы планшет был в той же Wi‑Fi сети, что и сервер.';
}
if (
(st === 404 && (low.includes('клиент') || low.includes('client')))
|| (low.includes('не найден') && (low.includes('клиент') || low.includes('client')))
) {
return 'Сервер не находит этот терминал в своём списке.\n\n'
+ 'Проверьте, что планшет в зоне Wi‑Fi фермы и привязка в меню выполнена. '
+ 'Если недавно меняли сервер или оборудование — сообщите администратору.';
}
if (st === 404) {
return 'Нужная служба на сервере не найдена.\n\nОбратитесь к администратору.';
}
if (st === 400) {
if (low.includes('client_id') || low.includes('обязателен')) {
return 'Запрос до сервера дошёл не полностью (не хватает данных терминала).\n\n'
+ 'Обновите страницу и попробуйте снова.';
}
return 'Сервер не принял запрос.\n\nОбновите страницу и повторите. Если снова ошибка — сообщите администратору.';
}
if (st === 429 || low.includes('слишком много') || low.includes('concurrent') || low.includes('слот')) {
return 'Сервер сейчас занят другими запросами синхронизации.\n\nПодождите минуту и нажмите проверку снова.';
}
if (st === 501 || (low.includes('отключен') && low.includes('операц')) || low.includes('restore-db')) {
return 'Это действие в текущей версии программы недоступно с этого экрана.\n\n'
+ 'Обратитесь к администратору — он подскажет другой способ восстановления данных.';
}
if (st >= 500 || low.includes('internal server')) {
return 'На сервере произошла внутренняя ошибка.\n\n'
+ 'Подождите немного и повторите. Если не поможет — перезагрузите терминал или компьютер сервера. '
+ 'Доступ в интернет не требуется.';
}
if (low.includes('failed to fetch') || low.includes('load failed') || low.includes('networkerror')
|| t === 'Failed to fetch') {
return 'Не удаётся связаться с сервером фермы.\n\n'
+ 'Проверьте: планшет в зоне Wi‑Fi роутера, роутер и сервер включены. '
+ 'Интернет не нужен — данные ходят только внутри вашей сети по Wi‑Fi.';
}
if (low.includes('aborted') || low.includes('timeout') || low.includes('таймаут')) {
return 'Сервер не ответил вовремя.\n\nПодойдите ближе к роутеру или подождите и попробуйте снова.';
}
if (low.includes('json') && (low.includes('parse') || low.includes('unexpected') || low.includes('invalid'))) {
return 'Ответ сервера не удалось разобрать.\n\nПроверьте стабильность Wi‑Fi и повторите проверку.';
}
if (context === 'check') {
return 'Проверку базы выполнить не удалось.\n\n'
+ 'Проверьте Wi‑Fi фермы и привязку терминала в меню, затем попробуйте снова. '
+ 'Если не поможет — сообщите администратору.';
}
return 'Восстановление выполнить не удалось.\n\n'
+ 'Проверьте Wi‑Fi фермы и привязку терминала в меню, затем попробуйте снова. '
+ 'Если не поможет — сообщите администратору.';
}
async function showFarmDbErrorModal(context, rawText, httpStatus) {
const title = context === 'check' ? 'Проверка базы' : 'Восстановление базы';
const body = humanizeFarmDbError(context, rawText, httpStatus);
await WespKioskDialog.alert(body, { title: title, variant: 'danger' });
}
function dbErrorInlineHint(context) {
const p = context === 'check' ? 'проверку' : 'восстановление';
return `
<div class="alert alert-danger">
<h6>Не удалось выполнить ${p}</h6>
<p>Только что было показано пояснение в отдельном окне. Если закрыли его: проверьте Wi‑Fi фермы и привязку терминала в меню (интернет не нужен).</p>
</div>`;
}
/** Понятные названия разделов данных вместо имён таблиц из базы. */
var DB_KIND_LABELS = {
recipe: 'рецепты',
recipes: 'рецепты',
component: 'компоненты кормов',
components: 'компоненты кормов',
ingredient: 'ингредиенты',
ingredients: 'ингредиенты',
feeding_period: 'рейсы кормления',
feeding_periods: 'рейсы кормления',
period_recipes: 'состав рецептов по рейсам',
feed_dispenser: 'кормораздатчики',
feed_mixers: 'кормосмесители',
feed_mixer: 'кормосмесители',
feeding_location: 'места кормления',
feeding_point: 'точки выдачи',
trip: 'рейсы',
unloading_group: 'группы выгрузки',
loading_report: 'отчёты о загрузке',
unloading_report: 'отчёты о выгрузке',
loading_report_component: 'детали загрузки',
component_loading_time: 'время загрузки компонентов',
unloading_report_group: 'группы в отчётах',
component_stock: 'остатки на складе',
sync_metadata: 'служебная информация синхронизации',
sync_clients: 'список устройств',
sync_queue: 'очередь обмена данными',
sync_conflicts: 'разбор расхождений',
sync_delivery: 'доставка данных',
sync_client_display_name: 'названия устройств',
hardware_settings: 'настройки оборудования',
kiosk_device: 'настройки терминала',
kiosk_pair_token: 'привязка терминала',
};
function looksLikeTechnicalId(s) {
var t = String(s || '').trim();
if (!t) return true;
if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(t)) return true;
if (t.length >= 32 && /^[0-9a-f\-]+$/i.test(t)) return true;
if (t.length >= 24 && /^[0-9a-f]+$/i.test(t)) return true;
return false;
}
function humanKindLabel(tableKey) {
var k = String(tableKey || '').trim().toLowerCase();
if (DB_KIND_LABELS[k]) return DB_KIND_LABELS[k];
if (!k) return 'данные программы';
if (k.indexOf('sync_') === 0) return 'служебные данные синхронизации';
if (k.indexOf('kiosk') === 0) return 'настройки терминала';
return 'данные программы';
}
function humanRecordLine(details) {
if (!details || typeof details !== 'object') {
return 'позиция без названия';
}
var keys = ['name', 'recipe_name', 'component_name', 'title', 'farm', 'operator', 'label', 'display_name'];
for (var i = 0; i < keys.length; i++) {
var v = details[keys[i]];
if (v != null && String(v).trim() && !looksLikeTechnicalId(String(v))) {
return String(v).trim();
}
}
if (details.period_id != null && String(details.period_id).trim()) {
var p = String(details.period_id).trim();
if (!looksLikeTechnicalId(p)) {
return 'Рейс «' + p + '»';
}
}
return 'позиция без названия';
}
function humanRestoreSuccessText(raw) {
var t = (raw == null ? '' : String(raw)).trim();
if (!t) {
return 'Данные с сервера фермы применены. Можно снова запустить проверку.';
}
if (looksLikeTechnicalId(t) || t.length > 280) {
return 'Данные с сервера фермы применены. Можно снова запустить проверку.';
}
if (/error|exception|traceback|sql|errno|failed|undefined|null/i.test(t)) {
return 'Данные с сервера фермы применены. Можно снова запустить проверку.';
}
return t;
}
function showLoading() {
document.getElementById('loadingOverlay').style.display = 'flex';
}
function hideLoading() {
document.getElementById('loadingOverlay').style.display = 'none';
}
async function checkDatabase() {
if (!kioskPaired) return;
const btn = document.getElementById('check-db-btn');
const resultDiv = document.getElementById('db-check-result');
btn.disabled = true;
btn.textContent = 'Проверка…';
showLoading();
resultDiv.innerHTML = '';
try {
const response = await fetch('/api/sync/check-db-client', {
method: 'POST',
headers: buildHeaders({ 'Content-Type': 'application/json' })
});
const responseText = await response.text();
let data;
try {
data = responseText ? JSON.parse(responseText) : {};
} catch {
data = {};
}
if (!response.ok) {
const raw = apiErrorPayload(data) || responseText.slice(0, 300);
await showFarmDbErrorModal('check', raw, response.status);
resultDiv.innerHTML = dbErrorInlineHint('check');
return;
}
await displayResults(data);
} catch (error) {
await showFarmDbErrorModal('check', error && error.message ? error.message : String(error), 0);
resultDiv.innerHTML = dbErrorInlineHint('check');
} finally {
btn.disabled = false;
btn.textContent = 'Запустить проверку';
hideLoading();
}
}
async function displayResults(data) {
const resultDiv = document.getElementById('db-check-result');
const statsCard = document.getElementById('stats-card');
const statsDiv = document.getElementById('db-stats');
const apiErr = apiErrorPayload(data);
const legacyErrStr = typeof data.error === 'string' && data.error.trim() ? data.error.trim() : '';
if (data.error === true || data.success === false || legacyErrStr) {
const raw = apiErr || legacyErrStr;
await showFarmDbErrorModal('check', raw, 0);
resultDiv.innerHTML = dbErrorInlineHint('check');
return;
}
const comp = data.comparison || {};
const hasDifferences = (comp.total_differences > 0) ||
(comp.tables_only_on_server && comp.tables_only_on_server.length > 0) ||
(comp.tables_only_on_client && comp.tables_only_on_client.length > 0);
let html = `
<div class="alert ${hasDifferences ? 'alert-warning' : 'alert-success'}">
<h6>${hasDifferences ? 'Есть расхождения' : 'Всё совпадает'}</h6>`;
if (!hasDifferences) {
html += '<p>На сервере и на этом устройстве данные совпадают.</p>';
} else {
html += '<div class="db-details">';
html += '<p>Ниже — только понятные названия: что именно не совпало. Технические коды скрыты.</p>';
if (comp.total_differences > 0) {
html += `<p><strong>Сколько записей не совпало:</strong> ${comp.total_differences}</p>`;
}
if (comp.tables_only_on_server && comp.tables_only_on_server.length > 0) {
const labels = comp.tables_only_on_server.map(function (t) { return humanKindLabel(t); });
const uniq = [];
labels.forEach(function (x) {
if (uniq.indexOf(x) === -1) uniq.push(x);
});
html += `<p><strong>На сервере есть целый раздел, которого не видно на планшете:</strong> ${escapeHtml(uniq.join(', '))}.</p>`;
}
if (comp.tables_only_on_client && comp.tables_only_on_client.length > 0) {
const labels = comp.tables_only_on_client.map(function (t) { return humanKindLabel(t); });
const uniq = [];
labels.forEach(function (x) {
if (uniq.indexOf(x) === -1) uniq.push(x);
});
html += `<p><strong>На планшете есть данные, которых нет на сервере:</strong> ${escapeHtml(uniq.join(', '))}.</p>`;
}
if (comp.common_tables && Object.keys(comp.common_tables).length > 0) {
html += '<p class="section-heading">Подробнее по разделам</p>';
Object.entries(comp.common_tables).forEach(function (entry) {
const table = entry[0];
const info = entry[1];
const hasDetails = info.only_on_server_details || info.only_on_client_details || info.changed_records_details;
if (hasDetails) {
const kindTitle = humanKindLabel(table);
html += `<div class="table-section">
<div class="table-name">${escapeHtml(kindTitle)}</div>`;
if (info.only_on_server_details && Object.keys(info.only_on_server_details).length > 0) {
const n = Object.keys(info.only_on_server_details).length;
html += `<p class="label-add">Не хватает на этом устройстве (на сервере уже есть) — ${n} шт.</p>
<ul class="record-list">`;
Object.values(info.only_on_server_details).forEach(function (details) {
html += '<li>' + escapeHtml(humanRecordLine(details)) + '</li>';
});
html += '</ul>';
}
if (info.only_on_client_details && Object.keys(info.only_on_client_details).length > 0) {
const n = Object.keys(info.only_on_client_details).length;
html += `<p class="label-remove">Есть только на планшете (на сервере пока нет) — ${n} шт.</p>
<ul class="record-list">`;
Object.values(info.only_on_client_details).forEach(function (details) {
html += '<li>' + escapeHtml(humanRecordLine(details)) + '</li>';
});
html += '</ul>';
}
if (info.changed_records_details && Object.keys(info.changed_records_details).length > 0) {
html += '<p class="text-muted" style="margin-top:10px;">Часть строк в этом разделе и там и там есть, но содержимое отличается. Синхронизация подтянет актуальное с сервера.</p>';
}
html += '</div>';
}
});
}
html += '</div>';
}
html += '</div>';
if (hasDifferences) {
html += `
<div class="alert alert-info">
<h6>Что можно сделать</h6>
<p>Выберите действие — данные передаются только по Wi‑Fi вашей фермы.</p>
<div class="action-buttons">
<button type="button" class="btn btn-success" onclick="restoreDatabase('sync')">
Подтянуть недостающее с сервера
</button>
<button type="button" class="btn btn-danger" onclick="restoreDatabase('full')">
Полностью как на сервере
</button>
</div>
<p class="text-muted mt-2" style="font-size: 0.9rem;">
<strong>Подтянуть недостающее:</strong> добавит на планшет то, чего не хватает, не удаляя лишнее без необходимости.<br>
<strong>Полностью как на сервере:</strong> приведёт базу на планшете к виду на сервере (перед этим делается резервная копия).
</p>
</div>`;
}
resultDiv.innerHTML = html;
if (comp && (comp.total_differences !== undefined || comp.common_tables)) {
statsDiv.innerHTML = `
<div class="stat-card">
<div class="stat-value">${comp.total_differences || 0}</div>
<div class="stat-label">Записей не совпало</div>
</div>
<div class="stat-card">
<div class="stat-value">${comp.common_tables ? Object.keys(comp.common_tables).length : 0}</div>
<div class="stat-label">Разделов проверено</div>
</div>
<div class="stat-card">
<div class="stat-value">${comp.tables_only_on_server ? comp.tables_only_on_server.length : 0}</div>
<div class="stat-label">Раздел только на сервере</div>
</div>
<div class="stat-card">
<div class="stat-value">${comp.tables_only_on_client ? comp.tables_only_on_client.length : 0}</div>
<div class="stat-label">Раздел только на планшете</div>
</div>`;
statsCard.style.display = 'block';
}
}
function escapeHtml(s) {
return String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
async function restoreDatabase(type) {
if (!kioskPaired) return;
const typeName = type === 'full' ? 'полное восстановление' : 'синхронизацию';
const warning = type === 'full'
? '\n\nВНИМАНИЕ: будет заменена вся локальная база снапшотом сервера. Перед этим создаётся резервная копия. Данные передаются только по локальной сети фермы (Wi‑Fi), интернет не нужен.'
: '\n\nБудут подтянуты недостающие данные с сервера. Работа идёт только по Wi‑Fi локальной сети, без интернета.';
if (!(await WespKioskDialog.confirm('Выполнить ' + typeName + '?' + warning, { danger: type === 'full' }))) return;
const resultDiv = document.getElementById('db-check-result');
resultDiv.innerHTML = '<div class="alert alert-info">Выполняется восстановление…</div>';
showLoading();
try {
const response = await fetch('/api/sync/restore-db', {
method: 'POST',
headers: buildHeaders({'Content-Type': 'application/json'}),
body: JSON.stringify({type: type})
});
const responseText = await response.text();
let data;
try {
data = responseText ? JSON.parse(responseText) : {};
} catch {
data = {};
}
if (!response.ok) {
const raw = apiErrorPayload(data) || responseText.slice(0, 300);
await showFarmDbErrorModal('restore', raw, response.status);
resultDiv.innerHTML = dbErrorInlineHint('restore');
return;
}
if (data.success) {
let html = `
<div class="alert alert-success">
<h6>Готово</h6>
<p>${escapeHtml(humanRestoreSuccessText(data.message))}</p>`;
if (data.changes_count !== undefined) {
html += `<p class="text-muted mt-2">Обновлено записей: ${data.changes_count}</p>`;
}
html += `
<button type="button" class="btn btn-primary mt-2" onclick="checkDatabase()">
Проверить снова
</button>
</div>`;
resultDiv.innerHTML = html;
} else {
const raw = apiErrorPayload(data) || (typeof data.error === 'string' ? data.error : '');
await showFarmDbErrorModal('restore', raw, 0);
resultDiv.innerHTML = dbErrorInlineHint('restore');
}
} catch (error) {
await showFarmDbErrorModal('restore', error && error.message ? error.message : String(error), 0);
resultDiv.innerHTML = dbErrorInlineHint('restore');
} finally {
hideLoading();
}
}
</script>
<script src="/static/js/kiosk-theme.js"></script>
<script src="/static/js/kiosk-nav-menu.js"></script>
<script src="/static/js/kiosk-pairing.js"></script>
{% if not embed %}
<script src="/static/js/kiosk-db-check.js"></script>
<script src="/static/js/kiosk-calibration.js"></script>
{% endif %}
<script>
document.addEventListener('DOMContentLoaded', function () {
window.WespKioskTheme.setup({ withToggle: false });
window.WespKioskMenu.setup({ menuButtonSelector: '#kioskMenuButton' });
{% if not embed %}
window.WespKioskDbCheck.setup({ menuLinkSelector: '#dbCheckLink' });
window.WespKioskCalibration.setup({ menuLinkSelector: '#calibrationLink' });
{% endif %}
window.WespKioskPairing.setup({
menuLinkSelector: '#pairTerminalLink',
onPaired: function () {
kioskPaired = true;
},
onUnpaired: function () {
kioskPaired = false;
}
});
});
</script>
<link href="/static/css/wesp-update-banner.css" rel="stylesheet">
<script src="/static/js/wesp-update-notifier.js"></script>
<script>
document.addEventListener("DOMContentLoaded", function () {
if (window.WespUpdateNotifier) {
WespUpdateNotifier.init({ dialog: window.WespKioskDialog || window.WespDialog });
}
});
</script>
</body>
</html>