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 += ''; } 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 += ``; } 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 = `

粉丝

${escapeHtml(fans.name || "粉丝追踪")}

${options.length > 1 ? `` : ""}
${!fans.available ? emptyState("暂无粉丝数据", "每日快照完成后会开始记录粉丝数。") : `
当前粉丝 ${formatCount(fans.follower)} ${escapeHtml(fans.stat_date || "—")} · 首次记录 ${escapeHtml(fans.first_date || "—")}
日增${fans.delta_1d === null ? "—" : formatDelta(fans.delta_1d)}
7 日净增${fans.delta_7d === null ? "—" : formatDelta(fans.delta_7d)}
累计增长(${escapeHtml(fans.first_date || "—")} 以来)${formatDelta(fans.delta_cum)}
${sectionHeader("粉丝曲线")}
${RANGES.map(([value, label]) => ``).join("")}
${customRange ? `` : ""}

缺失日断开,不插值。 ${fans.mode === "monthly" ? "当前范围超过一年,按每月粉丝峰值所在日期绘制。" : `当前按日绘制(${escapeHtml(fans.range_start || "—")} ~ ${escapeHtml(fans.range_end || "—")})。`}

${sectionHeader("最近记录")}

默认展示最近 ${fans.recent_days || 14} 天;更早的数据只在曲线中显示。

${(fans.recent_points || []).slice().reverse().map((point) => ` `).join("")}
日期粉丝变化值
${escapeHtml(point.stat_date)} ${point.ok ? formatNumber(point.follower) : "—"} ${point.delta === null || point.delta === undefined ? "—" : formatDelta(point.delta)}
`}
`; 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, }); } }