feat(web): use a range calendar picker for fan dates
This commit is contained in:
1 parent
e9fbbc1650
commit
917a93ab92
2 files changed
+226
-25
No files matched your search
@@ -531,24 +531,101 @@ h1, h2, h3, p { margin: 0; }
|
|||||||
.filter-row .select { width: 100%; max-width: none; }
|
.filter-row .select { width: 100%; max-width: none; }
|
||||||
}
|
}
|
||||||
.growth-days { padding-top: 2px; }
|
.growth-days { padding-top: 2px; }
|
||||||
.date-range {
|
.fans-range {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 8px;
|
||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
}
|
}
|
||||||
.date-range input[type="date"] {
|
.fans-range .chip-row { flex: 0 0 auto; min-width: 0; }
|
||||||
|
.fan-range-picker { position: relative; display: flex; align-items: center; gap: 6px; }
|
||||||
|
@media (min-width: 900px) {
|
||||||
|
.fans-range { flex-wrap: nowrap; }
|
||||||
|
.fans-range .chip-row { flex: 1 1 auto; }
|
||||||
|
.fan-range-picker { flex: 0 0 auto; }
|
||||||
|
}
|
||||||
|
@media (max-width: 899px) {
|
||||||
|
.fans-range .chip-row { flex-basis: 100%; }
|
||||||
|
}
|
||||||
|
.date-range-trigger {
|
||||||
min-height: 38px;
|
min-height: 38px;
|
||||||
padding: 8px 11px;
|
padding: 8px 12px;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
color: inherit;
|
color: inherit;
|
||||||
font: inherit;
|
font: inherit;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
|
white-space: nowrap;
|
||||||
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
.date-range span { color: var(--muted); font-size: 12px; }
|
.date-range-trigger:hover { border-color: var(--accent); color: var(--accent); }
|
||||||
|
.range-popover {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 50;
|
||||||
|
top: calc(100% + 6px);
|
||||||
|
left: 0;
|
||||||
|
width: min(300px, calc(100vw - 24px));
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 14px;
|
||||||
|
background: var(--surface);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
.range-popover-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.range-popover-head strong { font-size: 13px; }
|
||||||
|
.range-nav {
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.range-weekdays,
|
||||||
|
.range-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(7, 1fr);
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
.range-weekdays {
|
||||||
|
margin-bottom: 4px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 11px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.range-empty { height: 32px; }
|
||||||
|
.range-day {
|
||||||
|
height: 32px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.range-day:hover { background: var(--surface-2); }
|
||||||
|
.range-day.in-range { background: var(--accent-soft); color: var(--accent-strong); }
|
||||||
|
.range-day.is-start,
|
||||||
|
.range-day.is-end { background: var(--accent); color: var(--accent-contrast); font-weight: 650; }
|
||||||
|
.range-popover-foot {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
.range-hint { color: var(--muted); font-size: 11.5px; }
|
||||||
|
|
||||||
.rank-list { display: flex; flex-direction: column; gap: 8px; }
|
.rank-list { display: flex; flex-direction: column; gap: 8px; }
|
||||||
.pager {
|
.pager {
|
||||||
|
|||||||
@@ -2,11 +2,130 @@ import { api } from "../api.js";
|
|||||||
import { centerActiveChips, emptyState, sectionHeader } from "../components.js";
|
import { centerActiveChips, emptyState, sectionHeader } from "../components.js";
|
||||||
import { lineChart, themeColor } from "../charts.js";
|
import { lineChart, themeColor } from "../charts.js";
|
||||||
import {
|
import {
|
||||||
escapeAttr, escapeHtml, formatCount, formatDelta, formatNumber,
|
escapeHtml, formatCount, formatDelta, formatNumber,
|
||||||
} from "../format.js";
|
} from "../format.js";
|
||||||
import { replaceQuery } from "../router.js";
|
import { replaceQuery } from "../router.js";
|
||||||
|
|
||||||
const RANGES = [["7", "7 天"], ["30", "30 天"], ["90", "90 天"], ["3650", "全部"]];
|
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 && !picker.contains(event.target)) 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 }) {
|
export async function renderFans(root, { query }) {
|
||||||
const days = query.get("days") || "90";
|
const days = query.get("days") || "90";
|
||||||
@@ -49,20 +168,35 @@ export async function renderFans(root, { query }) {
|
|||||||
|
|
||||||
<section class="panel chart-panel">
|
<section class="panel chart-panel">
|
||||||
${sectionHeader("粉丝曲线")}
|
${sectionHeader("粉丝曲线")}
|
||||||
|
<div class="fans-range">
|
||||||
<div class="chip-row scroll-x">
|
<div class="chip-row scroll-x">
|
||||||
${RANGES.map(([value, label]) => `<button type="button"
|
${RANGES.map(([value, label]) => `<button type="button"
|
||||||
class="chip${!customRange && days === value ? " active" : ""}" data-days="${value}">${label}</button>`).join("")}
|
class="chip${!customRange && days === value ? " active" : ""}" data-days="${value}">${label}</button>`).join("")}
|
||||||
</div>
|
</div>
|
||||||
<form class="date-range" id="fan-range">
|
<div class="fan-range-picker" id="fan-range-picker">
|
||||||
<input type="date" name="start" value="${escapeAttr(start)}" aria-label="开始日期">
|
<button type="button" class="date-range-trigger" id="fan-range-trigger"
|
||||||
<span>至</span>
|
aria-haspopup="dialog" aria-expanded="false">
|
||||||
<input type="date" name="end" value="${escapeAttr(end)}" aria-label="结束日期">
|
${start && end ? `${escapeHtml(start)} ~ ${escapeHtml(end)}` : "选择日期范围"}
|
||||||
<button class="button" type="submit">应用</button>
|
</button>
|
||||||
${customRange ? `<button class="button ghost" type="button" id="fan-range-clear">清除</button>` : ""}
|
${customRange ? `<button class="button ghost" type="button" id="fan-range-clear">清除</button>` : ""}
|
||||||
</form>
|
<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>
|
<div class="chart" id="fans-chart"></div>
|
||||||
<p class="section-note">
|
<p class="section-note">
|
||||||
只绘制粉丝数,不叠加 reaction 视频数据;缺失日断开,不插值。
|
缺失日断开,不插值。
|
||||||
${fans.mode === "monthly"
|
${fans.mode === "monthly"
|
||||||
? "当前范围超过一年,按每月粉丝峰值所在日期绘制。"
|
? "当前范围超过一年,按每月粉丝峰值所在日期绘制。"
|
||||||
: `当前按日绘制(${escapeHtml(fans.range_start || "—")} ~ ${escapeHtml(fans.range_end || "—")})。`}
|
: `当前按日绘制(${escapeHtml(fans.range_start || "—")} ~ ${escapeHtml(fans.range_end || "—")})。`}
|
||||||
@@ -74,7 +208,7 @@ export async function renderFans(root, { query }) {
|
|||||||
<p class="section-note">默认展示最近 ${fans.recent_days || 14} 天;更早的数据只在曲线中显示。</p>
|
<p class="section-note">默认展示最近 ${fans.recent_days || 14} 天;更早的数据只在曲线中显示。</p>
|
||||||
<div class="table-wrap">
|
<div class="table-wrap">
|
||||||
<table class="data-table">
|
<table class="data-table">
|
||||||
<thead><tr><th>日期</th><th>粉丝</th><th>较上一成功日</th></tr></thead>
|
<thead><tr><th>日期</th><th>粉丝</th><th>变化值</th></tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
${(fans.recent_points || []).slice().reverse().map((point) => `
|
${(fans.recent_points || []).slice().reverse().map((point) => `
|
||||||
<tr class="${point.ok ? "" : "failed-row"}">
|
<tr class="${point.ok ? "" : "failed-row"}">
|
||||||
@@ -94,20 +228,10 @@ export async function renderFans(root, { query }) {
|
|||||||
root.querySelector("#fan-mid")?.addEventListener("change", (event) => {
|
root.querySelector("#fan-mid")?.addEventListener("change", (event) => {
|
||||||
replaceQuery({ mid: event.target.value });
|
replaceQuery({ mid: event.target.value });
|
||||||
});
|
});
|
||||||
root.querySelector("#fan-range")?.addEventListener("submit", (event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
const form = new FormData(event.target);
|
|
||||||
const nextStart = String(form.get("start") || "").trim();
|
|
||||||
const nextEnd = String(form.get("end") || "").trim();
|
|
||||||
if (nextStart && nextEnd && nextStart > nextEnd) {
|
|
||||||
window.alert("开始日期不能晚于结束日期");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
replaceQuery({ start: nextStart || null, end: nextEnd || null, days: null });
|
|
||||||
});
|
|
||||||
root.querySelector("#fan-range-clear")?.addEventListener("click", () => {
|
root.querySelector("#fan-range-clear")?.addEventListener("click", () => {
|
||||||
replaceQuery({ start: null, end: null, days: null });
|
replaceQuery({ start: null, end: null, days: null });
|
||||||
});
|
});
|
||||||
|
setupRangePicker(root, { start, end, defaultDate: fans.range_end || end });
|
||||||
centerActiveChips(root);
|
centerActiveChips(root);
|
||||||
const chartElement = root.querySelector("#fans-chart");
|
const chartElement = root.querySelector("#fans-chart");
|
||||||
if (chartElement) {
|
if (chartElement) {
|
||||||
|
|||||||
Reference in new issue
Block a user