(() => { "use strict"; const colors = ["#2563eb", "#0f9f6e", "#7a5af8", "#f79009", "#06aed5", "#e0528d", "#64748b", "#84cc16"]; const BUSINESS_TIME_ZONE = "Asia/Bangkok"; const I18N = window.ARRI18n; const $ = (selector) => document.querySelector(selector); const BI_POLL_INTERVAL = 5000; let csrf = ""; let lastData = null; let dataLoading = false; let biPollTimer = null; let biPollLoading = false; function escapeHtml(value) { return String(value ?? "").replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """).replaceAll("'", "'"); } function monthNow() { const parts = Object.fromEntries(new Intl.DateTimeFormat("en-CA", { timeZone: BUSINESS_TIME_ZONE, year: "numeric", month: "2-digit", }).formatToParts(new Date()).map((part) => [part.type, part.value])); return `${parts.year}-${parts.month}`; } function integer(value) { return I18N?.formatInteger(value) || new Intl.NumberFormat("zh-CN", { maximumFractionDigits: 0 }).format(Number(value || 0)); } function money(value, compact = true) { if (I18N) return I18N.formatMoney(value, compact); const amount = Number(value || 0); return new Intl.NumberFormat("zh-CN", { style: "currency", currency: "CNY", maximumFractionDigits: 0 }).format(amount); } function redirectToLogin() { window.location.replace("/login?next=%2Fh5"); } async function api(path, options = {}) { const allowUnauthorized = Boolean(options.allowUnauthorized); const requestOptions = { ...options }; delete requestOptions.allowUnauthorized; const headers = new Headers(options.headers || {}); if (options.method && options.method !== "GET") headers.set("X-ARR-CSRF", csrf); const response = await fetch(path, { ...requestOptions, headers, credentials: "same-origin" }); if (response.status === 401) { if (allowUnauthorized) return null; redirectToLogin(); throw new Error(I18N?.errorMessage("SESSION_INVALID", "登录状态已失效") || "登录状态已失效"); } let payload; try { payload = await response.json(); } catch (_) { throw new Error(I18N?.t("error.unrecognized_response") || "服务返回了无法识别的结果"); } if (!response.ok || !payload.ok) { const error = payload?.error || {}; throw new Error(I18N?.errorMessage(error.code, error.message) || error.message || I18N?.t("error.database_read_unavailable") || "数据读取失败"); } return payload.data; } function notify(message) { const node = $("#h5-message"); node.textContent = I18N?.text(message) || message; node.classList.add("visible"); window.setTimeout(() => node.classList.remove("visible"), 3000); } function reset(message) { ["#h5-rooms", "#h5-revenue", "#h5-nights", "#h5-companies", "#h5-donut-total"].forEach((id) => { $(id).textContent = "—"; }); const copy = I18N?.text(message) || message; $("#h5-ranking").innerHTML = `

${escapeHtml(copy)}

`; $("#h5-matrix").innerHTML = `

${escapeHtml(copy)}

`; $("#h5-legend").innerHTML = ""; $("#h5-donut").style.background = "#d8e0ec"; $("#h5-data-range").textContent = I18N?.t("bi.data_range", { start: "—", end: "—" }) || "数据范围:—"; } function dataRange(data) { const start = data.min_arrival_date ? (I18N?.formatDate(data.min_arrival_date) || data.min_arrival_date) : "—"; const end = data.max_arrival_date ? (I18N?.formatDate(data.max_arrival_date) || data.max_arrival_date) : "—"; return I18N?.t("bi.data_range", { start, end }) || `数据范围:${start} – ${end}`; } function renderSales(channels) { const sorted = [...channels].sort((a, b) => Number(b.totals.total_price) - Number(a.totals.total_price)); const total = sorted.reduce((sum, item) => sum + Number(item.totals.total_price || 0), 0); const maximum = Math.max(1, ...sorted.map((item) => Number(item.totals.total_price || 0))); let cursor = 0; const stops = sorted.map((item, index) => { const start = cursor; cursor += total ? Number(item.totals.total_price || 0) / total * 100 : 0; return `${colors[index % colors.length]} ${start.toFixed(3)}% ${cursor.toFixed(3)}%`; }); $("#h5-donut").style.background = total ? `conic-gradient(${stops.join(",")})` : "#d8e0ec"; $("#h5-donut-total").textContent = money(total); $("#h5-legend").innerHTML = sorted.map((item, index) => ` ${escapeHtml(item.worksheet)}${total ? (Number(item.totals.total_price || 0) / total * 100).toFixed(1) : "0.0"}%`).join(""); [...document.querySelectorAll("#h5-legend .mobile-legend-item i")].forEach((marker) => { marker.style.backgroundColor = marker.dataset.color; }); $("#h5-ranking").innerHTML = sorted.length ? sorted.map((item) => `
${escapeHtml(item.worksheet)}${money(item.totals.total_price)}
`).join("") : `

${escapeHtml(I18N?.t("bi.empty_sales") || "暂无销售数据")}

`; [...document.querySelectorAll("#h5-ranking .track i")].forEach((bar) => { bar.style.width = `${Number(bar.dataset.width || 0)}%`; }); } function renderMatrix(data) { const rooms = data.overall.room_types.map((item) => item.room_type); if (!rooms.length || !data.channels.length) { $("#h5-matrix").innerHTML = `

${escapeHtml(I18N?.t("bi.empty") || "暂无渠道与房型数据")}

`; return; } const rows = data.channels.map((channel) => { const values = new Map(channel.room_types.map((item) => [item.room_type, item.rooms_sold])); return `${escapeHtml(channel.worksheet)}${rooms.map((room) => `${integer(values.get(room) || 0)}`).join("")}${integer(channel.totals.rooms_sold)}`; }).join(""); const totals = new Map(data.overall.room_types.map((item) => [item.room_type, item.rooms_sold])); const channelLabel = I18N?.t("bi.channel") || "渠道"; const totalLabel = I18N?.t("common.total") || "合计"; $("#h5-matrix").innerHTML = `${rooms.map((room) => ``).join("")}${rows}${rooms.map((room) => ``).join("")}
${escapeHtml(channelLabel)}${escapeHtml(room)}${escapeHtml(totalLabel)}
${escapeHtml(totalLabel)}${integer(totals.get(room) || 0)}${integer(data.overall.totals.rooms_sold)}
`; } function render(data) { lastData = data; const totals = data.overall.totals; $("#h5-rooms").textContent = integer(totals.rooms_sold); $("#h5-revenue").textContent = money(totals.total_price); $("#h5-nights").textContent = integer(totals.room_nights); $("#h5-companies").textContent = integer(totals.channel_count); $("#h5-data-range").textContent = dataRange(data); renderSales(data.channels); renderMatrix(data); } function populateMonths(months) { const select = $("#h5-month"); const current = select.value; select.innerHTML = months.length ? months.map((item) => ``).join("") : ``; if (months.some((item) => item.month_key === current)) select.value = current; } async function load(showErrors = true, preserveOnError = false) { if (dataLoading) return; dataLoading = true; const month = $("#h5-month").value || monthNow(); try { render(await api(`/api/public/h5/analytics?month=${encodeURIComponent(month)}`)); } catch (error) { if (!preserveOnError) reset(I18N?.t("bi.empty") || "该月份暂无可用看板数据"); if (showErrors) notify(error.message); } finally { dataLoading = false; } } function clearBiPoll() { window.clearTimeout(biPollTimer); biPollTimer = null; } function scheduleBiPoll(delay = BI_POLL_INTERVAL) { clearBiPoll(); if (document.hidden) return; biPollTimer = window.setTimeout(checkBiFreshness, delay); } async function checkBiFreshness() { if (biPollLoading || dataLoading || document.hidden) { scheduleBiPoll(); return; } biPollLoading = true; try { const months = await api("/api/public/h5/months"); populateMonths(months); const month = $("#h5-month").value || monthNow(); const metadata = months.find((item) => item.month_key === month); const changed = !lastData || lastData.month_key !== month || Boolean(metadata && metadata.updated_at !== lastData.updated_at); if (changed) await load(false, Boolean(lastData)); } catch (_) { // Keep the last good H5 snapshot during a transient background failure. } finally { biPollLoading = false; scheduleBiPoll(); } } document.addEventListener("arr:locale-change", () => { if (lastData) render(lastData); I18N?.translateDom(); }); async function boot() { const connection = $("#h5-connection"); try { const session = await api("/api/session", { allowUnauthorized: true }); csrf = session?.csrf_token || ""; const logout = $("#h5-logout"); if (session?.username) { logout.hidden = false; logout.removeAttribute("aria-disabled"); logout.classList.remove("is-busy"); logout.title = `${session.username} · ${I18N?.t("auth.logout") || "退出登录"}`; logout.addEventListener("click", async (event) => { event.preventDefault(); if (logout.getAttribute("aria-disabled") === "true") return; logout.setAttribute("aria-disabled", "true"); logout.classList.add("is-busy"); try { await api("/api/logout", { method: "POST" }); window.location.replace("/login"); } catch (error) { notify(error.message || I18N?.t("auth.logout_failed") || "退出登录失败,请重试"); logout.removeAttribute("aria-disabled"); logout.classList.remove("is-busy"); } }); } } catch (_) { // Session lookup is optional for the public read-only dashboard. } try { const healthResponse = await fetch("/healthz", { credentials: "same-origin", cache: "no-store", }); const databaseReady = healthResponse.ok; connection.classList.add(databaseReady ? "ready" : "error"); connection.lastChild.textContent = databaseReady ? "" : ` ${I18N?.t("status.service_disconnected") || "数据服务未连接"}`; connection.setAttribute( "aria-label", databaseReady ? (I18N?.t("status.service_ready") || "服务正常") : (I18N?.t("status.service_disconnected") || "数据服务未连接"), ); } catch (_) { connection.classList.add("error"); connection.lastChild.textContent = ` ${I18N?.t("status.service_disconnected") || "数据服务未连接"}`; connection.setAttribute("aria-label", I18N?.t("status.service_disconnected") || "数据服务未连接"); } let months = []; try { months = await api("/api/public/h5/months"); } catch (_) { /* handled by load */ } populateMonths(months); const select = $("#h5-month"); select.addEventListener("change", () => load(true)); await load(); scheduleBiPoll(250); } document.addEventListener("visibilitychange", () => { if (document.hidden) clearBiPoll(); else checkBiFreshness(); }); boot(); })();