feat(web): show cumulative fan range and limit recent records
This commit is contained in:
1 parent
b6a9f598b5
commit
d8e9a7b0f0
5 files changed
+48
-9
No files matched your search
@@ -894,9 +894,17 @@ class DashboardQueries:
|
||||
|
||||
# ------------------------------------------------------------ fans
|
||||
|
||||
def fans(self, mid: int | None = None, *, days: int = 90) -> dict[str, Any]:
|
||||
def fans(
|
||||
self,
|
||||
mid: int | None = None,
|
||||
*,
|
||||
days: int = 90,
|
||||
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 之间")
|
||||
|
||||
def run(conn: sqlite3.Connection) -> dict[str, Any]:
|
||||
cfg = self.config_store.get()
|
||||
@@ -916,7 +924,8 @@ class DashboardQueries:
|
||||
return {
|
||||
"mid": None, "name": None, "available": False,
|
||||
"options": options, "dates": [], "series": [],
|
||||
"points": [], "days": days,
|
||||
"points": [], "recent_points": [], "days": days,
|
||||
"recent_days": recent_days,
|
||||
}
|
||||
rows = [
|
||||
dict(row) for row in conn.execute(
|
||||
@@ -933,10 +942,16 @@ class DashboardQueries:
|
||||
"dates": [],
|
||||
"series": [],
|
||||
"points": [],
|
||||
"recent_points": [],
|
||||
"days": days,
|
||||
"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
|
||||
]
|
||||
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}
|
||||
@@ -973,6 +988,8 @@ class DashboardQueries:
|
||||
"dates": dates,
|
||||
"series": series,
|
||||
"points": rows,
|
||||
"recent_points": recent_points,
|
||||
"recent_days": recent_days,
|
||||
"stat_date": current["stat_date"],
|
||||
"follower": current.get("follower"),
|
||||
"following": current.get("following"),
|
||||
|
||||
@@ -365,6 +365,7 @@ border-color:#3a2c22;color:#f3e9dc}button{background:#e9b97c;color:#2a1a10}}
|
||||
self._send_json(HTTPStatus.OK, queries.fans(
|
||||
_optional_int(query, "mid"),
|
||||
days=_int_param(query, "days", 90, 1, 3650),
|
||||
recent_days=_int_param(query, "recent_days", 14, 1, 3650),
|
||||
))
|
||||
return
|
||||
if path == "/api/candidates":
|
||||
|
||||
@@ -2,7 +2,7 @@ import { api } from "../api.js";
|
||||
import { centerActiveChips, emptyState, sectionHeader } from "../components.js";
|
||||
import { lineChart, themeColor } from "../charts.js";
|
||||
import {
|
||||
escapeAttr, escapeHtml, formatCount, formatDate, formatDelta,
|
||||
escapeHtml, formatCount, formatDelta, formatNumber,
|
||||
} from "../format.js";
|
||||
import { replaceQuery } from "../router.js";
|
||||
|
||||
@@ -36,8 +36,7 @@ export async function renderFans(root, { query }) {
|
||||
<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>累计增长</span><strong class="${Number(fans.delta_cum) >= 0 ? "up" : "down"}">${formatDelta(fans.delta_cum)}</strong></div>
|
||||
<div><span>关注数</span><strong>${formatCount(fans.following)}</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">
|
||||
@@ -52,15 +51,15 @@ export async function renderFans(root, { query }) {
|
||||
|
||||
<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><th>状态</th></tr></thead>
|
||||
<thead><tr><th>日期</th><th>粉丝</th><th>状态</th></tr></thead>
|
||||
<tbody>
|
||||
${(fans.points || []).slice().reverse().map((point) => `
|
||||
${(fans.recent_points || []).slice().reverse().map((point) => `
|
||||
<tr class="${point.ok ? "" : "failed-row"}">
|
||||
<td>${escapeHtml(point.stat_date)}</td>
|
||||
<td>${point.ok ? formatCount(point.follower) : "—"}</td>
|
||||
<td>${point.ok ? formatCount(point.following) : "—"}</td>
|
||||
<td>${point.ok ? formatNumber(point.follower) : "—"}</td>
|
||||
<td>${point.ok ? "成功" : "失败"}</td>
|
||||
</tr>`).join("")}
|
||||
</tbody>
|
||||
|
||||
@@ -213,6 +213,19 @@ def test_fans_candidates_system_and_export():
|
||||
wide = q.fans(days=30)
|
||||
assert wide["dates"] == list(DATES)
|
||||
|
||||
# “最近记录”只返回最近 14 天,早于窗口的记录不进入表格
|
||||
db.write(lambda conn: conn.execute(
|
||||
"INSERT OR REPLACE INTO up_stat"
|
||||
"(stat_date, mid, follower, following, captured_at, ok) "
|
||||
"VALUES (?, ?, ?, NULL, 1, 1)",
|
||||
("2026-10-01", 37754047, 900),
|
||||
))
|
||||
recent = q.fans(days=90)
|
||||
assert recent["recent_days"] == 14
|
||||
assert [point["stat_date"] for point in recent["recent_points"]] == list(DATES)
|
||||
one_day = q.fans(days=90, recent_days=1)
|
||||
assert [point["stat_date"] for point in one_day["recent_points"]] == [DATES[-1]]
|
||||
|
||||
# 缺失前一天时不能把跨多天的变化标成“日增”
|
||||
db.write(lambda conn: conn.execute(
|
||||
"DELETE FROM up_stat WHERE mid=37754047 AND stat_date=?",
|
||||
|
||||
@@ -102,6 +102,15 @@ def test_readonly_routes_and_static():
|
||||
assert status == 200
|
||||
assert json.loads(payload)["data"]["sort"] == "avg_views"
|
||||
|
||||
status, _, payload = running.request("/api/fans?days=7&recent_days=14")
|
||||
assert status == 200
|
||||
fans = json.loads(payload)["data"]
|
||||
assert fans["recent_days"] == 14
|
||||
assert all(
|
||||
point["stat_date"] >= "2026-10-18"
|
||||
for point in fans["recent_points"]
|
||||
)
|
||||
|
||||
status, headers, payload = running.request("/api/videos", method="HEAD")
|
||||
assert status == 200 and payload == b""
|
||||
assert "Content-Length" in headers
|
||||
|
||||
Reference in new issue
Block a user