diff --git a/reaction_board/web/static/style.css b/reaction_board/web/static/style.css index a79de13..fef7888 100644 --- a/reaction_board/web/static/style.css +++ b/reaction_board/web/static/style.css @@ -531,24 +531,101 @@ h1, h2, h3, p { margin: 0; } .filter-row .select { width: 100%; max-width: none; } } .growth-days { padding-top: 2px; } -.date-range { +.fans-range { display: flex; flex-wrap: wrap; align-items: center; - gap: 6px; + gap: 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; - padding: 8px 11px; + padding: 8px 12px; border: 1px solid var(--border); border-radius: 10px; background: var(--surface); color: inherit; font: inherit; 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; } .pager { diff --git a/reaction_board/web/static/views/fans.js b/reaction_board/web/static/views/fans.js index 8262957..87ee378 100644 --- a/reaction_board/web/static/views/fans.js +++ b/reaction_board/web/static/views/fans.js @@ -2,11 +2,130 @@ import { api } from "../api.js"; import { centerActiveChips, emptyState, sectionHeader } from "../components.js"; import { lineChart, themeColor } from "../charts.js"; import { - escapeAttr, escapeHtml, formatCount, formatDelta, formatNumber, + 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 && !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 }) { const days = query.get("days") || "90"; @@ -49,20 +168,35 @@ export async function renderFans(root, { query }) {
${sectionHeader("粉丝曲线")} -
- ${RANGES.map(([value, label]) => ``).join("")} +
+
+ ${RANGES.map(([value, label]) => ``).join("")} +
+
+ + ${customRange ? `` : ""} + +
-
- - 至 - - - ${customRange ? `` : ""} -

- 只绘制粉丝数,不叠加 reaction 视频数据;缺失日断开,不插值。 + 缺失日断开,不插值。 ${fans.mode === "monthly" ? "当前范围超过一年,按每月粉丝峰值所在日期绘制。" : `当前按日绘制(${escapeHtml(fans.range_start || "—")} ~ ${escapeHtml(fans.range_end || "—")})。`} @@ -74,7 +208,7 @@ export async function renderFans(root, { query }) {

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

- + ${(fans.recent_points || []).slice().reverse().map((point) => ` @@ -94,20 +228,10 @@ export async function renderFans(root, { query }) { root.querySelector("#fan-mid")?.addEventListener("change", (event) => { 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", () => { 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) {
日期粉丝较上一成功日
日期粉丝变化值