feat(web): up avatars, follower count, and dedupe views/avg secondary

This commit is contained in:
kairee committed 2026-11-01 14:34:23 +08:00
1 parent 1ed3746f4b
commit 2ff3e7952b
23 files changed
+209 -58

No files matched your search

+23
View File
@@ -387,6 +387,7 @@ class DashboardQueries:
"up_follower_at": video.get("up_follower_at"),
"up_follower_backfilled": bool(video.get("up_follower_backfilled") or 0),
"cover": normalize_cover(video.get("cover")),
"up_face": normalize_cover(video.get("up_face")),
"folder_id": video.get("folder_id"),
"folder_title": video.get("folder_title"),
"folder_year": video.get("folder_year"),
@@ -1074,7 +1075,10 @@ class DashboardQueries:
"up_mid": mid,
"up_name": video.get("up_name") or f"UP {mid}",
"videos": [],
"face": None,
})
if group["face"] is None and video.get("up_face"):
group["face"] = video.get("up_face")
metrics = _metric_map(snapshots.get(int(video["aid"])))
group["videos"].append((video, metrics))
items = []
@@ -1105,6 +1109,8 @@ class DashboardQueries:
items.append({
"up_mid": group["up_mid"],
"up_name": group["up_name"],
"up_face": normalize_cover(group["face"]),
"follower_latest": None,
"video_count": len(group_videos),
"total_views": total_views,
"avg_views": round(total_views / len(metrics_list), 1) if metrics_list else None,
@@ -1166,6 +1172,20 @@ class DashboardQueries:
if minutes > 0 else None
)
mids = [item["up_mid"] for item in items]
if mids:
placeholders = ",".join("?" for _ in mids)
profile_rows = conn.execute(
f"SELECT mid, follower_latest FROM up_profile "
f"WHERE mid IN ({placeholders})",
mids,
).fetchall()
followers = {
int(row["mid"]): row["follower_latest"] for row in profile_rows
}
for item in items:
item["follower_latest"] = followers.get(item["up_mid"])
items = [item for item in items if item["video_count"] >= min_videos]
sort_map = {
"views": lambda item: item["total_views"],
@@ -1256,6 +1276,9 @@ class DashboardQueries:
return {
"up_mid": mid,
"up_name": items[0].get("up_name") or f"UP {mid}",
"up_face": next(
(item["up_face"] for item in items if item.get("up_face")), None
),
"video_count": len(items),
"total_views": total_views,
"avg_views": round(total_views / len(metrics_list), 1) if metrics_list else None,
+20 -7
View File
@@ -19,6 +19,17 @@ export function rankBadge(rank) {
return `<span class="rank-badge${top}">${rank}</span>`;
}
export function avatar(item, className = "avatar") {
const url = coverUrl(item?.up_face || item?.avatar);
const name = escapeHtml(item?.up_name || "UP");
if (!url) {
const initial = String(item?.up_name || "U").trim().slice(0, 1).toUpperCase() || "U";
return `<div class="${className} avatar-placeholder" aria-hidden="true">${escapeHtml(initial)}</div>`;
}
return `<div class="${className}"><img src="${escapeAttr(url)}" alt="${name}"
loading="lazy" referrerpolicy="no-referrer"></div>`;
}
export function videoCard(item, {
rank = null, primary = "view", primaryLabel = "", primaryText = null,
showStatus = false,
@@ -64,27 +75,29 @@ export function videoCard(item, {
export function upCard(item, {
rank = null, primary = "efficiency", primaryLabel = "播放/小时", primaryText = null,
secondaryLabel = "总播放", secondaryText = null,
} = {}) {
const best = item.best_video || {};
const displayCover = cover(
{ title: best.title || item.up_name, cover: best.cover },
"video-thumb up-thumb",
);
const value = primaryText !== null ? primaryText : item[primary];
const secondary = secondaryText !== null ? secondaryText : formatCount(item.total_views);
const hours = item.content_hours;
const follower = item.follower_latest;
const followerText = follower === null || follower === undefined
? "粉丝 —"
: `粉丝 ${formatCount(follower)}`;
return `
<a class="up-card" href="/up/${item.up_mid}">
${rankBadge(rank)}
${displayCover}
${avatar(item, "up-avatar")}
<div class="up-card-body">
<h3 class="video-title">${escapeHtml(item.up_name || `UP ${item.up_mid}`)}</h3>
<div class="video-meta">
<span>${formatNumber(item.video_count)} 条视频</span>
<span>${hours === null || hours === undefined ? "时长未知" : `${formatNumber(hours)} 小时`}</span>
<span>${followerText}</span>
</div>
<div class="video-stats">
<span class="primary-stat"><b>${escapeHtml(value ?? "—")}</b><small>${escapeHtml(primaryLabel)}</small></span>
<span class="secondary-stat">总播放 ${formatCount(item.total_views)}</span>
<span class="secondary-stat">${escapeHtml(secondaryLabel)} ${escapeHtml(secondary)}</span>
</div>
</div>
<span class="card-arrow" aria-hidden="true">›</span>
+34
View File
@@ -353,6 +353,25 @@ a.live-chip:hover { border-color: color-mix(in srgb, var(--accent) 55%, var(--bo
.cover-placeholder img.image-error { opacity: 0; }
.video-card-body { grid-column: 3; grid-row: 1; min-width: 0; }
.up-card-body { grid-column: 3; grid-row: 1; min-width: 0; }
.up-avatar {
grid-column: 2;
grid-row: 1;
justify-self: center;
align-self: center;
width: 64px;
height: 64px;
border-radius: 50%;
overflow: hidden;
background: var(--surface-2);
display: grid;
place-items: center;
color: var(--muted);
font-size: 20px;
font-weight: 700;
}
.up-avatar img { width: 100%; height: 100%; object-fit: cover; display: block; }
.up-card.no-rank > .up-avatar { grid-column: 1; }
.avatar-placeholder { text-transform: uppercase; }
.video-title {
display: -webkit-box;
-webkit-line-clamp: 2;
@@ -717,6 +736,21 @@ a.live-chip:hover { border-color: color-mix(in srgb, var(--accent) 55%, var(--bo
background: var(--surface);
}
.hero-cover { width: 100%; aspect-ratio: 16 / 9; border-radius: 12px; }
.up-hero-head { display: flex; align-items: center; gap: 14px; }
.hero-avatar {
flex: 0 0 auto;
width: 72px;
height: 72px;
border-radius: 50%;
overflow: hidden;
background: var(--surface-2);
display: grid;
place-items: center;
color: var(--muted);
font-size: 26px;
font-weight: 700;
}
.hero-avatar img { width: 100%; height: 100%; object-fit: cover; display: block; }
.detail-hero-body h1 { font-size: 19px; line-height: 1.35; letter-spacing: -0.3px; }
.detail-meta { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 10px; margin-top: 8px; color: var(--muted); font-size: 12px; }
.compact-hero { padding: 18px; }
+12 -1
View File
@@ -133,6 +133,14 @@ function upPrimary(item, sort) {
return { label: "播放/小时", text: formatCount(item.efficiency) };
}
function upSecondary(item, sort) {
// “总播放”排序时主指标已是总播放,副指标改为平均播放,避免重复。
if (sort === "views") {
return { label: "平均播放", text: formatCount(item.avg_views) };
}
return { label: "总播放", text: formatCount(item.total_views) };
}
function listHtml(data, scope, sort, startRank) {
if (!data.items.length) {
return emptyState(
@@ -143,8 +151,11 @@ function listHtml(data, scope, sort, startRank) {
if (scope === "up") {
return `<div class="rank-list">${data.items.map((item, index) => {
const primary = upPrimary(item, sort);
const secondary = upSecondary(item, sort);
return upCard(item, {
rank: startRank + index, primaryLabel: primary.label, primaryText: primary.text,
rank: startRank + index,
primaryLabel: primary.label, primaryText: primary.text,
secondaryLabel: secondary.label, secondaryText: secondary.text,
});
}).join("")}</div>`;
}
+11 -8
View File
@@ -1,6 +1,6 @@
import { api } from "../api.js";
import {
badge, emptyState, sectionHeader, videoCard,
avatar, badge, emptyState, sectionHeader, videoCard,
} from "../components.js";
import { barChart, lineChart, themeColor } from "../charts.js";
import {
@@ -43,14 +43,17 @@ export async function renderUp(root, { params, query }) {
</header>
<div class="detail-hero compact-hero">
<div class="detail-hero-body">
<h1>${escapeHtml(up.up_name || `UP ${mid}`)}</h1>
<div class="detail-meta">
<span>mid ${up.up_mid}</span>
${up.blacklisted ? badge("黑名单 · 仅影响发现", "warning") : ""}
${(up.folders || []).map((title) => badge(title)).join("")}
<div class="up-hero-head">
${avatar(up, "hero-avatar")}
<div class="detail-hero-body">
<h1>${escapeHtml(up.up_name || `UP ${mid}`)}</h1>
<div class="detail-meta">
<span>mid ${up.up_mid}</span>
${up.blacklisted ? badge("黑名单 · 仅影响发现", "warning") : ""}
${(up.folders || []).map((title) => badge(title)).join("")}
</div>
<p class="section-note">首次发布 ${escapeHtml(formatDate(up.first_pubdate))} · 最近发布 ${escapeHtml(formatDate(up.latest_pubdate))}</p>
</div>
<p class="section-note">首次发布 ${escapeHtml(formatDate(up.first_pubdate))} · 最近发布 ${escapeHtml(formatDate(up.latest_pubdate))}</p>
</div>
</div>