feat(web): add mobile-first read-only dashboard frontend
This commit is contained in:
1 parent
257fbe009b
commit
cbba405271
18 files changed
+2628
-2
No files matched your search
@@ -0,0 +1,88 @@
|
||||
import { api } from "../api.js";
|
||||
import { emptyState, sectionHeader } from "../components.js";
|
||||
import { lineChart } from "../charts.js";
|
||||
import {
|
||||
escapeAttr, escapeHtml, formatCount, formatDate, formatDelta,
|
||||
} from "../format.js";
|
||||
import { replaceQuery } from "../router.js";
|
||||
|
||||
const RANGES = [["7", "7 天"], ["30", "30 天"], ["90", "90 天"], ["3650", "全部"]];
|
||||
|
||||
export async function renderFans(root, { query }) {
|
||||
const days = query.get("days") || "90";
|
||||
const mid = query.get("mid");
|
||||
const fans = await api("/api/fans", { days, 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>累计增长</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>
|
||||
|
||||
<section class="panel chart-panel">
|
||||
${sectionHeader("粉丝曲线")}
|
||||
<div class="chip-row scroll-x">
|
||||
${RANGES.map(([value, label]) => `<button type="button"
|
||||
class="chip${days === value ? " active" : ""}" data-days="${value}">${label}</button>`).join("")}
|
||||
</div>
|
||||
<div class="chart" id="fans-chart"></div>
|
||||
<p class="section-note">只绘制粉丝数,不叠加 reaction 视频数据;缺失日断开,不插值。</p>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
${sectionHeader("最近记录")}
|
||||
<div class="table-wrap">
|
||||
<table class="data-table">
|
||||
<thead><tr><th>日期</th><th>粉丝</th><th>关注</th><th>状态</th></tr></thead>
|
||||
<tbody>
|
||||
${(fans.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 ? "成功" : "失败"}</td>
|
||||
</tr>`).join("")}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>`}
|
||||
</section>`;
|
||||
|
||||
root.querySelectorAll("[data-days]").forEach((button) => button.addEventListener("click", () => {
|
||||
replaceQuery({ days: button.dataset.days });
|
||||
}));
|
||||
root.querySelector("#fan-mid")?.addEventListener("change", (event) => {
|
||||
replaceQuery({ mid: event.target.value });
|
||||
});
|
||||
const chartElement = root.querySelector("#fans-chart");
|
||||
if (chartElement) {
|
||||
lineChart(chartElement, {
|
||||
dates: fans.dates || [],
|
||||
values: fans.series || [],
|
||||
name: "粉丝",
|
||||
color: "#9b7bff",
|
||||
scale: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user