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,164 @@
|
||||
import { api, apiUrl } from "../api.js";
|
||||
import {
|
||||
chip, emptyState, pager, sectionHeader, upCard, videoCard,
|
||||
} from "../components.js";
|
||||
import { escapeAttr, escapeHtml, formatCount } from "../format.js";
|
||||
import { replaceQuery } from "../router.js";
|
||||
|
||||
const VIDEO_SORTS = [
|
||||
["heat", "综合热度"], ["view", "播放"], ["like", "点赞"], ["coin", "投币"],
|
||||
["favorite", "收藏"], ["share", "转发"], ["reply", "评论"], ["danmaku", "弹幕"],
|
||||
["interaction", "互动率"], ["growth", "增长"], ["growth_rate", "增速"],
|
||||
];
|
||||
const UP_SORTS = [
|
||||
["views", "总播放"], ["count", "视频数"], ["avg_views", "平均播放"],
|
||||
["interaction", "总互动"], ["avg_interaction", "平均互动"], ["latest", "最近发布"],
|
||||
];
|
||||
const METRIC_PRIMARY = new Set(["view", "like", "coin", "favorite", "share", "reply", "danmaku"]);
|
||||
|
||||
function controls({ scope, sort, within, growthDays, q, folder, minVideos, folders }) {
|
||||
const sorts = scope === "up" ? UP_SORTS : VIDEO_SORTS;
|
||||
return `
|
||||
<div class="segmented" role="tablist">
|
||||
<button type="button" class="${scope === "video" ? "active" : ""}" data-scope="video">视频</button>
|
||||
<button type="button" class="${scope === "up" ? "active" : ""}" data-scope="up">UP</button>
|
||||
</div>
|
||||
<div class="chip-row scroll-x">
|
||||
${sorts.map(([value, label]) => chip(label, sort === value, "sort", value)).join("")}
|
||||
</div>
|
||||
<form class="filter-bar" id="rank-filter">
|
||||
<input class="search-input" type="search" name="q" value="${escapeAttr(q || "")}"
|
||||
placeholder="${scope === "up" ? "搜索 UP 名" : "搜索标题或 UP"}" autocomplete="off">
|
||||
<button class="button" type="submit">搜索</button>
|
||||
</form>
|
||||
<div class="filter-row">
|
||||
<div class="chip-row scroll-x">
|
||||
${[["all", "全部发布时间"], ["7", "近 7 天"], ["30", "近 30 天"], ["90", "近 90 天"]]
|
||||
.map(([value, label]) => chip(label, String(within || "all") === value, "within", value)).join("")}
|
||||
</div>
|
||||
${scope === "video" && folders.length ? `<select class="select" id="folder-filter">
|
||||
<option value="">全部收藏夹</option>
|
||||
${folders.map((item) => `<option value="${item.media_id}"
|
||||
${String(folder) === String(item.media_id) ? "selected" : ""}>${escapeHtml(item.title || item.year)}</option>`).join("")}
|
||||
</select>` : ""}
|
||||
${scope === "up" ? `<select class="select" id="min-videos">
|
||||
<option value="1" ${String(minVideos) === "1" ? "selected" : ""}>全部 UP</option>
|
||||
<option value="2" ${String(minVideos) === "2" ? "selected" : ""}>≥ 2 条视频</option>
|
||||
<option value="5" ${String(minVideos) === "5" ? "selected" : ""}>≥ 5 条视频</option>
|
||||
</select>` : ""}
|
||||
</div>
|
||||
${scope === "video" && (sort === "growth" || sort === "growth_rate") ? `
|
||||
<div class="chip-row growth-days">
|
||||
<span class="chip-label">增长区间</span>
|
||||
${[[1, "1 天"], [7, "7 天"], [30, "30 天"]]
|
||||
.map(([value, label]) => chip(label, Number(growthDays) === value, "growth-days", value)).join("")}
|
||||
</div>` : ""}
|
||||
`;
|
||||
}
|
||||
|
||||
function listHtml(data, scope, sort, startRank) {
|
||||
if (!data.items.length) {
|
||||
return emptyState(
|
||||
data.available === false ? "增长榜暂不可用" : "没有匹配的视频",
|
||||
data.available === false
|
||||
? (data.reason || "需要至少两天的快照。")
|
||||
: "换个筛选条件或清空搜索关键词试试。",
|
||||
);
|
||||
}
|
||||
if (scope === "up") {
|
||||
return `<div class="rank-list">${data.items.map((item, index) => upCard(item, { rank: startRank + index })).join("")}</div>`;
|
||||
}
|
||||
const primary = METRIC_PRIMARY.has(sort) ? sort : "view";
|
||||
return `<div class="rank-list">${data.items.map((item, index) =>
|
||||
videoCard(item, { rank: startRank + index, primary })).join("")}</div>`;
|
||||
}
|
||||
|
||||
export async function renderRankings(root, { query }) {
|
||||
const scope = query.get("scope") === "up" ? "up" : "video";
|
||||
const defaultSort = scope === "up" ? "views" : "heat";
|
||||
const sort = query.get("sort") || defaultSort;
|
||||
const within = query.get("within_days") || "all";
|
||||
const growthDays = query.get("growth_days") || "7";
|
||||
const q = query.get("q") || "";
|
||||
const folder = query.get("folder") || "";
|
||||
const minVideos = query.get("min_videos") || "1";
|
||||
const page = Math.max(1, Number(query.get("page") || 1));
|
||||
|
||||
const overview = await api("/api/overview");
|
||||
const folders = overview.folders || [];
|
||||
|
||||
const params = {
|
||||
sort,
|
||||
page,
|
||||
page_size: 20,
|
||||
within_days: within === "all" ? null : within,
|
||||
growth_days: growthDays,
|
||||
q,
|
||||
};
|
||||
let data;
|
||||
if (scope === "up") {
|
||||
data = await api("/api/rankings", {
|
||||
...params, scope: "up", min_videos: minVideos,
|
||||
});
|
||||
} else {
|
||||
data = await api("/api/videos", { ...params, folder: folder || null });
|
||||
}
|
||||
|
||||
const startRank = (data.page - 1) * data.page_size + 1;
|
||||
const exportParams = {
|
||||
...params, folder: folder || null, scope,
|
||||
min_videos: scope === "up" ? minVideos : null,
|
||||
};
|
||||
delete exportParams.page;
|
||||
delete exportParams.page_size;
|
||||
|
||||
root.innerHTML = `<section class="page rankings-page">
|
||||
<header class="page-head">
|
||||
<div>
|
||||
<h1>榜单</h1>
|
||||
<p class="page-sub">共 ${formatCount(data.total)} ${scope === "up" ? "个 UP" : "条视频"}</p>
|
||||
</div>
|
||||
${scope === "video"
|
||||
? `<a class="button ghost" href="${escapeAttr(apiUrl("/api/export.csv", exportParams))}">导出 CSV</a>`
|
||||
: ""}
|
||||
</header>
|
||||
${controls({ scope, sort, within, growthDays, q, folder, minVideos, folders })}
|
||||
${data.available === false ? `<div class="notice-row warning">
|
||||
<strong>增长数据尚不可用</strong><span>${escapeHtml(data.reason || "")}</span>
|
||||
</div>` : ""}
|
||||
${listHtml(data, scope, sort, startRank)}
|
||||
${pager(data.page, data.pages)}
|
||||
</section>`;
|
||||
|
||||
root.querySelectorAll("[data-scope]").forEach((button) => button.addEventListener("click", () => {
|
||||
replaceQuery({
|
||||
scope: button.dataset.scope,
|
||||
sort: button.dataset.scope === "up" ? "views" : "heat",
|
||||
page: null,
|
||||
});
|
||||
}));
|
||||
root.querySelectorAll("[data-sort]").forEach((button) => button.addEventListener("click", () => {
|
||||
replaceQuery({ sort: button.dataset.sort, page: null });
|
||||
}));
|
||||
root.querySelectorAll("[data-within]").forEach((button) => button.addEventListener("click", () => {
|
||||
replaceQuery({ within_days: button.dataset.within === "all" ? null : button.dataset.within, page: null });
|
||||
}));
|
||||
root.querySelectorAll("[data-growth-days]").forEach((button) => button.addEventListener("click", () => {
|
||||
replaceQuery({ growth_days: button.dataset.growthDays, page: null });
|
||||
}));
|
||||
root.querySelector("#folder-filter")?.addEventListener("change", (event) => {
|
||||
replaceQuery({ folder: event.target.value || null, page: null });
|
||||
});
|
||||
root.querySelector("#min-videos")?.addEventListener("change", (event) => {
|
||||
replaceQuery({ min_videos: event.target.value, page: null });
|
||||
});
|
||||
root.querySelector("#rank-filter")?.addEventListener("submit", (event) => {
|
||||
event.preventDefault();
|
||||
const value = new FormData(event.target).get("q") || "";
|
||||
replaceQuery({ q: String(value).trim() || null, page: null });
|
||||
});
|
||||
root.querySelectorAll("[data-page]").forEach((button) => button.addEventListener("click", () => {
|
||||
if (button.disabled) return;
|
||||
replaceQuery({ page: button.dataset.page });
|
||||
}));
|
||||
}
|
||||
Reference in new issue
Block a user