(() => { "use strict"; const colors = ["#2563eb", "#0f9f6e", "#7a5af8", "#f79009", "#06aed5", "#e0528d", "#64748b", "#84cc16"]; const BUSINESS_TIME_ZONE = "Asia/Bangkok"; const $ = (selector) => document.querySelector(selector); 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); } async function api(path) { const response = await fetch(path, { credentials: "same-origin" }); 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 = `

${escapeHtml(message)}

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

${escapeHtml(message)}

`; $("#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) => ` ${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("") : '

暂无销售数据

'; [...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 = '

暂无渠道与房型数据

'; 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])); $("#h5-matrix").innerHTML = `${rooms.map((room) => ``).join("")}${rows}${rooms.map((room) => ``).join("")}
渠道${escapeHtml(room)}合计
合计${integer(totals.get(room) || 0)}${integer(data.overall.totals.rooms_sold)}
`; } 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 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) => ``).join("") : ``; select.addEventListener("change", load); await load(); } boot(); })();