250 lines
10 KiB
JavaScript
250 lines
10 KiB
JavaScript
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,
|
||
});
|
||
}
|
||
}
|