fix: добавить 15-секундный таймаут fetch во все модули (measurements, assembly, proposals, request)

Кнопки больше не зависают бесконечно при медленном или недоступном бэкенде.
AbortController + дружелюбное сообщение «Сервер не отвечает — попробуйте ещё раз».

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
wasrusgen 2026-05-18 09:27:53 +03:00
parent 40e2275949
commit 0fb0597b8d
5 changed files with 81 additions and 64 deletions

View File

@ -17,6 +17,20 @@ const Assembly = (function () {
manager_note: "", manager_note: "",
}; };
async function _fetchWithTimeout(url, body, timeoutMs = 15000) {
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), timeoutMs);
try {
const res = await fetch(url, { method: "POST", signal: ctrl.signal, body: JSON.stringify(body) });
return await res.json();
} catch (e) {
if (e.name === "AbortError") throw new Error("Сервер не отвечает — попробуйте ещё раз");
throw e;
} finally {
clearTimeout(timer);
}
}
function mount(container) { function mount(container) {
root = container; root = container;
document.body.classList.remove("has-bottom-nav"); document.body.classList.remove("has-bottom-nav");
@ -198,11 +212,7 @@ const Assembly = (function () {
}; };
try { try {
const res = await fetch(`${BACKEND_URL}/api/assembly_create`, { const data = await _fetchWithTimeout(`${BACKEND_URL}/api/assembly_create`, body);
method: "POST",
body: JSON.stringify(body),
});
const data = await res.json();
if (data.error) { if (data.error) {
result.innerHTML = `<div class="error">Ошибка: ${escHtml(data.error)}</div>`; result.innerHTML = `<div class="error">Ошибка: ${escHtml(data.error)}</div>`;
btn.disabled = false; btn.disabled = false;
@ -247,14 +257,10 @@ const Assembly = (function () {
const loading = el(`<div class="loader-inline"><div class="spinner"></div></div>`); const loading = el(`<div class="loader-inline"><div class="spinner"></div></div>`);
root.appendChild(loading); root.appendChild(loading);
try { try {
const res = await fetch(`${BACKEND_URL}/api/assembly_list`, { const data = await _fetchWithTimeout(`${BACKEND_URL}/api/assembly_list`, {
method: "POST",
body: JSON.stringify({
initData: tg?.initData || "", initData: tg?.initData || "",
initDataUnsafe: tg?.initDataUnsafe || null, initDataUnsafe: tg?.initDataUnsafe || null,
}), });
});
const data = await res.json();
loading.remove(); loading.remove();
if (data.error) { if (data.error) {
root.appendChild(el(`<div class="error">${escHtml(data.error)}</div>`)); root.appendChild(el(`<div class="error">${escHtml(data.error)}</div>`));
@ -309,15 +315,11 @@ const Assembly = (function () {
root.appendChild(loading); root.appendChild(loading);
let a; let a;
try { try {
const res = await fetch(`${BACKEND_URL}/api/assembly_detail`, { a = await _fetchWithTimeout(`${BACKEND_URL}/api/assembly_detail`, {
method: "POST",
body: JSON.stringify({
initData: tg?.initData || "", initData: tg?.initData || "",
initDataUnsafe: tg?.initDataUnsafe || null, initDataUnsafe: tg?.initDataUnsafe || null,
assembly_id: id, assembly_id: id,
}), });
});
a = await res.json();
} catch (e) { } catch (e) {
loading.remove(); loading.remove();
root.appendChild(el(`<div class="error">Сеть: ${escHtml(e.message)}</div>`)); root.appendChild(el(`<div class="error">Сеть: ${escHtml(e.message)}</div>`));

View File

@ -94,20 +94,30 @@ const Measurements = (function () {
render(); render();
} }
async function _fetchWithTimeout(url, body, timeoutMs = 15000) {
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), timeoutMs);
try {
const res = await fetch(url, { method: "POST", signal: ctrl.signal, body: JSON.stringify(body) });
return await res.json();
} catch (e) {
if (e.name === "AbortError") throw new Error("Сервер не отвечает — попробуйте ещё раз");
throw e;
} finally {
clearTimeout(timer);
}
}
async function loadRequestAndStart() { async function loadRequestAndStart() {
root.innerHTML = ""; root.innerHTML = "";
root.appendChild(renderHeader("Закрыть заявку")); root.appendChild(renderHeader("Закрыть заявку"));
root.appendChild(el(`<div class="loader-inline"><div class="spinner"></div></div>`)); root.appendChild(el(`<div class="loader-inline"><div class="spinner"></div></div>`));
try { try {
const res = await fetch(`${BACKEND_URL}/api/measurement_detail`, { const data = await _fetchWithTimeout(`${BACKEND_URL}/api/measurement_detail`, {
method: "POST",
body: JSON.stringify({
initData: tg?.initData || "", initData: tg?.initData || "",
initDataUnsafe: tg?.initDataUnsafe || null, initDataUnsafe: tg?.initDataUnsafe || null,
measurement_id: measurementId, measurement_id: measurementId,
}), });
});
const data = await res.json();
if (data.error) { if (data.error) {
root.innerHTML = ""; root.innerHTML = "";
root.appendChild(renderHeader("Ошибка")); root.appendChild(renderHeader("Ошибка"));
@ -332,14 +342,10 @@ const Measurements = (function () {
async function fetchNextZamerNo(node) { async function fetchNextZamerNo(node) {
try { try {
const res = await fetch(`${BACKEND_URL}/api/measurement_next_no`, { const data = await _fetchWithTimeout(`${BACKEND_URL}/api/measurement_next_no`, {
method: "POST",
body: JSON.stringify({
initData: tg?.initData || "", initData: tg?.initData || "",
initDataUnsafe: tg?.initDataUnsafe || null, initDataUnsafe: tg?.initDataUnsafe || null,
}), });
});
const data = await res.json();
const hint = node.querySelector("#zamerNoHint"); const hint = node.querySelector("#zamerNoHint");
const input = node.querySelector("#zamerNoInput"); const input = node.querySelector("#zamerNoInput");
if (data.ok && data.next_no && input && !state.zamer_no) { if (data.ok && data.next_no && input && !state.zamer_no) {
@ -529,14 +535,10 @@ const Measurements = (function () {
let clients = []; let clients = [];
try { try {
const res = await fetch(`${BACKEND_URL}/api/clients`, { const data = await _fetchWithTimeout(`${BACKEND_URL}/api/clients`, {
method: "POST",
body: JSON.stringify({
initData: tg?.initData || "", initData: tg?.initData || "",
initDataUnsafe: tg?.initDataUnsafe || null, initDataUnsafe: tg?.initDataUnsafe || null,
}), });
});
const data = await res.json();
clients = (data.clients || []).sort((a, b) => clients = (data.clients || []).sort((a, b) =>
(a.client_name || "").localeCompare(b.client_name || "", "ru") (a.client_name || "").localeCompare(b.client_name || "", "ru")
); );
@ -937,15 +939,11 @@ const Measurements = (function () {
}; };
try { try {
const res = await fetch(`${BACKEND_URL}/api/measurement`, { const data = await _fetchWithTimeout(`${BACKEND_URL}/api/measurement`, {
method: "POST",
body: JSON.stringify({
initData: tg?.initData || "", initData: tg?.initData || "",
initDataUnsafe: tg?.initDataUnsafe || null, initDataUnsafe: tg?.initDataUnsafe || null,
measurement, measurement,
}), });
});
const data = await res.json();
if (data.error) { if (data.error) {
result.innerHTML = `<div class="error">Ошибка: ${data.error}</div>`; result.innerHTML = `<div class="error">Ошибка: ${data.error}</div>`;
btn.disabled = false; btn.textContent = isUpdate ? "Закрыть заявку" : "Сохранить замер"; btn.disabled = false; btn.textContent = isUpdate ? "Закрыть заявку" : "Сохранить замер";

View File

@ -19,13 +19,23 @@ const Proposals = (function () {
return { initData: tg?.initData || "", initDataUnsafe: tg?.initDataUnsafe || null }; return { initData: tg?.initData || "", initDataUnsafe: tg?.initDataUnsafe || null };
} }
async function apiFetch(path, extra = {}) { async function apiFetch(path, extra = {}, timeoutMs = 15000) {
const res = await fetch(`${BACKEND_URL}/api/${path}`, { const ctrl = new AbortController();
method: "POST", const timer = setTimeout(() => ctrl.abort(), timeoutMs);
body: JSON.stringify({ ...authBody(), ...extra }), try {
}); const res = await fetch(`${BACKEND_URL}/api/${path}`, {
if (!res.ok) throw new Error("HTTP " + res.status); method: "POST",
return res.json(); signal: ctrl.signal,
body: JSON.stringify({ ...authBody(), ...extra }),
});
if (!res.ok) throw new Error("HTTP " + res.status);
return res.json();
} catch (e) {
if (e.name === "AbortError") throw new Error("Сервер не отвечает — попробуйте ещё раз");
throw e;
} finally {
clearTimeout(timer);
}
} }
// ── Constants ───────────────────────────────────────────── // ── Constants ─────────────────────────────────────────────

View File

@ -15,6 +15,20 @@ const MeasurementRequest = (function () {
}; };
let measurers = []; let measurers = [];
async function _fetchWithTimeout(url, body, timeoutMs = 15000) {
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), timeoutMs);
try {
const res = await fetch(url, { method: "POST", signal: ctrl.signal, body: JSON.stringify(body) });
return await res.json();
} catch (e) {
if (e.name === "AbortError") throw new Error("Сервер не отвечает — попробуйте ещё раз");
throw e;
} finally {
clearTimeout(timer);
}
}
function mount(container) { function mount(container) {
root = container; root = container;
document.body.classList.remove("has-bottom-nav"); document.body.classList.remove("has-bottom-nav");
@ -116,11 +130,9 @@ const MeasurementRequest = (function () {
async function loadMeasurers() { async function loadMeasurers() {
try { try {
const res = await fetch(`${BACKEND_URL}/api/staff_list`, { const data = await _fetchWithTimeout(`${BACKEND_URL}/api/staff_list`, {
method: "POST", initData: tg?.initData || "", role: "measurer",
body: JSON.stringify({ initData: tg?.initData || "", role: "measurer" }), });
});
const data = await res.json();
measurers = data.staff || []; measurers = data.staff || [];
const sel = document.getElementById("measurerSelect"); const sel = document.getElementById("measurerSelect");
const hint = document.getElementById("measurerHint"); const hint = document.getElementById("measurerHint");
@ -163,21 +175,16 @@ const MeasurementRequest = (function () {
result.innerHTML = ""; result.innerHTML = "";
try { try {
const res = await fetch(`${BACKEND_URL}/api/measurement_request`, { const data = await _fetchWithTimeout(`${BACKEND_URL}/api/measurement_request`, {
method: "POST",
body: JSON.stringify({
initData: tg?.initData || "", initData: tg?.initData || "",
initDataUnsafe: tg?.initDataUnsafe || null, initDataUnsafe: tg?.initDataUnsafe || null,
client_name: name, client_name: name,
client_phone: phone, client_phone: phone,
address: state.address || "", address: state.address || "",
assigned_to_tg_id: state.assigned_to_tg_id || "", assigned_to_tg_id: state.assigned_to_tg_id || "",
// Примечание (рекомендации по дате + особенности) — единое поле
preferred_note: state.preferred_note || "", preferred_note: state.preferred_note || "",
preferred_type: "tbd", preferred_type: "tbd",
}), });
});
const data = await res.json();
if (data.error) { if (data.error) {
result.innerHTML = `<div class="error">Ошибка: ${data.error}</div>`; result.innerHTML = `<div class="error">Ошибка: ${data.error}</div>`;
btn.disabled = false; btn.disabled = false;

View File

@ -41,10 +41,10 @@
<script src="assets/podbor.js?v=20260517d"></script> <script src="assets/podbor.js?v=20260517d"></script>
<script src="assets/clients.js?v=20260518e"></script> <script src="assets/clients.js?v=20260518e"></script>
<script src="assets/zamer-picts.js?v=20260516h"></script> <script src="assets/zamer-picts.js?v=20260516h"></script>
<script src="assets/measurements.js?v=20260517d"></script> <script src="assets/measurements.js?v=20260518f"></script>
<script src="assets/request.js?v=20260517d"></script> <script src="assets/request.js?v=20260518f"></script>
<script src="assets/assembly.js?v=20260517d"></script> <script src="assets/assembly.js?v=20260518f"></script>
<script src="assets/proposals.js?v=20260516h"></script> <script src="assets/proposals.js?v=20260518f"></script>
<script src="assets/app.js?v=20260517d"></script> <script src="assets/app.js?v=20260517d"></script>
</body> </body>
</html> </html>