Files
re-xxm/reaction_board/web/static/views/fans.js
T

250 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.
import { api } from "../api.js";
import { centerActiveChips, emptyState, sectionHeader } from "../components.js";
import { lineChart, themeColor } from "../charts.js";
import {
escapeHtml, formatCount, formatDelta, formatNumber,
} from "../format.js";
import { replaceQuery } from "../router.js";
const RANGES = [["7", "7 天"], ["30", "30 天"], ["90", "90 天"], ["3650", "全部"]];
let pickerCleanup = null;
function pad2(value) {
return String(value).padStart(2, "0");
}
function isoDay(year, month, day) {
return `${year}-${pad2(month)}-${pad2(day)}`;
}
function setupRangePicker(root, { start, end, defaultDate }) {
const picker = root.querySelector("#fan-range-picker");
if (!picker) return;
const trigger = picker.querySelector("#fan-range-trigger");
const popover = picker.querySelector("#fan-range-popover");
const grid = picker.querySelector("#fan-range-grid");
const monthLabel = picker.querySelector("#fan-range-month");
const hint = picker.querySelector("#fan-range-hint");
const apply = picker.querySelector("#fan-range-apply");
const fallback = String(defaultDate || start || "2026-01-01").slice(0, 10);
let viewYear = Number((start || fallback).slice(0, 4));
let viewMonth = Number((start || fallback).slice(5, 7));
let selStart = start || null;
let selEnd = end || null;
const refreshFooter = () => {
if (selStart && selEnd) hint.textContent = `${selStart} ~ ${selEnd}`;
else if (selStart) hint.textContent = "选择结束日期";
else hint.textContent = "选择开始日期";
apply.disabled = !(selStart && selEnd);
};
const renderMonth = () => {
const first = new Date(viewYear, viewMonth - 1, 1);
const offset = (first.getDay() + 6) % 7; // 周一开头
const total = new Date(viewYear, viewMonth, 0).getDate();
monthLabel.textContent = `${viewYear} 年 ${viewMonth} 月`;
let html = "";
for (let index = 0; index < offset; index += 1) {
html += '<span class="range-empty"></span>';
}
for (let day = 1; day <= total; day += 1) {
const iso = isoDay(viewYear, viewMonth, day);
const classes = ["range-day"];
if (iso === selStart) classes.push("is-start");
if (iso === selEnd) classes.push("is-end");
if (selStart && selEnd && iso > selStart && iso < selEnd) classes.push("in-range");
html += `<button type="button" class="${classes.join(" ")}" data-day="${iso}">${day}</button>`;
}
grid.innerHTML = html;
};
const close = () => {
popover.hidden = true;
trigger.setAttribute("aria-expanded", "false");
};
const open = () => {
popover.hidden = false;
trigger.setAttribute("aria-expanded", "true");
if (!selStart && !selEnd && fallback) {
viewYear = Number(fallback.slice(0, 4));
viewMonth = Number(fallback.slice(5, 7));
}
renderMonth();
refreshFooter();
};
trigger.addEventListener("click", () => {
if (popover.hidden) open();
else close();
});
picker.querySelectorAll("[data-nav]").forEach((button) => button.addEventListener("click", () => {
viewMonth += Number(button.dataset.nav);
if (viewMonth < 1) { viewMonth = 12; viewYear -= 1; }
else if (viewMonth > 12) { viewMonth = 1; viewYear += 1; }
renderMonth();
}));
grid.addEventListener("click", (event) => {
const dayButton = event.target.closest("[data-day]");
if (!dayButton) return;
event.stopPropagation();
const day = dayButton.dataset.day;
if (!selStart || (selStart && selEnd) || day < selStart) {
selStart = day;
selEnd = null;
} else {
selEnd = day;
if (selEnd < selStart) {
const swap = selStart;
selStart = selEnd;
selEnd = swap;
}
}
refreshFooter();
renderMonth();
});
apply.addEventListener("click", () => {
if (!selStart || !selEnd) return;
close();
replaceQuery({ start: selStart, end: selEnd, days: null });
});
pickerCleanup?.();
const onDocumentClick = (event) => {
if (popover.hidden) return;
// 事件路径在派发时确定,即使点击后 grid 被重绘,仍能识别为弹层内部点击。
if (event.composedPath().includes(picker)) return;
close();
};
const onKeydown = (event) => {
if (event.key === "Escape" && !popover.hidden) close();
};
document.addEventListener("click", onDocumentClick);
document.addEventListener("keydown", onKeydown);
pickerCleanup = () => {
document.removeEventListener("click", onDocumentClick);
document.removeEventListener("keydown", onKeydown);
};
renderMonth();
refreshFooter();
}
export async function renderFans(root, { query }) {
const days = query.get("days") || "90";
const mid = query.get("mid");
const start = query.get("start") || "";
const end = query.get("end") || "";
const customRange = Boolean(start || end);
const fans = await api("/api/fans", {
days: customRange ? null : days,
start,
end,
mid,
});
const options = fans.options || [];
root.innerHTML = `<section class="page fans-page">
<header class="page-head">
<div>
<h1>粉丝</h1>
<p class="page-sub">${escapeHtml(fans.name || "粉丝追踪")}</p>
</div>
${options.length > 1 ? `<select class="select" id="fan-mid">
${options.map((item) => `<option value="${item.mid}" ${String(fans.mid) === String(item.mid) ? "selected" : ""}>${escapeHtml(item.name)}</option>`).join("")}
</select>` : ""}
</header>
${!fans.available ? emptyState("暂无粉丝数据", "每日快照完成后会开始记录粉丝数。")
: `
<div class="hero-number">
<span>当前粉丝</span>
<strong>${formatCount(fans.follower)}</strong>
<small>${escapeHtml(fans.stat_date || "—")} · 首次记录 ${escapeHtml(fans.first_date || "—")}</small>
</div>
<div class="mini-stats">
<div><span>日增</span><strong class="${Number(fans.delta_1d) >= 0 ? "up" : "down"}">${fans.delta_1d === null ? "—" : formatDelta(fans.delta_1d)}</strong></div>
<div><span>7 日净增</span><strong class="${Number(fans.delta_7d) >= 0 ? "up" : "down"}">${fans.delta_7d === null ? "—" : formatDelta(fans.delta_7d)}</strong></div>
<div><span>累计增长(${escapeHtml(fans.first_date || "—")} 以来)</span><strong class="${Number(fans.delta_cum) >= 0 ? "up" : "down"}">${formatDelta(fans.delta_cum)}</strong></div>
</div>
<section class="panel chart-panel">
${sectionHeader("粉丝曲线")}
<div class="fans-range">
<div class="chip-row scroll-x">
${RANGES.map(([value, label]) => `<button type="button"
class="chip${!customRange && days === value ? " active" : ""}" data-days="${value}">${label}</button>`).join("")}
</div>
<div class="fan-range-picker" id="fan-range-picker">
<button type="button" class="date-range-trigger" id="fan-range-trigger"
aria-haspopup="dialog" aria-expanded="false">
${start && end ? `${escapeHtml(start)} ~ ${escapeHtml(end)}` : "选择日期范围"}
</button>
${customRange ? `<button class="button ghost" type="button" id="fan-range-clear">清除</button>` : ""}
<div class="range-popover" id="fan-range-popover" role="dialog" aria-label="选择粉丝日期范围" hidden>
<div class="range-popover-head">
<button type="button" class="range-nav" data-nav="-1" aria-label="上个月">‹</button>
<strong id="fan-range-month"></strong>
<button type="button" class="range-nav" data-nav="1" aria-label="下个月">›</button>
</div>
<div class="range-weekdays">${["一","二","三","四","五","六","日"].map((d) => `<span>${d}</span>`).join("")}</div>
<div class="range-grid" id="fan-range-grid"></div>
<div class="range-popover-foot">
<span class="range-hint" id="fan-range-hint">选择开始日期</span>
<button type="button" class="button" id="fan-range-apply" disabled>应用</button>
</div>
</div>
</div>
</div>
<div class="chart" id="fans-chart"></div>
<p class="section-note">
缺失日断开,不插值。
${fans.mode === "monthly"
? "当前范围超过一年,按每月粉丝峰值所在日期绘制。"
: `当前按日绘制(${escapeHtml(fans.range_start || "—")} ~ ${escapeHtml(fans.range_end || "—")})。`}
</p>
</section>
<section class="panel">
${sectionHeader("最近记录")}
<p class="section-note">默认展示最近 ${fans.recent_days || 14} 天;更早的数据只在曲线中显示。</p>
<div class="table-wrap">
<table class="data-table">
<thead><tr><th>日期</th><th>粉丝</th><th>变化值</th></tr></thead>
<tbody>
${(fans.recent_points || []).slice().reverse().map((point) => `
<tr class="${point.ok ? "" : "failed-row"}">
<td>${escapeHtml(point.stat_date)}</td>
<td>${point.ok ? formatNumber(point.follower) : "—"}</td>
<td>${point.delta === null || point.delta === undefined ? "—" : formatDelta(point.delta)}</td>
</tr>`).join("")}
</tbody>
</table>
</div>
</section>`}
</section>`;
root.querySelectorAll("[data-days]").forEach((button) => button.addEventListener("click", () => {
replaceQuery({ days: button.dataset.days, start: null, end: null });
}));
root.querySelector("#fan-mid")?.addEventListener("change", (event) => {
replaceQuery({ mid: event.target.value });
});
root.querySelector("#fan-range-clear")?.addEventListener("click", () => {
replaceQuery({ start: null, end: null, days: null });
});
setupRangePicker(root, { start, end, defaultDate: fans.range_end || end });
centerActiveChips(root);
const chartElement = root.querySelector("#fans-chart");
if (chartElement) {
lineChart(chartElement, {
dates: fans.dates || [],
values: fans.series || [],
name: "粉丝",
color: themeColor("accent"),
scale: true,
});
}
}