feat: refine web analytics and history views

This commit is contained in:
Wyndham ARR
2026-08-02 12:56:22 +08:00
parent a891c0ae7a
commit 7e7470821b
37 changed files with 2088 additions and 361 deletions

View File

@@ -3,8 +3,10 @@
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;");
@@ -20,12 +22,12 @@
}
function integer(value) {
return new Intl.NumberFormat("zh-CN", { maximumFractionDigits: 0 }).format(Number(value || 0));
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);
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);
}
@@ -39,26 +41,42 @@
const response = await fetch(path, { ...options, headers, credentials: "same-origin" });
if (response.status === 401) {
redirectToLogin();
throw new Error("登录状态已失效");
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") || "数据读取失败");
}
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.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 = "—"; });
$("#h5-ranking").innerHTML = `<p class="empty">${escapeHtml(message)}</p>`;
$("#h5-matrix").innerHTML = `<p class="empty">${escapeHtml(message)}</p>`;
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) {
@@ -77,14 +95,14 @@
<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>';
<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">暂无渠道与房型数据</p>';
$("#h5-matrix").innerHTML = `<p class="empty">${escapeHtml(I18N?.t("bi.empty") || "暂无渠道与房型数据")}</p>`;
return;
}
const rows = data.channels.map((channel) => {
@@ -92,16 +110,19 @@
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>`;
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-updated").textContent = `更新至 ${data.max_arrival_date || "—"} · 数据库月报快照`;
$("#h5-data-range").textContent = dataRange(data);
renderSales(data.channels);
renderMatrix(data);
}
@@ -111,11 +132,16 @@
try {
render(await api(`/api/analytics?month=${encodeURIComponent(month)}`));
} catch (error) {
reset("该月份暂无可用看板数据");
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 {
@@ -124,16 +150,21 @@
const logout = $("#h5-logout");
if (session.username) {
logout.hidden = false;
logout.disabled = false;
logout.title = `${session.username} · 退出登录`;
logout.addEventListener("click", async () => {
logout.disabled = true;
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 || "退出登录失败,请重试");
logout.disabled = false;
notify(error.message || I18N?.t("auth.logout_failed") || "退出登录失败,请重试");
logout.removeAttribute("aria-disabled");
logout.classList.remove("is-busy");
}
});
}
@@ -142,11 +173,21 @@
}
try {
const health = await api("/api/health");
connection.classList.add(health.database_ready ? "ready" : "error");
connection.lastChild.textContent = health.database_ready ? " 数据库已连接" : " 数据服务未连接";
const databaseReady = Boolean(health.database_ready);
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 = " 数据服务未连接";
connection.lastChild.textContent = ` ${I18N?.t("status.service_disconnected") || "数据服务未连接"}`;
connection.setAttribute("aria-label", I18N?.t("status.service_disconnected") || "数据服务未连接");
}
let months = [];
try { months = await api("/api/months"); } catch (_) { /* handled by load */ }