163 lines
7.5 KiB
JavaScript
163 lines
7.5 KiB
JavaScript
(() => {
|
|
"use strict";
|
|
|
|
const colors = ["#2563eb", "#0f9f6e", "#7a5af8", "#f79009", "#06aed5", "#e0528d", "#64748b", "#84cc16"];
|
|
const BUSINESS_TIME_ZONE = "Asia/Bangkok";
|
|
const $ = (selector) => document.querySelector(selector);
|
|
let csrf = "";
|
|
|
|
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 new Intl.NumberFormat("zh-CN", { maximumFractionDigits: 0 }).format(Number(value || 0));
|
|
}
|
|
|
|
function money(value, compact = true) {
|
|
const amount = Number(value || 0);
|
|
if (compact && amount >= 10000) return `¥${(amount / 10000).toFixed(amount >= 100000 ? 1 : 2)}万`;
|
|
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 headers = new Headers(options.headers || {});
|
|
if (options.method && options.method !== "GET") headers.set("X-ARR-CSRF", csrf);
|
|
const response = await fetch(path, { ...options, headers, credentials: "same-origin" });
|
|
if (response.status === 401) {
|
|
redirectToLogin();
|
|
throw new Error("登录状态已失效");
|
|
}
|
|
const payload = await response.json();
|
|
if (!response.ok || !payload.ok) throw new Error(payload?.error?.message || "数据读取失败");
|
|
return payload.data;
|
|
}
|
|
|
|
function notify(message) {
|
|
const node = $("#h5-message");
|
|
node.textContent = 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 = "—"; });
|
|
$("#h5-ranking").innerHTML = `<p class="empty">${escapeHtml(message)}</p>`;
|
|
$("#h5-matrix").innerHTML = `<p class="empty">${escapeHtml(message)}</p>`;
|
|
$("#h5-legend").innerHTML = "";
|
|
$("#h5-donut").style.background = "#d8e0ec";
|
|
}
|
|
|
|
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">暂无销售数据</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">暂无渠道与房型数据</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]));
|
|
$("#h5-matrix").innerHTML = `<table><thead><tr><th>渠道</th>${rooms.map((room) => `<th>${escapeHtml(room)}</th>`).join("")}<th>合计</th></tr></thead><tbody>${rows}<tr><td class="total">合计</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) {
|
|
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-updated").textContent = `更新至 ${data.max_arrival_date || "—"} · 数据库月报快照`;
|
|
renderSales(data.channels);
|
|
renderMatrix(data);
|
|
}
|
|
|
|
async function load() {
|
|
const month = $("#h5-month").value || monthNow();
|
|
try {
|
|
render(await api(`/api/analytics?month=${encodeURIComponent(month)}`));
|
|
} catch (error) {
|
|
reset("该月份暂无可用看板数据");
|
|
notify(error.message);
|
|
}
|
|
}
|
|
|
|
async function boot() {
|
|
const connection = $("#h5-connection");
|
|
try {
|
|
const session = await api("/api/session");
|
|
csrf = session.csrf_token;
|
|
const logout = $("#h5-logout");
|
|
if (session.username) {
|
|
logout.hidden = false;
|
|
logout.disabled = false;
|
|
logout.title = `${session.username} · 退出登录`;
|
|
logout.addEventListener("click", async () => {
|
|
logout.disabled = true;
|
|
try {
|
|
await api("/api/logout", { method: "POST" });
|
|
window.location.replace("/login");
|
|
} catch (error) {
|
|
notify(error.message || "退出登录失败,请重试");
|
|
logout.disabled = false;
|
|
}
|
|
});
|
|
}
|
|
} catch (_) {
|
|
return;
|
|
}
|
|
try {
|
|
const health = await api("/api/health");
|
|
connection.classList.add(health.database_ready ? "ready" : "error");
|
|
connection.lastChild.textContent = health.database_ready ? " 数据库已连接" : " 数据服务未连接";
|
|
} catch (_) {
|
|
connection.classList.add("error");
|
|
connection.lastChild.textContent = " 数据服务未连接";
|
|
}
|
|
let months = [];
|
|
try { months = await api("/api/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();
|
|
})();
|