Drop nested home panels, overlay rank and duration on covers, and keep metrics fully visible by moving dates onto the author row.
130 lines
5.3 KiB
JavaScript
130 lines
5.3 KiB
JavaScript
import { api } from "../api.js";
|
|
import { emptyState, mediaCard } from "../components.js";
|
|
import {
|
|
escapeAttr, escapeHtml, formatCount, formatDelta,
|
|
formatPercent, formatScore,
|
|
} from "../format.js";
|
|
import { canAccessProtected } from "../session.js";
|
|
|
|
function statChip(label, value, { href = null, tone = "" } = {}) {
|
|
const inner = `<span>${escapeHtml(label)}</span><strong>${value}</strong>`;
|
|
if (href) {
|
|
return `<a class="live-chip ${tone}" href="${escapeAttr(href)}">${inner}</a>`;
|
|
}
|
|
return `<div class="live-chip ${tone}">${inner}</div>`;
|
|
}
|
|
|
|
function heatGrid(items) {
|
|
if (!items.length) return emptyState("暂无热度数据", "完成一次快照后这里会出现榜单。");
|
|
return `<div class="media-grid">
|
|
${items.map((item, index) => mediaCard(item, {
|
|
rank: index + 1,
|
|
highlight: `热度 ${formatScore(item.heat)} · 前 ${Math.max(0, 100 - (item.heat_percentile || 0)).toFixed(0)}%`,
|
|
})).join("")}
|
|
</div>`;
|
|
}
|
|
|
|
function publishedCard(item) {
|
|
return mediaCard(item, {
|
|
highlight: `${formatCount(item.metrics?.view)} 播放 · 互动 ${formatPercent(item.interaction_rate)}`,
|
|
});
|
|
}
|
|
|
|
function growthCard(item, index) {
|
|
const delta = item.growth?.delta;
|
|
let highlight = `${formatCount(item.metrics?.view)} 播放`;
|
|
if (delta !== null && delta !== undefined) {
|
|
const tone = Number(delta) >= 0 ? "up" : "down";
|
|
highlight += ` <span class="${tone}">${formatDelta(delta)}</span>`;
|
|
}
|
|
return mediaCard(item, { rank: index + 1, highlight });
|
|
}
|
|
|
|
export async function renderHome(root) {
|
|
const [overview, published, heat, growth] = await Promise.all([
|
|
api("/api/overview"),
|
|
api("/api/videos", { sort: "published", within_days: 14, limit: 6 }),
|
|
api("/api/videos", { sort: "heat", limit: 6 }),
|
|
// 不带天数:后端按 home_growth_days(默认 auto:有 7 日则 7,否则 1)决定,响应带 days
|
|
api("/api/videos", { sort: "growth", limit: 6 }),
|
|
]);
|
|
|
|
const fans = overview.fans;
|
|
const firstDay = overview.is_first_day;
|
|
const addedLabel = firstDay
|
|
? formatCount(overview.initial_import_count)
|
|
: formatCount(overview.added_today);
|
|
const activeCount = overview.counts?.active || 0;
|
|
const pending = overview.counts?.candidates_pending || 0;
|
|
// 候选提示仅对已登录(或未启用鉴权)用户可见,访客不展示运行期信息。
|
|
const showCandidates = pending > 0 && canAccessProtected();
|
|
// 以后端返回的 days(与 growth_days 同步)为准,避免前端再探查
|
|
const growthDays = Number(growth.days ?? growth.growth_days) || 1;
|
|
const growthLabel = `近 ${growthDays} 日`;
|
|
const growthEmptyTitle = growth.reason
|
|
|| `近 ${growthDays} 日增长暂不可用`;
|
|
const growthEmptyHint = growthDays <= 1
|
|
? "当前快照不足,完成至少 2 天快照后即可显示播放增量。"
|
|
: `需要至少 ${growthDays + 1} 天快照才能计算近 ${growthDays} 日增长。`;
|
|
|
|
root.innerHTML = `<section class="page home-page">
|
|
<header class="page-head home-head">
|
|
<div>
|
|
<h1>最新动态</h1>
|
|
<p class="page-sub">收录视频 ${formatCount(activeCount)}</p>
|
|
</div>
|
|
<button class="icon-button" id="refresh-home" type="button" title="刷新">刷新</button>
|
|
</header>
|
|
|
|
<div class="live-strip">
|
|
${statChip(firstDay ? "首次导入" : "今日入库", escapeHtml(addedLabel), {
|
|
href: "/videos?sort=added&within_days=7", tone: "accent",
|
|
})}
|
|
${statChip("播放增量", overview.view_delta_available ? formatDelta(overview.view_delta) : "—", {
|
|
href: "/videos?sort=growth&growth_days=1",
|
|
})}
|
|
${statChip("7 日粉丝变化", fans?.delta_7d === null || fans?.delta_7d === undefined ? "—" : formatDelta(fans.delta_7d), {
|
|
href: "/fans",
|
|
})}
|
|
${statChip("数据日期", escapeHtml(overview.data_date || "—"))}
|
|
</div>
|
|
|
|
${showCandidates ? `<a class="notice-row" href="/candidates">
|
|
<strong>${formatCount(pending)} 条候选待确认</strong>
|
|
<span>请在 Telegram 中处理 →</span>
|
|
</a>` : ""}
|
|
|
|
<section class="home-block">
|
|
<div class="section-head">
|
|
<h2>最近发布</h2>
|
|
<a class="text-link" href="/videos?sort=published&within_days=30">查看全部</a>
|
|
</div>
|
|
<p class="section-note">按视频发布时间排序,与入库时间无关。</p>
|
|
<div class="media-grid">
|
|
${published.items.length ? published.items.map((item) => publishedCard(item)).join("")
|
|
: emptyState("最近 14 天没有新发布视频", "搜索发现的新视频会逐步补充。")}
|
|
</div>
|
|
</section>
|
|
|
|
<section class="home-block">
|
|
<div class="section-head">
|
|
<h2>增长动态 · ${escapeHtml(growthLabel)}</h2>
|
|
<a class="text-link" href="/videos?sort=growth&growth_days=${growthDays}">增长榜</a>
|
|
</div>
|
|
${growth.available && growth.items.length
|
|
? `<div class="media-grid">${growth.items.map((item, index) => growthCard(item, index)).join("")}</div>`
|
|
: emptyState(growthEmptyTitle, growthEmptyHint)}
|
|
</section>
|
|
|
|
<section class="home-block">
|
|
<div class="section-head">
|
|
<h2>热度速览</h2>
|
|
<a class="text-link" href="/videos?sort=heat">完整榜单</a>
|
|
</div>
|
|
${heatGrid(heat.items)}
|
|
</section>
|
|
</section>`;
|
|
|
|
root.querySelector("#refresh-home")?.addEventListener("click", () => window.location.reload());
|
|
}
|