feat(ui): индикатор connected в тулбаре и панель переподключения

This commit is contained in:
AidarKC 2026-04-22 16:53:36 +03:00
parent 29a07a9a8b
commit 0159dd9074
4 changed files with 198 additions and 37 deletions

View File

@ -113,53 +113,126 @@ let versionCheckIntervalId = null;
let versionCheckInFlight = false;
let reconnectIntervalId = null;
let sessionRuntimeStarted = false;
let connectionStatusEl = null;
let connectionState = '';
let connectionStatusText = '';
let connectionRetryBannerEl = null;
let connectionStatusCountdownId = null;
let connectionNextRetryAtMs = 0;
let connectionCheckInFlight = false;
setClientErrorTransport((payload) => authService.reportClientError(payload));
initPwaInstallPromptHandling();
initCallUiOverlay();
setCallDebugReporter((payload) => authService.reportClientDebug(payload));
function ensureConnectionStatusEl() {
if (connectionStatusEl) return connectionStatusEl;
function ensureConnectionIndicatorEl() {
return document.getElementById('toolbar-connection-indicator');
}
function ensureConnectionRetryBannerEl() {
if (connectionRetryBannerEl) return connectionRetryBannerEl;
if (!appShellEl) return null;
const el = document.createElement('div');
el.id = 'connection-status-slot';
el.className = 'connection-status-slot is-connecting';
el.textContent = 'Подключение к серверу...';
el.id = 'connection-retry-banner';
el.className = 'connection-retry-banner is-connecting';
el.textContent = 'Соединяюсь…';
el.addEventListener('click', () => {
void triggerImmediateConnectionRetry();
});
appShellEl.append(el);
connectionStatusEl = el;
connectionRetryBannerEl = el;
return el;
}
function stopConnectionCountdown() {
if (connectionStatusCountdownId) {
window.clearInterval(connectionStatusCountdownId);
connectionStatusCountdownId = null;
}
}
function getConnectionRetrySecondsLeft() {
const leftMs = Math.max(0, Number(connectionNextRetryAtMs || 0) - Date.now());
return Math.ceil(leftMs / 1000);
}
function refreshConnectionUi() {
const state = String(connectionState || 'connecting').trim();
const indicatorEl = ensureConnectionIndicatorEl();
if (indicatorEl) {
indicatorEl.classList.remove('is-connected', 'is-disconnected', 'is-connecting', 'is-updating', 'is-unknown');
indicatorEl.classList.add(`is-${state || 'unknown'}`);
}
const bannerEl = ensureConnectionRetryBannerEl();
if (!bannerEl) return;
if (state === 'connected' || state === 'updating') {
bannerEl.hidden = true;
stopConnectionCountdown();
return;
}
bannerEl.hidden = false;
bannerEl.classList.remove('is-connected', 'is-disconnected', 'is-connecting', 'is-updating');
bannerEl.classList.add(`is-${state}`);
if (connectionStatusText) {
bannerEl.textContent = connectionStatusText;
return;
}
if (state === 'connecting') {
bannerEl.textContent = 'Соединяюсь… Нажмите, чтобы попробовать сразу';
return;
}
if (state === 'disconnected') {
const secs = getConnectionRetrySecondsLeft();
bannerEl.textContent = `Нет соединения. Повтор через ${secs}с. Нажмите для попытки сейчас`;
return;
}
bannerEl.textContent = 'Проблема с соединением. Нажмите для повтора';
}
function startConnectionCountdown() {
if (connectionStatusCountdownId) return;
connectionStatusCountdownId = window.setInterval(() => {
if (connectionState !== 'disconnected') return;
const secs = getConnectionRetrySecondsLeft();
if (secs <= 0 && !connectionCheckInFlight) {
connectionStatusText = '';
setConnectionStatus('connecting', 'Соединяюсь…');
void checkConnectionHealth();
return;
}
refreshConnectionUi();
}, 1000);
}
function setConnectionStatus(nextState, text = '') {
const el = ensureConnectionStatusEl();
if (!el) return;
const state = String(nextState || '').trim();
if (!state) return;
if (state === connectionState && !text) return;
connectionState = state;
el.classList.remove('is-connected', 'is-connecting', 'is-disconnected', 'is-updating');
el.classList.add(`is-${state}`);
if (text) {
el.textContent = text;
return;
}
if (state === 'connected') {
el.textContent = 'Подключено к серверу';
return;
}
connectionStatusText = String(text || '').trim();
if (state === 'disconnected') {
el.textContent = 'Нет соединения с сервером';
return;
if (!connectionNextRetryAtMs || connectionNextRetryAtMs <= Date.now()) {
connectionNextRetryAtMs = Date.now() + CONNECTION_CHECK_INTERVAL_MS;
}
startConnectionCountdown();
} else {
stopConnectionCountdown();
}
if (state === 'updating') {
el.textContent = 'Найдена новая версия, обновляю приложение...';
return;
}
el.textContent = 'Подключение к серверу...';
refreshConnectionUi();
}
async function triggerImmediateConnectionRetry() {
if (connectionCheckInFlight) return;
connectionNextRetryAtMs = Date.now() + CONNECTION_CHECK_INTERVAL_MS;
setConnectionStatus('connecting', 'Соединяюсь…');
await checkConnectionHealth();
}
function parseBuildHashFromHtml(html) {
@ -207,14 +280,22 @@ function startVersionMonitor() {
}
async function checkConnectionHealth() {
if (connectionCheckInFlight) return;
connectionCheckInFlight = true;
if (connectionState !== 'connected') {
setConnectionStatus('connecting');
}
try {
if (!wsIsOpen()) {
await authService.ws.open();
}
await authService.ws.request('Ping', { ts: Date.now() }, 7000);
setConnectionStatus('connected');
} catch {
connectionStatusText = '';
setConnectionStatus('disconnected');
} finally {
connectionCheckInFlight = false;
}
}
@ -223,8 +304,15 @@ function startConnectionMonitor() {
window.clearInterval(pingIntervalId);
pingIntervalId = null;
}
connectionNextRetryAtMs = Date.now() + CONNECTION_CHECK_INTERVAL_MS;
void checkConnectionHealth();
pingIntervalId = window.setInterval(() => {
connectionNextRetryAtMs = Date.now() + CONNECTION_CHECK_INTERVAL_MS;
if (connectionState === 'disconnected') {
refreshConnectionUi();
}
void checkConnectionHealth();
}, CONNECTION_CHECK_INTERVAL_MS);
}
@ -355,6 +443,7 @@ function renderPageFailureFallback(pageId, error) {
screenEl.classList.toggle('no-app-chrome', false);
toolbarEl.innerHTML = '';
refreshConnectionUi();
}
function renderApp() {
@ -395,6 +484,7 @@ function renderApp() {
if (showAppChrome) {
toolbarEl.append(renderToolbar(page.pageMeta.id, navigate));
}
refreshConnectionUi();
} catch (error) {
console.error('[renderApp] controlled fallback', error);
renderPageFailureFallback(pageId, error);

View File

@ -15,8 +15,22 @@ export function renderToolbar(currentPageId, navigate) {
ITEMS.forEach((item) => {
const btn = document.createElement('button');
btn.className = `toolbar-btn${item.pageId === active ? ' active' : ''}`;
btn.innerHTML = `<span>${item.icon}</span><span>${item.label}</span>`;
const isProfile = item.pageId === 'profile-view';
btn.className = `toolbar-btn${item.pageId === active ? ' active' : ''}${isProfile ? ' toolbar-btn-profile' : ''}`;
if (isProfile) {
btn.innerHTML = `
<span>${item.icon}</span>
<span class="toolbar-label-wrap">
<span>${item.label}</span>
<span id="toolbar-connection-indicator" class="toolbar-connection-indicator is-unknown">
<span class="toolbar-connection-text">connected</span>
<span class="toolbar-connection-dot" aria-hidden="true"></span>
</span>
</span>
`;
} else {
btn.innerHTML = `<span>${item.icon}</span><span>${item.label}</span>`;
}
btn.addEventListener('click', () => navigate(item.pageId));
root.append(btn);
});

View File

@ -611,6 +611,62 @@
font-size: 11px;
}
.toolbar-label-wrap {
display: grid;
justify-items: center;
line-height: 1.05;
}
.toolbar-btn-profile {
position: relative;
}
.toolbar-connection-indicator {
display: inline-flex;
align-items: center;
gap: 3px;
margin-top: 1px;
min-height: 9px;
}
.toolbar-connection-text {
font-size: 8px;
letter-spacing: 0.02em;
text-transform: lowercase;
color: rgba(191, 213, 255, 0.8);
opacity: 0;
transition: opacity 0.2s ease;
}
.toolbar-connection-dot {
width: 7px;
height: 7px;
border-radius: 999px;
background: rgba(148, 162, 195, 0.65);
box-shadow: 0 0 0 2px rgba(97, 116, 156, 0.25);
transition: 0.2s ease;
}
.toolbar-connection-indicator.is-connected .toolbar-connection-text {
opacity: 1;
color: rgba(145, 255, 192, 0.9);
}
.toolbar-connection-indicator.is-connected .toolbar-connection-dot {
background: #71e9a5;
box-shadow: 0 0 0 2px rgba(72, 201, 134, 0.28);
}
.toolbar-connection-indicator.is-connecting .toolbar-connection-dot {
background: #f0c56b;
box-shadow: 0 0 0 2px rgba(240, 197, 107, 0.22);
}
.toolbar-connection-indicator.is-disconnected .toolbar-connection-dot {
background: #e48792;
box-shadow: 0 0 0 2px rgba(228, 135, 146, 0.24);
}
.toolbar-btn.active {
background: rgba(83, 216, 251, 0.14);
color: var(--text);

View File

@ -41,11 +41,11 @@ body {
background: linear-gradient(180deg, rgba(7, 12, 23, 0) 0%, rgba(6, 11, 22, 0.96) 44%);
}
.connection-status-slot {
.connection-retry-banner {
position: absolute;
left: 12px;
right: 12px;
bottom: calc(62px + env(safe-area-inset-bottom));
bottom: calc(74px + env(safe-area-inset-bottom));
z-index: 5;
border-radius: 11px;
border: 1px solid rgba(133, 156, 201, 0.3);
@ -55,26 +55,27 @@ body {
line-height: 1.2;
text-align: center;
padding: 7px 10px;
pointer-events: none;
pointer-events: auto;
cursor: pointer;
backdrop-filter: blur(10px);
}
.connection-status-slot.is-connected {
.connection-retry-banner.is-connected {
border-color: rgba(124, 235, 171, 0.4);
color: #d8ffe9;
}
.connection-status-slot.is-connecting {
.connection-retry-banner.is-connecting {
border-color: rgba(238, 196, 107, 0.42);
color: #ffe8bb;
}
.connection-status-slot.is-disconnected {
.connection-retry-banner.is-disconnected {
border-color: rgba(228, 127, 145, 0.44);
color: #ffdce3;
}
.connection-status-slot.is-updating {
.connection-retry-banner.is-updating {
border-color: rgba(144, 201, 255, 0.44);
color: #d9eeff;
}