feat(web): add fan date range modes and delta column
This commit is contained in:
1 parent
998d0fd0c0
commit
274eccba93
6 files changed
+253
-22
No files matched your search
+120
-15
@@ -7,6 +7,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import calendar
|
||||
import json
|
||||
import math
|
||||
import sqlite3
|
||||
@@ -89,6 +90,84 @@ def _safe_float(value: Any) -> float | None:
|
||||
return None
|
||||
|
||||
|
||||
def _with_fan_deltas(
|
||||
rows: Sequence[Mapping[str, Any]], recent_start: str
|
||||
) -> list[dict[str, Any]]:
|
||||
"""给最近记录附加“相较上一成功日期”的增量。"""
|
||||
result: list[dict[str, Any]] = []
|
||||
last_ok: int | None = None
|
||||
for row in rows:
|
||||
day = str(row["stat_date"])
|
||||
ok = int(row.get("ok") or 0) == 1
|
||||
delta: int | None = None
|
||||
if ok:
|
||||
follower = int(row.get("follower") or 0)
|
||||
if last_ok is not None:
|
||||
delta = follower - last_ok
|
||||
last_ok = follower
|
||||
if day >= recent_start:
|
||||
result.append({**dict(row), "delta": delta})
|
||||
return result
|
||||
|
||||
|
||||
def _daily_fan_series(
|
||||
rows: Sequence[Mapping[str, Any]], start: str, end: str
|
||||
) -> tuple[list[str], list[int | None]]:
|
||||
by_date = {str(row["stat_date"]): row for row in rows}
|
||||
dates = _date_range(start, end)
|
||||
series: list[int | None] = []
|
||||
for day in dates:
|
||||
row = by_date.get(day)
|
||||
if row is None or int(row.get("ok") or 0) != 1:
|
||||
series.append(None)
|
||||
else:
|
||||
series.append(int(row["follower"] or 0))
|
||||
return dates, series
|
||||
|
||||
|
||||
def _monthly_peak_series(
|
||||
rows: Sequence[Mapping[str, Any]], start: str, end: str
|
||||
) -> tuple[list[str], list[int | None]]:
|
||||
"""超过一年时按自然月聚合,每月取粉丝峰值所在的日期。"""
|
||||
start_day = date.fromisoformat(start)
|
||||
end_day = date.fromisoformat(end)
|
||||
peaks: dict[tuple[int, int], tuple[str, int]] = {}
|
||||
for row in rows:
|
||||
if int(row.get("ok") or 0) != 1:
|
||||
continue
|
||||
day = date.fromisoformat(str(row["stat_date"]))
|
||||
key = (day.year, day.month)
|
||||
value = int(row["follower"] or 0)
|
||||
current = peaks.get(key)
|
||||
if current is None or value > current[1] or (
|
||||
value == current[1] and str(row["stat_date"]) > current[0]
|
||||
):
|
||||
peaks[key] = (str(row["stat_date"]), value)
|
||||
|
||||
dates: list[str] = []
|
||||
series: list[int | None] = []
|
||||
year, month = start_day.year, start_day.month
|
||||
while (year, month) <= (end_day.year, end_day.month):
|
||||
peak = peaks.get((year, month))
|
||||
if peak is not None:
|
||||
dates.append(peak[0])
|
||||
series.append(peak[1])
|
||||
else:
|
||||
last_day = calendar.monthrange(year, month)[1]
|
||||
placeholder = date(year, month, last_day)
|
||||
if placeholder < start_day:
|
||||
placeholder = start_day
|
||||
if placeholder > end_day:
|
||||
placeholder = end_day
|
||||
dates.append(placeholder.isoformat())
|
||||
series.append(None)
|
||||
month += 1
|
||||
if month > 12:
|
||||
month = 1
|
||||
year += 1
|
||||
return dates, series
|
||||
|
||||
|
||||
class DashboardQueries:
|
||||
def __init__(
|
||||
self,
|
||||
@@ -899,12 +978,22 @@ class DashboardQueries:
|
||||
mid: int | None = None,
|
||||
*,
|
||||
days: int = 90,
|
||||
start: str | None = None,
|
||||
end: str | None = None,
|
||||
recent_days: int = 14,
|
||||
) -> dict[str, Any]:
|
||||
if days < 1:
|
||||
raise ValueError("days 必须大于 0")
|
||||
if recent_days < 1 or recent_days > 3650:
|
||||
raise ValueError("recent_days 必须在 1~3650 之间")
|
||||
for label, value in (("start", start), ("end", end)):
|
||||
if value:
|
||||
try:
|
||||
date.fromisoformat(value)
|
||||
except ValueError as exc:
|
||||
raise ValueError(f"{label} 必须是 YYYY-MM-DD") from exc
|
||||
if start and end and start > end:
|
||||
raise ValueError("start 不能晚于 end")
|
||||
|
||||
def run(conn: sqlite3.Connection) -> dict[str, Any]:
|
||||
cfg = self.config_store.get()
|
||||
@@ -925,7 +1014,8 @@ class DashboardQueries:
|
||||
"mid": None, "name": None, "available": False,
|
||||
"options": options, "dates": [], "series": [],
|
||||
"points": [], "recent_points": [], "days": days,
|
||||
"recent_days": recent_days,
|
||||
"mode": "daily", "range_start": None, "range_end": None,
|
||||
"span_days": 0, "recent_days": recent_days,
|
||||
}
|
||||
rows = [
|
||||
dict(row) for row in conn.execute(
|
||||
@@ -944,24 +1034,35 @@ class DashboardQueries:
|
||||
"points": [],
|
||||
"recent_points": [],
|
||||
"days": days,
|
||||
"recent_days": recent_days,
|
||||
"mode": "daily", "range_start": None, "range_end": None,
|
||||
"span_days": 0, "recent_days": recent_days,
|
||||
}
|
||||
latest = max(str(row["stat_date"]) for row in rows if int(row["ok"]) == 1) if any(int(row["ok"]) == 1 for row in rows) else str(rows[-1]["stat_date"])
|
||||
first_date = str(rows[0]["stat_date"])
|
||||
recent_start = _date_minus(latest, recent_days - 1)
|
||||
recent_points = [
|
||||
row for row in rows if str(row["stat_date"]) >= recent_start
|
||||
recent_points = _with_fan_deltas(rows, recent_start)
|
||||
if start or end:
|
||||
range_start = max(str(start or first_date), first_date)
|
||||
range_end = min(str(end or latest), latest)
|
||||
if range_start > range_end:
|
||||
raise ValueError("所选日期范围内没有粉丝数据")
|
||||
else:
|
||||
range_start = max(_date_minus(latest, days - 1), first_date)
|
||||
range_end = latest
|
||||
span_days = (
|
||||
date.fromisoformat(range_end) - date.fromisoformat(range_start)
|
||||
).days + 1
|
||||
mode = "daily" if span_days <= 366 else "monthly"
|
||||
points = [
|
||||
row for row in rows
|
||||
if range_start <= str(row["stat_date"]) <= range_end
|
||||
]
|
||||
start = max(_date_minus(latest, days - 1), first_date)
|
||||
dates = _date_range(start, latest)
|
||||
by_date = {str(row["stat_date"]): row for row in rows}
|
||||
series = []
|
||||
for day in dates:
|
||||
row = by_date.get(day)
|
||||
if row is None or int(row["ok"]) != 1:
|
||||
series.append(None)
|
||||
else:
|
||||
series.append(row["follower"])
|
||||
if mode == "daily":
|
||||
dates, series = _daily_fan_series(points, range_start, range_end)
|
||||
else:
|
||||
dates, series = _monthly_peak_series(
|
||||
points, range_start, range_end
|
||||
)
|
||||
ok_rows = [row for row in rows if int(row["ok"]) == 1]
|
||||
current = ok_rows[-1] if ok_rows else rows[-1]
|
||||
previous = ok_rows[-2] if len(ok_rows) >= 2 else None
|
||||
@@ -985,9 +1086,13 @@ class DashboardQueries:
|
||||
"available": bool(ok_rows),
|
||||
"options": options,
|
||||
"days": days,
|
||||
"mode": mode,
|
||||
"range_start": range_start,
|
||||
"range_end": range_end,
|
||||
"span_days": span_days,
|
||||
"dates": dates,
|
||||
"series": series,
|
||||
"points": rows,
|
||||
"points": points,
|
||||
"recent_points": recent_points,
|
||||
"recent_days": recent_days,
|
||||
"stat_date": current["stat_date"],
|
||||
|
||||
Reference in new issue
Block a user