Files
wyndham-ARR/arr_web/static/h5.js

211 lines
10 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

(() => {
"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);
let csrf = "";
let lastData = null;
function escapeHtml(value) {
return String(value ?? "").replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;").replaceAll("'", "&#039;");
}
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 = `<p class="empty">${escapeHtml(copy)}</p>`;
$("#h5-matrix").innerHTML = `<p class="empty">${escapeHtml(copy)}</p>`;
$("#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) => `
<span class="mobile-legend-item"><i data-color="${colors[index % colors.length]}"></i><span>${escapeHtml(item.worksheet)}</span><b>${total ? (Number(item.totals.total_price || 0) / total * 100).toFixed(1) : "0.0"}%</b></span>`).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) => `
<div class="mobile-rank"><span title="${escapeHtml(item.worksheet)}">${escapeHtml(item.worksheet)}</span><span class="track"><i data-width="${Number(item.totals.total_price || 0) / maximum * 100}"></i></span><b>${money(item.totals.total_price)}</b></div>`).join("") : `<p class="empty">${escapeHtml(I18N?.t("bi.empty_sales") || "暂无销售数据")}</p>`;
[...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 = `<p class="empty">${escapeHtml(I18N?.t("bi.empty") || "暂无渠道与房型数据")}</p>`;
return;
}
const rows = data.channels.map((channel) => {
const values = new Map(channel.room_types.map((item) => [item.room_type, item.rooms_sold]));
return `<tr><td><b>${escapeHtml(channel.worksheet)}</b></td>${rooms.map((room) => `<td>${integer(values.get(room) || 0)}</td>`).join("")}<td class="total">${integer(channel.totals.rooms_sold)}</td></tr>`;
}).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 = `<table><thead><tr><th>${escapeHtml(channelLabel)}</th>${rooms.map((room) => `<th>${escapeHtml(room)}</th>`).join("")}<th>${escapeHtml(totalLabel)}</th></tr></thead><tbody>${rows}<tr><td class="total">${escapeHtml(totalLabel)}</td>${rooms.map((room) => `<td class="total">${integer(totals.get(room) || 0)}</td>`).join("")}<td class="total">${integer(data.overall.totals.rooms_sold)}</td></tr></tbody></table>`;
}
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);
}
async function load() {
const month = $("#h5-month").value || monthNow();
try {
render(await api(`/api/public/h5/analytics?month=${encodeURIComponent(month)}`));
} catch (error) {
reset(I18N?.t("bi.empty") || "该月份暂无可用看板数据");
notify(error.message);
}
}
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 */ }
const select = $("#h5-month");
select.innerHTML = months.length
? months.map((item) => `<option value="${escapeHtml(item.month_key)}">${escapeHtml(item.month_key)}</option>`).join("")
: `<option value="${monthNow()}">${monthNow()}</option>`;
select.addEventListener("change", load);
await load();
}
boot();
})();