/* VMIS Admin Portal - API utilities */ const API = 'http://localhost:10281/api/v1'; /* ── Auth state ── */ let _sysSettings = null; let _kcInstance = null; let _accessToken = null; /* ── Keycloak SSO guard ── */ async function _loadKcScript(src) { return new Promise((resolve) => { const s = document.createElement('script'); s.src = src; s.onload = () => resolve(true); s.onerror = () => resolve(false); document.head.appendChild(s); }); } async function _initKcAuth(settings) { // 嘗試路徑順序:新版(Quarkus) → 舊版(WildFly /auth) → CDN const candidates = [ `${settings.keycloak_url}/js/keycloak.js`, `${settings.keycloak_url}/auth/js/keycloak.js`, 'https://cdn.jsdelivr.net/npm/keycloak-js/dist/keycloak.min.js', ]; let loaded = false; for (const src of candidates) { loaded = await _loadKcScript(src); if (loaded) { console.info('KC JS 載入成功:', src); break; } } if (!loaded) { console.warn('KC JS 全部來源載入失敗,跳過 SSO 守衛'); return; } try { const kc = new Keycloak({ url: settings.keycloak_url, realm: settings.keycloak_realm, clientId: settings.keycloak_client || 'vmis-portal', }); const authenticated = await kc.init({ onLoad: 'login-required', pkceMethod: 'S256', checkLoginIframe: false, }); if (authenticated) { _kcInstance = kc; _accessToken = kc.token; setInterval(() => { kc.updateToken(60) .then(refreshed => { if (refreshed) _accessToken = kc.token; }) .catch(() => kc.login()); }, 30000); // 在 topbar 插入使用者資訊與登出按鈕 const topbar = document.getElementById('topbar'); if (topbar) { const username = kc.tokenParsed?.preferred_username || kc.tokenParsed?.name || ''; const btn = document.createElement('div'); btn.className = 'ms-3 d-flex align-items-center gap-2'; btn.innerHTML = ` ${username} `; topbar.appendChild(btn); } } } catch (e) { console.error('KC 初始化失敗:', e); } } /* ── Global settings (loaded on every page) ── */ async function loadSysSettings() { try { _sysSettings = await apiFetch('/settings'); // Apply site title: prepend page name if title contains " — " if (_sysSettings.site_title) { const cur = document.title; const sep = cur.indexOf(' — '); const pageName = sep >= 0 ? cur.substring(0, sep) : cur; document.title = `${pageName} — ${_sysSettings.site_title}`; } // Inject version badge into topbar if element exists const topbar = document.getElementById('topbar'); if (topbar && _sysSettings.version) { const badge = document.createElement('span'); badge.className = 'badge bg-secondary-subtle text-secondary ms-2'; badge.textContent = `v${_sysSettings.version}`; topbar.querySelector('.fw-semibold')?.after(badge); } // SSO guard:若已啟用,觸發 Keycloak 認證 if (_sysSettings.sso_enabled && _sysSettings.keycloak_url && _sysSettings.keycloak_realm) { await _initKcAuth(_sysSettings); } } catch (e) { // Silently ignore if settings not yet available } } // Auto-load on every page document.addEventListener('DOMContentLoaded', loadSysSettings); async function apiFetch(path, options = {}) { const headers = { 'Content-Type': 'application/json', ...options.headers }; if (_accessToken) headers['Authorization'] = `Bearer ${_accessToken}`; const resp = await fetch(API + path, { headers, ...options }); if (!resp.ok) { // Token 過期 → 重新登入 if (resp.status === 401 && _kcInstance) { _kcInstance.login(); return; } const err = await resp.json().catch(() => ({ detail: resp.statusText })); throw Object.assign(new Error(err.detail || resp.statusText), { status: resp.status }); } if (resp.status === 204) return null; return resp.json(); } /* ── light helper ── */ function lightHtml(val) { if (val === null || val === undefined) return ''; return val ? '' : ''; } /* ── availability color ── */ function availClass(pct) { if (pct === null || pct === undefined) return ''; if (pct >= 99) return ''; if (pct >= 95) return 'warn'; return 'danger'; } function availBar(pct) { if (pct === null || pct === undefined) return ''; const cls = availClass(pct); return `
${pct}%
`; } /* ── toast ── */ function toast(msg, type = 'success') { const container = document.getElementById('toast-container'); if (!container) return; const id = 'toast-' + Date.now(); const icon = type === 'success' ? '✓' : '✗'; const bg = type === 'success' ? 'text-bg-success' : 'text-bg-danger'; container.insertAdjacentHTML('beforeend', ` `); setTimeout(() => document.getElementById(id)?.remove(), 3500); } /* ── confirm modal ── */ function confirm(msg) { return new Promise(resolve => { document.getElementById('confirm-body').textContent = msg; const modal = bootstrap.Modal.getOrCreateInstance(document.getElementById('confirmModal')); document.getElementById('confirm-ok').onclick = () => { modal.hide(); resolve(true); }; document.getElementById('confirm-cancel').onclick = () => { modal.hide(); resolve(false); }; modal.show(); }); } /* ── format datetime ── */ // 後端已依設定時區儲存,直接顯示資料庫原始時間,不做轉換 function fmtDt(iso) { if (!iso) return '—'; return iso.replace('T', ' ').substring(0, 19); } function fmtDate(iso) { if (!iso) return '—'; return iso.substring(0, 10); }