feat: platform.js — адаптер Telegram WebApp для миграции на VK Max

- platform.js: глобальный Platform (initData, initDataUnsafe, startParam,
  colorScheme, ready, expand, haptic, showAlert, onThemeChange, enableClosingConfirmation)
- tg остаётся глобальным для backward-совместимости модулей
- app.js: setupTelegram() и haptic() делегируют в Platform,
  все tg?.initData/initDataUnsafe → Platform.initData/initDataUnsafe
- index.html: platform.js грузится первым (перед icons.js)
- VK Max Phase 2: заменить platform.js, остальной код не трогать

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
wasrusgen 2026-05-18 18:06:03 +03:00
parent cb4bbfce70
commit ef51ebcb85
3 changed files with 100 additions and 41 deletions

View File

@ -1,8 +1,7 @@
// ЗОВ MiniApp — главный скрипт. v20260518l
// ЗОВ MiniApp — главный скрипт. v20260518n
// На входе: подписанный initData от Telegram.
// Ходим на backend → получаем профиль (роль, статус) → рендерим меню.
const tg = window.Telegram?.WebApp;
// tg и Platform определены в platform.js (загружается первым).
// Cloudflare Quick Tunnel → VPS FastAPI backend (GigaChat).
// Временный URL — пока wasrusgen1.pro в verification-hold; затем переключим на https://api.wasrusgen1.pro
// Позволяет переключить бэкенд через ?backend=https://staging.api.wasrusgen1.pro
@ -34,31 +33,21 @@ function savedVariant() {
try { return localStorage.getItem(THEME_KEY) ?? ""; } catch(e) { return ""; }
}
/* ----------------- Telegram WebApp setup ----------------- */
/* ----------------- Platform setup ----------------- */
function setupTelegram() {
const scheme = tg?.colorScheme || (window.matchMedia?.("(prefers-color-scheme: dark)").matches ? "dark" : "light");
document.documentElement.setAttribute("data-theme", scheme);
// Восстанавливаем тему из localStorage (по умолч. — brand)
document.documentElement.setAttribute("data-theme", Platform.colorScheme);
applyVariant(savedVariant());
if (!tg) return;
try {
tg.ready();
tg.expand();
if (tg.onEvent) tg.onEvent("themeChanged", () => {
document.documentElement.setAttribute("data-theme", tg.colorScheme || "light");
});
if (tg.enableClosingConfirmation) tg.enableClosingConfirmation();
} catch (e) { console.warn(e); }
Platform.ready();
Platform.expand();
Platform.onThemeChange(() => {
document.documentElement.setAttribute("data-theme", Platform.colorScheme);
});
Platform.enableClosingConfirmation();
}
function haptic(type = "selection") {
try {
if (!tg?.HapticFeedback) return;
if (type === "impact") tg.HapticFeedback.impactOccurred("light");
else if (type === "success") tg.HapticFeedback.notificationOccurred("success");
else tg.HapticFeedback.selectionChanged();
} catch (e) {}
Platform.haptic(type);
}
/* ----------------- Palette switcher UI ----------------- */
@ -119,11 +108,11 @@ async function fetchMe() {
const res = await fetch(`${BACKEND_URL}/api/me`, {
method: "POST",
body: JSON.stringify({
initData: tg?.initData || "",
initData: Platform.initData,
// Fallback для Telegram Desktop side-panel где initData может приходить пустым.
// Backend проверит подпись initData первым; если её нет — упадёт сюда. UNSAFE!
initDataUnsafe: tg?.initDataUnsafe || null,
startParam: tg?.initDataUnsafe?.start_param || null,
initDataUnsafe: Platform.initDataUnsafe,
startParam: Platform.startParam,
role: explicitRole,
}),
});
@ -219,7 +208,7 @@ async function renderManagerHome(me) {
card.addEventListener("click", () => {
haptic("impact");
if (qa.href) location.hash = qa.href;
else tg?.showAlert?.(`«${qa.title}» — скоро`);
else Platform.showAlert(`«${qa.title}» — скоро`);
});
grid.appendChild(card);
});
@ -246,7 +235,7 @@ async function renderManagerHome(me) {
// Параллельно грузим реальные данные (измерения + pending — критичные)
// Складские данные грузим отдельно, чтобы ошибка Drive не ломала весь дашборд
try {
const authBody = { initData: tg?.initData || "", initDataUnsafe: tg?.initDataUnsafe || null };
const authBody = { initData: Platform.initData, initDataUnsafe: Platform.initDataUnsafe };
const [resM, resP] = await Promise.all([
fetch(`${BACKEND_URL}/api/measurements`, { method: "POST", body: JSON.stringify(authBody) }),
fetch(`${BACKEND_URL}/api/manager_pending`, { method: "POST", body: JSON.stringify(authBody) }),
@ -319,8 +308,8 @@ async function handlePodborDecision(item, act, card) {
const res = await fetch(`${BACKEND_URL}/api/measurement_decision`, {
method: "POST",
body: JSON.stringify({
initData: tg?.initData || "",
initDataUnsafe: tg?.initDataUnsafe || null,
initData: Platform.initData,
initDataUnsafe: Platform.initDataUnsafe,
measurement_id: item.id,
decision,
}),
@ -639,7 +628,7 @@ function renderBottomNav(active, opts = {}) {
`);
btn.addEventListener("click", () => {
haptic("impact");
if (t.key !== active) tg?.showAlert?.(`«${t.label || "Новое"}» — скоро`);
if (t.key !== active) Platform.showAlert(`«${t.label || "Новое"}» — скоро`);
});
nav.appendChild(btn);
});
@ -868,7 +857,7 @@ async function renderStaff(me) {
const t1 = setTimeout(() => ctrl1.abort(), 15000);
const res = await fetch(`${BACKEND_URL}/api/measurement_inbox`, {
method: "POST", signal: ctrl1.signal,
body: JSON.stringify({ initData: tg?.initData || "", initDataUnsafe: tg?.initDataUnsafe || null }),
body: JSON.stringify({ initData: Platform.initData, initDataUnsafe: Platform.initDataUnsafe }),
});
clearTimeout(t1);
const data = await res.json();
@ -928,7 +917,7 @@ async function renderStaffAssemblies(container) {
const t2 = setTimeout(() => ctrl2.abort(), 15000);
const res = await fetch(`${BACKEND_URL}/api/assembly_list`, {
method: "POST", signal: ctrl2.signal,
body: JSON.stringify({ initData: tg?.initData || "", initDataUnsafe: tg?.initDataUnsafe || null }),
body: JSON.stringify({ initData: Platform.initData, initDataUnsafe: Platform.initDataUnsafe }),
});
clearTimeout(t2);
const data = await res.json();
@ -1213,7 +1202,7 @@ async function renderInboxDetail(measurementId) {
try {
const res = await fetch(`${BACKEND_URL}/api/measurement_detail`, {
method: "POST",
body: JSON.stringify({ initData: tg?.initData || "", measurement_id: measurementId }),
body: JSON.stringify({ initData: Platform.initData, measurement_id: measurementId }),
});
m = await res.json();
} catch (e) {
@ -1343,8 +1332,8 @@ async function saveScheduleDate(measurementId, section) {
const res = await fetch(`${BACKEND_URL}/api/measurement_schedule`, {
method: "POST",
body: JSON.stringify({
initData: tg?.initData || "",
initDataUnsafe: tg?.initDataUnsafe || null,
initData: Platform.initData,
initDataUnsafe: Platform.initDataUnsafe,
measurement_id: measurementId,
scheduled_at: iso,
}),
@ -1355,7 +1344,7 @@ async function saveScheduleDate(measurementId, section) {
return;
}
haptic && haptic("success");
tg?.showAlert?.("Дата сохранена — менеджер уведомлён.");
Platform.showAlert("Дата сохранена — менеджер уведомлён.");
renderInboxDetail(measurementId); // перерисовать с новым статусом
} catch (e) {
if (errorEl) errorEl.textContent = "Сеть: " + e.message;
@ -1513,8 +1502,8 @@ function renderLogisticsBlock(m) {
const res = await fetch(`${BACKEND_URL}/api/geocode`, {
method: "POST",
body: JSON.stringify({
initData: tg?.initData || "",
initDataUnsafe: tg?.initDataUnsafe || null,
initData: Platform.initData,
initDataUnsafe: Platform.initDataUnsafe,
address: addr,
}),
});
@ -1549,8 +1538,8 @@ function renderLogisticsBlock(m) {
}
const parkType = (section.querySelector('input[name="parkType"]:checked') || {}).value || "";
const payload = {
initData: tg?.initData || "",
initDataUnsafe: tg?.initDataUnsafe || null,
initData: Platform.initData,
initDataUnsafe: Platform.initDataUnsafe,
measurement_id: m.id,
entrance: section.querySelector("#logEntrance").value,
floor: section.querySelector("#logFloor").value,

View File

@ -0,0 +1,69 @@
/* ============================================================
platform.js адаптер платформы
Telegram Phase 1 · VK Max Phase 2 (отдельно, не параллельно)
Загружается ПЕРВЫМ из всех app-скриптов (после telegram-web-app.js).
Определяет два глобальных:
tg ссылка на WebApp (backward-совместимость модулей)
Platform единый API без привязки к Telegram SDK
Миграция на VK Max: заменить этот файл, остальной код не трогать.
============================================================ */
/* global */ var tg = window.Telegram?.WebApp || null; // eslint-disable-line no-var
const Platform = (function () {
"use strict";
const _tg = tg;
return {
// ── Auth ────────────────────────────────────────────────
/** Подписанная строка initData — для HMAC-верификации на бэкенде */
get initData() { return _tg?.initData || ""; },
/** Небезопасный объект (fallback для Telegram Desktop) */
get initDataUnsafe() { return _tg?.initDataUnsafe || null; },
/** Параметр ?startapp= / start_param от бота */
get startParam() { return _tg?.initDataUnsafe?.start_param || null; },
// ── Тема ────────────────────────────────────────────────
/** "light" | "dark" — берём из платформы или matchMedia */
get colorScheme() {
return _tg?.colorScheme
|| (window.matchMedia?.("(prefers-color-scheme: dark)").matches ? "dark" : "light");
},
// ── Lifecycle ───────────────────────────────────────────
/** Сигнал платформе: MiniApp готов к показу */
ready() { try { _tg?.ready?.(); } catch (e) { /* не в Telegram → ок */ } },
/** Развернуть на весь экран */
expand() { try { _tg?.expand?.(); } catch (e) {} },
/** Подтверждение при закрытии (предотвращает случайный свайп) */
enableClosingConfirmation() { try { _tg?.enableClosingConfirmation?.(); } catch (e) {} },
// ── События ─────────────────────────────────────────────
/** Подписка на смену темы платформой */
onThemeChange(cb) { try { _tg?.onEvent?.("themeChanged", cb); } catch (e) {} },
// ── UI ──────────────────────────────────────────────────
/** Нативный alert платформы (fallback → window.alert) */
showAlert(msg) {
if (_tg?.showAlert) { try { _tg.showAlert(msg); return; } catch (e) {} }
alert(msg);
},
// ── Haptic ──────────────────────────────────────────────
/**
* Тактильный отклик.
* @param {"impact"|"success"|"selection"} type
*/
haptic(type = "selection") {
try {
const hf = _tg?.HapticFeedback;
if (!hf) return;
if (type === "impact") hf.impactOccurred("light");
else if (type === "success") hf.notificationOccurred("success");
else hf.selectionChanged();
} catch (e) {}
},
};
})();

View File

@ -35,6 +35,7 @@
<div class="brand-tagline-gold">CRM</div>
</div>
<main id="app"></main>
<script src="assets/platform.js?v=20260518n"></script>
<script src="assets/icons.js?v=20260516h"></script>
<script src="assets/podbor.config.js?v=20260516h"></script>
<script src="assets/podbor.picts.js?v=20260516h"></script>
@ -51,6 +52,6 @@
<script src="assets/selfmeasure.js?v=20260518k"></script>
<script src="assets/orders.js?v=20260518l"></script>
<script src="assets/assembly_detail.js?v=20260518m"></script>
<script src="assets/app.js?v=20260518m"></script>
<script src="assets/app.js?v=20260518n"></script>
</body>
</html>