feat(web): dropdown sort and time filters with year support

This commit is contained in:
kairee committed 2026-11-01 12:45:40 +08:00
1 parent bc3bef0743
commit 9be7d1cab2
10 files changed
+191 -67

No files matched your search

+54 -14
View File
@@ -286,6 +286,7 @@ class DashboardQueries:
q: str | None = None,
within_field: str | None = None,
within_start: str | None = None,
within_end: str | None = None,
up_mid: int | None = None,
) -> list[dict[str, Any]]:
clauses = [f"v.status IN ({','.join('?' for _ in statuses)})"]
@@ -302,12 +303,17 @@ class DashboardQueries:
"(v.title LIKE ? ESCAPE '\\' OR v.up_name LIKE ? ESCAPE '\\')"
)
params.extend([pattern, pattern])
if within_field and within_start:
if within_field:
if within_field not in {"pubdate", "first_seen"}:
raise ValueError("within_field 必须是 pubdate 或 first_seen")
start_ts, _ = day_bounds(within_start)
clauses.append(f"v.{within_field} >= ?")
params.append(start_ts)
if within_start:
start_ts, _ = day_bounds(within_start)
clauses.append(f"v.{within_field} >= ?")
params.append(start_ts)
if within_end:
end_ts, _ = day_bounds(within_end)
clauses.append(f"v.{within_field} < ?")
params.append(end_ts)
sql = (
"SELECT v.*, f.title AS folder_title, f.year AS folder_year "
"FROM video v LEFT JOIN folder f ON f.media_id = v.folder_id "
@@ -490,6 +496,14 @@ class DashboardQueries:
cfg = self.config_store.get()
fans = self._fans_overview(conn, cfg.track_fans, latest, snapshot_days)
folders = self._folders_overview(conn)
pub_years = sorted(
{
int(shanghai_date(int(video["pubdate"]))[:4])
for video in videos
if int(video.get("pubdate") or 0) > 0
},
reverse=True,
)
tasks = self._task_records(conn)
auth = {
"ok": self._kv(conn, "auth:ok"),
@@ -527,6 +541,7 @@ class DashboardQueries:
"snapshot_completed_today": snapshot_completed_today,
"fans": fans,
"folders": folders,
"years": pub_years,
"tasks": tasks,
"auth": auth,
"preview": preview,
@@ -650,6 +665,7 @@ class DashboardQueries:
*,
sort: str = "heat",
within_days: int | None = None,
year: int | None = None,
growth_days: int = 7,
folder_id: int | None = None,
q: str | None = None,
@@ -659,6 +675,7 @@ class DashboardQueries:
params = {
"sort": sort,
"within_days": within_days,
"year": year,
"growth_days": growth_days,
"folder_id": folder_id,
"q": q or "",
@@ -669,6 +686,7 @@ class DashboardQueries:
"videos", params, TTL_VIDEOS, lambda: self._list_videos_uncached(
sort=sort,
within_days=within_days,
year=year,
growth_days=growth_days,
folder_id=folder_id,
q=q,
@@ -682,6 +700,7 @@ class DashboardQueries:
*,
sort: str = "heat",
within_days: int | None = None,
year: int | None = None,
growth_days: int = 7,
folder_id: int | None = None,
q: str | None = None,
@@ -701,19 +720,24 @@ class DashboardQueries:
duration_sorts = {"efficiency", "interaction_efficiency"}
min_view_for_ranking = max(0, int(analytics.min_view_for_ranking))
within_field = None
if within_days is not None:
within_start: str | None = None
within_end: str | None = None
if year is not None:
within_field = "pubdate"
within_start = f"{int(year)}-01-01"
within_end = f"{int(year) + 1}-01-01"
elif within_days is not None:
if within_days < 1:
raise ValueError("within_days 必须大于 0")
within_field = "first_seen" if sort == "added" else "pubdate"
within_start = _date_minus(latest, int(within_days) - 1)
videos = self._videos(
conn,
folder_id=folder_id,
q=q,
within_field=within_field,
within_start=(
_date_minus(latest, int(within_days) - 1)
if within_days is not None else None
),
within_start=within_start,
within_end=within_end,
)
aids = [int(video["aid"]) for video in videos]
snapshots = self._snapshot_map(conn, aids)
@@ -862,6 +886,7 @@ class DashboardQueries:
return {
"sort": sort,
"within_days": within_days,
"year": year,
"growth_days": growth_days,
"page": page,
"page_size": page_size,
@@ -973,6 +998,7 @@ class DashboardQueries:
*,
sort: str = "avg_views",
within_days: int | None = None,
year: int | None = None,
q: str | None = None,
min_videos: int = 1,
page: int = 1,
@@ -981,6 +1007,7 @@ class DashboardQueries:
params = {
"sort": sort,
"within_days": within_days,
"year": year,
"q": q or "",
"min_videos": min_videos,
"page": page,
@@ -990,6 +1017,7 @@ class DashboardQueries:
"videos", params, TTL_VIDEOS, lambda: self._list_ups_uncached(
sort=sort,
within_days=within_days,
year=year,
q=q,
min_videos=min_videos,
page=page,
@@ -1002,6 +1030,7 @@ class DashboardQueries:
*,
sort: str = "avg_views",
within_days: int | None = None,
year: int | None = None,
q: str | None = None,
min_videos: int = 1,
page: int = 1,
@@ -1015,14 +1044,24 @@ class DashboardQueries:
def run(conn: sqlite3.Connection) -> dict[str, Any]:
latest = self._latest_date(conn) or self._today()
if year is not None:
within_field = "pubdate"
within_start: str | None = f"{int(year)}-01-01"
within_end: str | None = f"{int(year) + 1}-01-01"
elif within_days is not None:
within_field = "pubdate"
within_start = _date_minus(latest, int(within_days) - 1)
within_end = None
else:
within_field = None
within_start = None
within_end = None
videos = self._videos(
conn,
q=q,
within_field="pubdate" if within_days is not None else None,
within_start=(
_date_minus(latest, int(within_days) - 1)
if within_days is not None else None
),
within_field=within_field,
within_start=within_start,
within_end=within_end,
)
snapshots = self._snapshot_map(conn, [int(video["aid"]) for video in videos])
groups: dict[int, dict[str, Any]] = {}
@@ -1147,6 +1186,7 @@ class DashboardQueries:
return {
"sort": sort,
"within_days": within_days,
"year": year,
"min_videos": min_videos,
"page": page,
"page_size": page_size,
+13
View File
@@ -294,6 +294,7 @@ border-color:#3a2c22;color:#f3e9dc}button{background:#e9b97c;color:#2a1a10}}
self._send_json(HTTPStatus.OK, queries.list_videos(
sort=_param(query, "sort", "heat"),
within_days=_optional_int(query, "within_days"),
year=_year_param(query),
growth_days=_int_param(query, "growth_days", 7, 1, 365),
folder_id=_optional_int(query, "folder"),
q=_optional_str(query, "q"),
@@ -326,6 +327,7 @@ border-color:#3a2c22;color:#f3e9dc}button{background:#e9b97c;color:#2a1a10}}
self._send_json(HTTPStatus.OK, queries.list_ups(
sort=_param(query, "sort", "efficiency"),
within_days=_optional_int(query, "within_days"),
year=_year_param(query),
q=_optional_str(query, "q"),
min_videos=_int_param(query, "min_videos", 1, 1, 10000),
page=_int_param(query, "page", 1, 1, 1_000_000),
@@ -341,6 +343,7 @@ border-color:#3a2c22;color:#f3e9dc}button{background:#e9b97c;color:#2a1a10}}
self._send_json(HTTPStatus.OK, queries.list_videos(
sort=_param(query, "sort", "heat"),
within_days=_optional_int(query, "within_days"),
year=_year_param(query),
growth_days=_int_param(query, "growth_days", 7, 1, 365),
folder_id=_optional_int(query, "folder"),
q=_optional_str(query, "q"),
@@ -356,6 +359,7 @@ border-color:#3a2c22;color:#f3e9dc}button{background:#e9b97c;color:#2a1a10}}
self._send_json(HTTPStatus.OK, queries.list_ups(
sort=_param(query, "sort", "efficiency"),
within_days=_optional_int(query, "within_days"),
year=_year_param(query),
q=_optional_str(query, "q"),
min_videos=_int_param(query, "min_videos", 1, 1, 10000),
page=_int_param(query, "page", 1, 1, 1_000_000),
@@ -526,6 +530,15 @@ def _optional_int(query: Mapping[str, list[str]], name: str) -> int | None:
raise ApiError(HTTPStatus.BAD_REQUEST, "bad_request", f"{name} 必须是整数") from exc
def _year_param(query: Mapping[str, list[str]]) -> int | None:
value = _optional_int(query, "year")
if value is None:
return None
if value < 2000 or value > 2100:
raise ApiError(HTTPStatus.BAD_REQUEST, "bad_request", "year 必须在 2000~2100 之间")
return value
def _int_param(
query: Mapping[str, list[str]],
name: str,
+13 -3
View File
@@ -520,10 +520,20 @@ h1, h2, h3, p { margin: 0; }
align-items: center;
gap: 8px;
}
.rank-search { display: flex; flex: 1; min-width: 0; gap: 6px; }
.rank-search .search-input { flex: 1; min-width: 0; }
.rank-search { display: flex; flex: 0 0 auto; width: 230px; gap: 6px; }
.rank-search .search-input { flex: 1 1 auto; min-width: 0; }
.select-label {
display: inline-flex;
align-items: center;
gap: 6px;
color: var(--muted);
font-size: 12.5px;
white-space: nowrap;
}
.rank-toolbar .select { max-width: 220px; width: auto; }
@media (max-width: 619px) {
.rank-search { flex-basis: 100%; }
.rank-search { width: 230px; }
.rank-toolbar .select { max-width: 100%; }
}
.search-input, .select {
min-height: 38px;
+1 -1
View File
@@ -102,7 +102,7 @@ export async function renderHome(root) {
<section class="panel">
<div class="section-head">
<h2>最近发布</h2>
<a class="text-link" href="/videos?sort=published&within_days=14">查看全部</a>
<a class="text-link" href="/videos?sort=published&within_days=30">查看全部</a>
</div>
<p class="section-note">按视频发布时间排序,与入库时间无关。</p>
<div class="video-list">
+61 -40
View File
@@ -8,6 +8,8 @@ import { replaceQuery } from "../router.js";
const VIDEO_SORTS = [
["efficiency", "内容效率"],
["heat", "综合热度"],
["published", "发布时间"],
["added", "入库时间"],
["view", "播放"],
["coin", "投币"],
["favorite", "收藏"],
@@ -26,6 +28,13 @@ const UP_SORTS = [
["interaction", "总互动"],
["latest", "最近发布"],
];
const TIME_RANGES = [
["all", "全部时间"],
["7", "近 7 天"],
["30", "近 30 天"],
["90", "近 90 天"],
["365", "近 1 年"],
];
const VIDEO_METRIC_PRIMARY = new Set(["view", "coin", "favorite", "reply"]);
const OVERVIEW_TTL = 60_000;
let overviewCache = { at: 0, promise: null };
@@ -43,8 +52,22 @@ async function getOverview() {
}
}
function controls({ scope, sort, within, growthDays, q, folder, minVideos, folders }) {
function sortOptions(scope, sort) {
const sorts = scope === "up" ? UP_SORTS : VIDEO_SORTS;
return sorts.map(([value, label]) =>
`<option value="${value}" ${value === sort ? "selected" : ""}>${escapeHtml(label)}</option>`).join("");
}
function timeOptions(within, year, years) {
const selected = year ? `year:${year}` : (within || "all");
const base = TIME_RANGES.map(([value, label]) =>
`<option value="${value}" ${selected === value ? "selected" : ""}>${escapeHtml(label)}</option>`).join("");
const yearOptions = (years || []).map((value) =>
`<option value="year:${value}" ${selected === `year:${value}` ? "selected" : ""}>${value} 年</option>`).join("");
return base + yearOptions;
}
function controls({ scope, sort, within, year, growthDays, q, minVideos, years }) {
return `
<div class="rank-toolbar">
<form class="rank-search" id="rank-filter">
@@ -52,25 +75,19 @@ function controls({ scope, sort, within, growthDays, q, folder, minVideos, folde
placeholder="${scope === "up" ? "搜索 UP 名" : "搜索标题或 UP"}" autocomplete="off">
<button class="button" type="submit">搜索</button>
</form>
</div>
<div class="chip-row scroll-x">
${sorts.map(([value, label]) => chip(label, sort === value, "sort", value)).join("")}
</div>
<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>` : ""}
<label class="select-label">排序
<select class="select" id="rank-sort">${sortOptions(scope, sort)}</select>
</label>
<label class="select-label">时间
<select class="select" id="rank-time">${timeOptions(within, year, years)}</select>
</label>
${scope === "up" ? `<label class="select-label">视频数
<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>
</label>` : ""}
</div>
${scope === "video" && (sort === "growth" || sort === "growth_rate") ? `
<div class="chip-row growth-days">
@@ -150,19 +167,23 @@ function durationNote(data) {
发布满 ${filter.maturity_days} 天、播放 ≥ ${filter.min_view} 的视频${shrink};时长缺失的视频不参与排序。</div>`;
}
function bindControls(root, scope) {
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 });
}));
function bindControls(root) {
root.querySelector("#rank-sort")?.addEventListener("change", (event) => {
replaceQuery({ sort: event.target.value, page: null });
});
root.querySelector("#rank-time")?.addEventListener("change", (event) => {
const value = event.target.value;
if (value.startsWith("year:")) {
replaceQuery({ year: value.slice(5), within_days: null, page: null });
} else if (value === "all") {
replaceQuery({ year: null, within_days: null, page: null });
} else {
replaceQuery({ year: null, within_days: value, 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 });
});
@@ -179,23 +200,23 @@ function bindControls(root, scope) {
}
async function renderScope(root, { query }, scope) {
const defaultSort = "efficiency";
const sort = query.get("sort") || defaultSort;
const within = query.get("within_days") || "all";
const sort = query.get("sort") || "efficiency";
const within = query.get("within_days") || "";
const year = query.get("year") || "";
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 getOverview();
const folders = overview.folders || [];
const years = overview.years || [];
const params = {
sort,
page,
page_size: 20,
within_days: within === "all" ? null : within,
within_days: within || null,
year: year || null,
growth_days: growthDays,
q,
};
@@ -203,7 +224,7 @@ async function renderScope(root, { query }, scope) {
if (scope === "up") {
data = await api("/api/ups", { ...params, min_videos: minVideos });
} else {
data = await api("/api/videos", { ...params, folder: folder || null });
data = await api("/api/videos", params);
}
const startRank = (data.page - 1) * data.page_size + 1;
@@ -214,7 +235,7 @@ async function renderScope(root, { query }, scope) {
<header class="page-head">
<h1>${title} <span class="page-sub">共 ${formatCount(data.total)} ${unit}</span></h1>
</header>
${controls({ scope, sort, within, growthDays, q, folder, minVideos, folders })}
${controls({ scope, sort, within, year, growthDays, q, minVideos, years })}
${durationNote(data)}
${data.available === false ? `<div class="notice-row warning">
<strong>${sort.startsWith("growth") ? "增长数据尚不可用" : "榜单筛选后为空"}</strong>
@@ -224,7 +245,7 @@ async function renderScope(root, { query }, scope) {
${pager(data.page, data.pages)}
</section>`;
bindControls(root, scope);
bindControls(root);
}
export function renderVideos(root, context) {
@@ -233,4 +254,4 @@ export function renderVideos(root, context) {
export function renderUps(root, context) {
return renderScope(root, context, "up");
}
}