hyapp-h5/rank/fami.html
2026-07-15 20:37:49 +08:00

1554 lines
56 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover"
/>
<meta name="theme-color" content="#07160d" />
<title data-i18n="fami_rank.page_title">Fami Ranking</title>
<link rel="stylesheet" href="../common/theme.css" />
<style>
:root {
--rank-zoom: 1;
--rank-bg: url('assets/fami/bg-wealth.webp');
--rank-base: #04150b;
--rank-base-fade: rgba(4, 21, 11, 0.82);
--rank-panel: rgba(5, 42, 24, 0.78);
--rank-panel-deep: rgba(1, 23, 12, 0.94);
--rank-mine: url('assets/fami/mine-wealth.webp');
--rank-gold: #ffd875;
--rank-gold-strong: #f3b934;
--rank-text: #fff8e6;
}
* {
box-sizing: border-box;
-webkit-tap-highlight-color: transparent;
}
html,
body {
min-height: 100%;
margin: 0;
background: #050a07;
color: var(--rank-text);
font-family: Arial, 'Helvetica Neue', sans-serif;
overscroll-behavior-y: none;
}
body {
overflow-x: hidden;
}
button {
margin: 0;
padding: 0;
border: 0;
outline: none;
color: inherit;
font: inherit;
background: transparent;
cursor: pointer;
}
img {
display: block;
user-select: none;
-webkit-user-drag: none;
}
.rank-app {
position: relative;
width: 375px;
min-height: 100vh;
margin: 0 auto;
/* sticky 必须以页面视口为滚动容器;这里不能设置 clip/hidden否则 WebView 会让吸顶导航随榜单一起滚走。 */
overflow: visible;
isolation: isolate;
background-color: var(--rank-base);
zoom: var(--rank-zoom);
}
.rank-background {
position: absolute;
z-index: -2;
top: 0;
bottom: 0;
left: 0;
width: 375px;
background-color: var(--rank-base);
background-image: var(--rank-bg);
background-position: center top;
background-repeat: no-repeat;
background-size: 375px auto;
}
/* 三张 Figma 背景以 @2x 导出,最短图在 CSS 坐标 656px 结束;渐隐在 655px 前变成底色,避免图片结尾形成横向断层。 */
.rank-background::after {
position: absolute;
top: 520px;
left: 0;
width: 375px;
height: 150px;
background: linear-gradient(
180deg,
transparent 0%,
var(--rank-base-fade) 48%,
var(--rank-base) 90%,
var(--rank-base) 100%
);
content: '';
pointer-events: none;
}
.sticky-header {
position: sticky;
z-index: 50;
top: 0;
width: 375px;
height: 96px;
background: linear-gradient(
180deg,
rgba(2, 7, 5, 0.7) 0%,
rgba(2, 7, 5, 0.48) 58%,
rgba(2, 7, 5, 0) 100%
);
backdrop-filter: blur(1.5px);
}
.top-nav {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 44px;
}
.back-button {
position: absolute;
top: 0;
left: 14px;
display: grid;
width: 42px;
height: 44px;
place-items: center;
}
.back-button::before {
width: 10px;
height: 10px;
border-bottom: 2px solid #fff;
border-left: 2px solid #fff;
content: '';
transform: rotate(45deg);
filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.7));
}
.board-tabs {
display: flex;
align-items: center;
justify-content: center;
gap: 37px;
height: 44px;
margin-left: 4px;
}
.board-tab {
min-width: 48px;
height: 44px;
color: #fff;
font-size: 17px;
line-height: 44px;
text-align: center;
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.85);
white-space: nowrap;
}
.board-tab.is-active {
color: #ffd369;
font-weight: 700;
text-shadow:
0 0 8px rgba(255, 189, 44, 0.52),
0 1px 3px rgba(0, 0, 0, 0.9);
}
.period-tabs {
position: absolute;
top: 52px;
left: 56px;
display: flex;
width: 263px;
height: 40px;
align-items: center;
justify-content: space-between;
border-radius: 20px;
background: rgba(5, 6, 5, 0.88);
box-shadow:
inset 0 0 0 1px rgba(245, 180, 54, 0.8),
0 2px 8px rgba(0, 0, 0, 0.34);
}
.period-tab {
position: relative;
z-index: 1;
width: 87px;
height: 40px;
color: #fff;
font-size: 13px;
line-height: 40px;
text-align: center;
white-space: nowrap;
}
.period-tab::before {
position: absolute;
z-index: -1;
top: 0;
left: -2px;
display: none;
width: 92px;
height: 40px;
background: url('assets/fami/period-active.webp') center / 92px
40px no-repeat;
content: '';
}
.period-tab.is-active {
color: #815018;
text-shadow: 0 1px 0 rgba(255, 255, 255, 0.45);
}
.period-tab.is-active::before {
display: block;
}
.podium {
position: relative;
width: 375px;
height: 458px;
}
.podium-card {
position: absolute;
overflow: visible;
}
.podium-card .avatar {
position: absolute;
z-index: 1;
border-radius: 3px;
object-fit: cover;
background: #050505;
}
.podium-card .frame,
.podium-card .banner {
position: absolute;
pointer-events: none;
}
.podium-card .frame {
/* Figma 中铭牌节点先绘制、皇冠框后绘制;皇冠编号必须完整压在铭牌上方。 */
z-index: 3;
}
.podium-card .banner {
z-index: 2;
}
.podium-card .podium-copy {
position: absolute;
z-index: 4;
color: #694417;
text-align: center;
}
.podium-card .podium-name {
display: block;
overflow: hidden;
font-size: 14px;
line-height: 18px;
text-overflow: ellipsis;
white-space: nowrap;
}
.badge-strip {
display: flex;
height: 14px;
align-items: center;
justify-content: center;
gap: 1px;
overflow: hidden;
}
.badge-strip img {
width: auto;
max-width: 24px;
height: 12px;
object-fit: contain;
}
.podium-score,
.row-score,
.mine-score {
display: flex;
align-items: center;
justify-content: center;
gap: 3px;
white-space: nowrap;
}
.podium-score {
height: 16px;
font-size: 10px;
line-height: 16px;
}
.score-coin {
width: 12px;
height: 12px;
object-fit: contain;
}
.podium-card.top-one {
top: 7px;
left: 0;
width: 375px;
height: 301px;
}
.top-one .frame {
top: 0;
left: 53px;
width: 270px;
height: 180px;
}
.top-one .avatar {
top: 75px;
left: 151px;
width: 78px;
height: 78px;
}
.top-one .banner {
top: 155px;
left: 119px;
width: 137px;
height: 121px;
}
.top-one .podium-copy {
top: 191px;
left: 126px;
width: 123px;
}
.top-one .badge-strip {
margin-top: 1px;
}
.podium-card.side {
top: 234px;
width: 169px;
height: 224px;
}
.podium-card.top-two {
left: 0;
}
.podium-card.top-three {
right: 0;
width: 163px;
}
.side .frame {
top: 0;
left: 0;
width: 169px;
height: 147px;
}
.top-three .frame {
width: 163px;
height: 144px;
}
.side .avatar {
top: 43px;
left: 46px;
width: 68px;
height: 68px;
}
.top-three .avatar {
left: 46px;
}
.side .banner {
top: 117px;
left: 35px;
width: 98px;
height: 107px;
}
.top-three .banner {
left: 31px;
width: 99px;
}
.side .podium-copy {
top: 145px;
left: 43px;
width: 82px;
}
.top-three .podium-copy {
left: 39px;
}
.side .podium-name {
font-size: 12px;
line-height: 16px;
}
.side .badge-strip {
height: 13px;
}
.side .badge-strip img {
max-width: 19px;
height: 10px;
}
.side .podium-score {
font-size: 9px;
}
.rank-list {
position: relative;
z-index: 2;
width: 375px;
min-height: 407px;
padding-bottom: 110px;
}
.rank-item {
position: relative;
display: block;
width: 375px;
height: 81px;
text-align: left;
}
.rank-row-svg {
position: absolute;
top: 8px;
left: 10px;
width: 355px;
height: 63px;
filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.34));
}
.row-ornament {
position: absolute;
z-index: 2;
top: 0;
left: 96px;
width: 184px;
height: 14px;
object-fit: contain;
}
.rank-number {
position: absolute;
z-index: 3;
top: 27px;
left: 21px;
width: 25px;
color: #fff;
font-size: 16px;
line-height: 22px;
text-align: center;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.86);
}
.row-avatar {
position: absolute;
z-index: 3;
top: 18px;
left: 48px;
width: 44px;
height: 44px;
border-radius: 50%;
object-fit: cover;
background: #1b1b1b;
box-shadow: 0 0 0 1px rgba(255, 225, 143, 0.32);
}
.row-main {
position: absolute;
z-index: 3;
top: 17px;
left: 99px;
width: 145px;
height: 48px;
}
.row-name {
display: block;
overflow: hidden;
color: #fff;
font-size: 15px;
line-height: 23px;
text-overflow: ellipsis;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.75);
white-space: nowrap;
}
.row-badges {
display: flex;
height: 17px;
align-items: center;
gap: 2px;
overflow: hidden;
}
.row-badges img {
width: auto;
max-width: 24px;
height: 14px;
object-fit: contain;
}
.row-score {
position: absolute;
z-index: 3;
top: 28px;
right: 25px;
width: 91px;
color: #fff;
font-size: 16px;
line-height: 22px;
text-align: right;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.8);
}
.empty-state,
.error-state {
display: none;
width: 100%;
min-height: 120px;
align-items: center;
justify-content: center;
padding: 24px;
color: rgba(255, 248, 230, 0.76);
font-size: 14px;
text-align: center;
text-shadow: 0 1px 3px #000;
}
.empty-state.is-visible,
.error-state.is-visible {
display: flex;
}
.my-rank {
position: fixed;
z-index: 80;
bottom: -22px;
left: 50%;
display: block;
width: 375px;
height: 117px;
margin-left: -187.5px;
overflow: hidden;
isolation: isolate;
text-align: left;
zoom: var(--rank-zoom);
}
.my-rank::before {
position: absolute;
z-index: 0;
inset: 0;
background: var(--rank-mine) center / 375px 117px no-repeat;
content: '';
}
.my-rank::after {
position: absolute;
z-index: 1;
top: 22px;
left: 5px;
width: 365px;
height: 85px;
/* 对应 Figma Vector 3158只给金框内部做 2.11px 背景模糊,文字、头像和外侧金框继续保持清晰。 */
background: rgba(2, 12, 7, 0.02);
-webkit-backdrop-filter: blur(2.11px);
backdrop-filter: blur(2.11px);
clip-path: polygon(
3.33% 0,
96.67% 0,
100% 14.29%,
100% 85.88%,
96.67% 100%,
3.33% 100%,
0 85.88%,
0 14.29%
);
clip-path: path(
'M12.1491 0C10.8813 7.60248 3.52147 11.2629 0 12.1429C0.176073 30.6211 0.52822 68.6335 0.52822 72.8571C8.55716 74.9689 11.6208 81.4803 12.1491 84.472L181.708 85L182.764 83.4161L184.349 85H352.851C353.696 78.2422 361.302 74.232 365 73.0716V12.1429C357.394 10.4534 353.731 3.34369 352.851 0H246.151C241.08 1.68944 236.995 0.703934 235.586 0H230.304C228.191 0 228.191 0 228.719 1.0559C228.191 1.58385 225.022 2.1118 222.381 1.58385C214.457 5.80745 203.365 2.63975 200.724 1.0559C199.033 3.1677 195.089 2.28778 193.329 1.58385C193.329 1.75983 193.223 2.1118 192.8 2.1118C192.378 2.1118 190.159 1.40787 189.103 1.0559C188.751 1.40787 188.046 2.21739 188.046 2.63975C187.518 2.63975 183.821 5.80745 182.764 6.86335C182.764 6.01863 179.595 3.34369 178.01 2.1118L176.954 1.0559C176.531 1.47826 173.96 1.93582 172.728 2.1118H171.671C170.404 2.95652 166.918 2.1118 165.333 1.58385C163.572 2.1118 159.1 3.27329 155.297 3.69565C151.493 4.11801 145.965 2.46377 143.676 1.58385H138.922C138.394 1.58385 137.443 1.47826 137.865 1.0559C138.288 0.63354 137.337 0.175983 136.809 0H130.47C125.822 2.1118 121.491 0.879917 119.906 0H12.1491Z'
);
content: '';
pointer-events: none;
}
.mine-number {
position: absolute;
z-index: 2;
top: 40px;
left: 18px;
width: 26px;
color: #fff;
font-size: 20px;
line-height: 28px;
text-align: center;
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.8);
}
.mine-avatar {
position: absolute;
z-index: 2;
top: 33px;
left: 48px;
width: 50px;
height: 50px;
border-radius: 50%;
object-fit: cover;
box-shadow: 0 0 0 1px rgba(255, 228, 152, 0.52);
}
.mine-main {
position: absolute;
z-index: 2;
top: 33px;
left: 103px;
width: 145px;
}
.mine-name {
display: block;
overflow: hidden;
color: #fff;
font-size: 16px;
line-height: 24px;
text-overflow: ellipsis;
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.86);
white-space: nowrap;
}
.mine-badges {
justify-content: flex-start;
}
.mine-score {
position: absolute;
z-index: 2;
top: 42px;
right: 18px;
width: 104px;
color: #fff;
font-size: 16px;
line-height: 22px;
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.86);
}
.loading-overlay {
position: fixed;
z-index: 100;
inset: 0;
display: none;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.2);
pointer-events: none;
}
.loading-overlay.is-visible {
display: flex;
}
.loading-dot {
width: 30px;
height: 30px;
border: 3px solid rgba(255, 255, 255, 0.26);
border-top-color: var(--rank-gold);
border-radius: 50%;
animation: loading-spin 0.8s linear infinite;
}
@keyframes loading-spin {
to {
transform: rotate(360deg);
}
}
[dir='rtl'] .rank-item,
[dir='rtl'] .my-rank {
direction: ltr;
}
@media (prefers-reduced-motion: reduce) {
.loading-dot {
animation-duration: 1.8s;
}
}
</style>
<script>
window.HyAppI18nSupported = ['en', 'ar', 'tr', 'es'];
// 该入口只属于 Fami在公共 API/参数脚本加载前同步默认值和 URL确保用户概览与排行榜请求都发送 X-App-Code: fami。
window.HyAppDefaultAppCode = 'fami';
(function enforceFamiAppCode() {
try {
var entryURL = new URL(window.location.href);
if (entryURL.searchParams.get('app_code') !== 'fami') {
entryURL.searchParams.set('app_code', 'fami');
window.history.replaceState(
null,
'',
entryURL.toString()
);
}
} catch (_) {
// 某些旧 WebView 禁止 file:// 改写 history页面默认值仍会让公共请求层固定使用 Fami。
}
})();
</script>
<script src="../common/api.js"></script>
<script src="../common/params.js"></script>
<script src="../common/jsbridge.js"></script>
<script src="../common/i18n.js"></script>
</head>
<body>
<main class="rank-app" id="rankApp">
<div class="rank-background" aria-hidden="true"></div>
<header class="sticky-header" id="stickyHeader">
<nav
class="top-nav"
data-i18n-aria="fami_rank.navigation"
aria-label="Ranking navigation"
>
<button
class="back-button"
id="backButton"
type="button"
data-i18n-aria="fami_rank.back"
aria-label="Back"
></button>
<div class="board-tabs" id="boardTabs">
<button
class="board-tab"
type="button"
data-board="wealth"
data-i18n="fami_rank.wealth"
>
Wealth
</button>
<button
class="board-tab"
type="button"
data-board="charm"
data-i18n="fami_rank.charm"
>
Charm
</button>
<button
class="board-tab"
type="button"
data-board="room"
data-i18n="fami_rank.room"
>
Room
</button>
</div>
</nav>
<div class="period-tabs" id="periodTabs">
<button
class="period-tab"
type="button"
data-period="today"
data-i18n="fami_rank.daily"
>
Daily
</button>
<button
class="period-tab"
type="button"
data-period="week"
data-i18n="fami_rank.weekly"
>
Weekly
</button>
<button
class="period-tab"
type="button"
data-period="month"
data-i18n="fami_rank.monthly"
>
Monthly
</button>
</div>
</header>
<section class="podium" id="podium" aria-live="polite"></section>
<section class="rank-list" id="rankList"></section>
<div
class="empty-state"
id="emptyState"
data-i18n="fami_rank.empty"
>
No ranking data yet
</div>
<div
class="error-state"
id="errorState"
data-i18n="fami_rank.network_error"
>
Unable to load the ranking. Please try again.
</div>
</main>
<button class="my-rank" id="myRank" type="button" hidden></button>
<div
class="loading-overlay"
id="loadingOverlay"
aria-hidden="true"
data-i18n-aria="fami_rank.loading"
aria-label="Loading"
>
<span class="loading-dot"></span>
</div>
<script>
(function () {
'use strict';
var ASSET_ROOT = 'assets/fami/';
var BOARD_CONFIG = {
// UI 使用业务名称,接口继续使用后端既有枚举;集中映射可避免标签文案直接泄漏到请求协议。
wealth: {
apiType: 'sent',
background: 'bg-wealth.webp',
mine: 'mine-wealth.webp',
base: '#04150b',
fade: 'rgba(4, 21, 11, 0.82)',
panel: 'rgba(5, 42, 24, 0.78)',
},
charm: {
apiType: 'received',
background: 'bg-charm.webp',
mine: 'mine-charm.webp',
base: '#050619',
fade: 'rgba(5, 6, 25, 0.82)',
panel: 'rgba(11, 17, 55, 0.8)',
},
room: {
apiType: 'room',
background: 'bg-room.webp',
mine: 'mine-room.webp',
base: '#130417',
fade: 'rgba(19, 4, 23, 0.82)',
panel: 'rgba(48, 10, 48, 0.78)',
},
};
var PERIODS = ['today', 'week', 'month'];
var query = new URLSearchParams(window.location.search);
var queryBoard = query.get('board') || query.get('board_type');
var state = {
board: normalizeBoard(queryBoard),
period:
PERIODS.indexOf(query.get('period')) >= 0
? query.get('period')
: 'today',
page: 1,
pageSize: 20,
total: 0,
items: [],
myRank: null,
currentUser: null,
loading: false,
loaded: false,
error: null,
requestSerial: 0,
};
function byId(id) {
return document.getElementById(id);
}
function normalizeBoard(value) {
var aliases = {
sent: 'wealth',
received: 'charm',
wealth: 'wealth',
charm: 'charm',
room: 'room',
};
return (
aliases[String(value || '').toLowerCase()] || 'wealth'
);
}
function escapeHTML(value) {
return String(value == null ? '' : value)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/\"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function escapeAttr(value) {
return escapeHTML(value);
}
function t(key, fallback) {
return window.HyAppI18n && window.HyAppI18n.t
? window.HyAppI18n.t(key, fallback)
: fallback;
}
function mockEnabled() {
var hasToken = Boolean(
window.HyAppAPI &&
window.HyAppAPI.getAccessToken &&
window.HyAppAPI.getAccessToken()
);
// APP 也可能通过 file:// 加载内置 H5只在“本地文件且没有 token”时展示设计数据携带 token 的 file/HTTP 页面必须走 Fami 真实接口。
return (
query.get('mock') === '1' ||
(window.location.protocol === 'file:' && !hasToken)
);
}
function compactNumber(value) {
var number = Number(value || 0);
if (!Number.isFinite(number)) number = 0;
if (number >= 1000000000)
return trimDecimal(number / 1000000000) + 'b';
if (number >= 1000000)
return trimDecimal(number / 1000000) + 'm';
if (number >= 1000) return trimDecimal(number / 1000) + 'k';
return String(Math.round(number));
}
function trimDecimal(value) {
return value
.toFixed(value >= 100 ? 0 : 1)
.replace(/\.0$/, '');
}
function normalizeItem(raw) {
var item = raw || {};
var user = item.user || {};
var room = item.room || {};
var userId = item.user_id || user.user_id || '';
var roomId = item.room_id || room.room_id || '';
var badgeURLs =
user.short_badge_cover_urls ||
user.shortBadgeCoverUrls ||
[];
if (!Array.isArray(badgeURLs)) badgeURLs = [];
return {
rank: Number(item.rank || 0),
giftValue: Number(
item.gift_value || item.giftValue || item.value || 0
),
userId: String(userId || ''),
roomId: String(roomId || ''),
name:
user.username ||
user.nickname ||
user.display_user_id ||
room.title ||
room.name ||
room.room_short_id ||
t('fami_rank.unknown', 'Unknown'),
avatar:
user.avatar ||
user.avatar_url ||
user.avatarUrl ||
room.cover_url ||
room.coverUrl ||
room.room_avatar ||
room.avatar ||
'',
badges: badgeURLs.filter(function (url) {
return typeof url === 'string' && url.trim();
}),
};
}
function normalizeCurrentUser(current) {
var overview = current && current.user;
var profile =
(overview &&
(overview.profile || overview.user || overview)) ||
{};
var badges =
profile.short_badge_cover_urls ||
profile.shortBadgeCoverUrls ||
[];
if (!Array.isArray(badges)) badges = [];
return {
rank: 0,
giftValue: 0,
userId: String(
profile.user_id ||
profile.userId ||
profile.id ||
''
),
roomId: '',
name:
profile.nickname ||
profile.nick_name ||
profile.nickName ||
profile.username ||
profile.display_user_id ||
profile.displayUserId ||
profile.user_id ||
profile.userId ||
t('fami_rank.unknown', 'Unknown'),
avatar:
profile.avatar ||
profile.user_avatar ||
profile.userAvatar ||
profile.avatar_url ||
profile.avatarUrl ||
'',
badges: badges.filter(function (url) {
return typeof url === 'string' && url.trim();
}),
};
}
function normalizePayload(data, reset) {
var incoming = (
data && Array.isArray(data.items) ? data.items : []
)
.map(normalizeItem)
.filter(function (item) {
return item.rank > 0;
});
state.items = reset
? incoming
: state.items.concat(incoming);
state.total = Number(
(data && data.total) || state.items.length || 0
);
state.page = Number((data && data.page) || state.page);
state.pageSize = Number(
(data && (data.page_size || data.pageSize)) ||
state.pageSize
);
state.myRank =
data && data.my_rank
? normalizeItem(data.my_rank)
: null;
state.loaded = true;
state.error = null;
}
function mockItems() {
return Array.from({ length: 14 }, function (_, index) {
return {
rank: index + 1,
giftValue:
index < 3 ? 1232345 : 102400 - index * 700,
userId: 'fami_user_' + (index + 1),
roomId: 'fami_room_' + (index + 1),
name: index < 3 ? 'Namenamename' : 'Namenamename',
avatar:
ASSET_ROOT +
(index < 3
? 'default-top.webp'
: 'default-row.webp'),
// mock 仅用于设计核对;生产数据没有徽章时必须保持为空,不能伪造用户权益。
badges: Array.from(
{ length: index < 3 ? 3 : 4 },
function () {
return ASSET_ROOT + 'mock-badge.webp';
}
),
};
});
}
function visibleItems() {
return mockEnabled() ? mockItems() : state.items;
}
function itemAvatar(item, podium) {
if (item && item.avatar) return item.avatar;
return (
ASSET_ROOT +
(podium ? 'default-top.webp' : 'default-row.webp')
);
}
function badgeMarkup(item, className) {
var badges =
item && Array.isArray(item.badges) ? item.badges : [];
// 短徽章是后端权益结果:空数组直接不渲染,避免用占位图误导用户当前等级/权益。
return (
'<span class="' +
className +
'">' +
badges
.slice(0, 4)
.map(function (url) {
return (
'<img class="rank-badge" src="' +
escapeAttr(url) +
'" alt="" />'
);
})
.join('') +
'</span>'
);
}
function scoreMarkup(item, className) {
return (
'<span class="' +
className +
'"><img class="score-coin" src="' +
ASSET_ROOT +
'coin.webp" alt="" /><span>' +
compactNumber(item ? item.giftValue : 0) +
'</span></span>'
);
}
function podiumMarkup(item, rank) {
var actual = item || {
rank: rank,
name: t('fami_rank.unknown', 'Unknown'),
giftValue: 0,
badges: [],
};
var classes =
rank === 1
? 'podium-card top-one'
: rank === 2
? 'podium-card side top-two'
: 'podium-card side top-three';
var frame =
rank === 1
? 'top-frame.webp'
: rank === 2
? 'side-frame-silver.webp'
: 'side-frame-bronze.webp';
var banner =
rank === 1
? 'top-banner.webp'
: rank === 2
? 'side-banner-silver.webp'
: 'side-banner-bronze.webp';
return (
'<button class="' +
classes +
'" type="button" data-rank="' +
rank +
'"' +
(item ? '' : ' disabled') +
'><img class="avatar" src="' +
escapeAttr(itemAvatar(actual, true)) +
'" alt="" /><img class="frame" src="' +
ASSET_ROOT +
frame +
'" alt="" /><img class="banner" src="' +
ASSET_ROOT +
banner +
'" alt="" /><span class="podium-copy"><span class="podium-name">' +
escapeHTML(actual.name) +
'</span>' +
badgeMarkup(actual, 'badge-strip') +
scoreMarkup(actual, 'podium-score') +
'</span></button>'
);
}
function rowFrameMarkup() {
return '<svg class="rank-row-svg" viewBox="0 0 355 63" aria-hidden="true"><path d="M10 0.75H345L354.25 10V53L345 62.25H10L0.75 53V10Z" fill="var(--rank-panel)" stroke="#EBC339" stroke-width="1.5"/><path d="M14 4H341L350 13V50L341 59H14L5 50V13Z" fill="none" stroke="rgba(255,218,101,.25)" stroke-width=".75"/></svg>';
}
function rowMarkup(item) {
return (
'<button class="rank-item" type="button" data-rank="' +
item.rank +
'">' +
rowFrameMarkup() +
'<img class="row-ornament" src="' +
ASSET_ROOT +
'row-ornament.webp" alt="" /><span class="rank-number">' +
item.rank +
'</span><img class="row-avatar" src="' +
escapeAttr(itemAvatar(item, false)) +
'" alt="" /><span class="row-main"><span class="row-name">' +
escapeHTML(item.name) +
'</span>' +
badgeMarkup(item, 'row-badges') +
'</span>' +
scoreMarkup(item, 'row-score') +
'</button>'
);
}
function renderPodium() {
var items = visibleItems();
byId('podium').innerHTML =
podiumMarkup(items[0], 1) +
podiumMarkup(items[1], 2) +
podiumMarkup(items[2], 3);
}
function renderList() {
var items = visibleItems();
var listItems = items.slice(3);
byId('rankList').innerHTML = listItems
.map(rowMarkup)
.join('');
byId('emptyState').classList.toggle(
'is-visible',
state.loaded && !state.error && items.length === 0
);
byId('errorState').classList.toggle(
'is-visible',
state.loaded &&
Boolean(state.error) &&
items.length === 0
);
}
function renderMine() {
var item = currentMineItem();
var mine = byId('myRank');
mine.hidden = !item;
if (!item) {
mine.innerHTML = '';
return;
}
mine.innerHTML =
'<span class="mine-number">' +
(item.rank || '--') +
'</span><img class="mine-avatar" src="' +
escapeAttr(itemAvatar(item, false)) +
'" alt="" /><span class="mine-main"><span class="mine-name">' +
escapeHTML(item.name) +
'</span>' +
badgeMarkup(item, 'badge-strip mine-badges') +
'</span>' +
scoreMarkup(item, 'mine-score');
}
function currentMineItem() {
if (mockEnabled()) return mockItems()[3];
// 接口未返回 my_rank 表示用户尚未上榜;仍展示真实头像/昵称,排名用 --、贡献值用 0避免整条个人栏消失。
return state.myRank || state.currentUser;
}
function applyTheme() {
var config = BOARD_CONFIG[state.board];
var root = document.documentElement;
root.style.setProperty(
'--rank-bg',
'url("' + ASSET_ROOT + config.background + '")'
);
root.style.setProperty(
'--rank-mine',
'url("' + ASSET_ROOT + config.mine + '")'
);
root.style.setProperty('--rank-base', config.base);
root.style.setProperty('--rank-base-fade', config.fade);
root.style.setProperty('--rank-panel', config.panel);
}
function syncTabs() {
document
.querySelectorAll('[data-board]')
.forEach(function (tab) {
tab.classList.toggle(
'is-active',
tab.getAttribute('data-board') === state.board
);
});
document
.querySelectorAll('[data-period]')
.forEach(function (tab) {
tab.classList.toggle(
'is-active',
tab.getAttribute('data-period') === state.period
);
});
}
function syncLoading() {
byId('loadingOverlay').classList.toggle(
'is-visible',
state.loading
);
byId('loadingOverlay').setAttribute(
'aria-hidden',
state.loading ? 'false' : 'true'
);
}
function render() {
applyTheme();
syncTabs();
syncLoading();
renderPodium();
renderList();
renderMine();
}
function updateURLState() {
if (!window.history || !window.history.replaceState) return;
var url = new URL(window.location.href);
url.searchParams.set('board', state.board);
url.searchParams.set('period', state.period);
window.history.replaceState(null, '', url.toString());
}
function load(reset) {
if (reset) {
state.page = 1;
state.items = [];
state.total = 0;
state.myRank = null;
state.loaded = false;
state.error = null;
}
if (mockEnabled()) {
state.loaded = true;
state.loading = false;
state.error = null;
render();
return Promise.resolve();
}
if (!window.HyAppAPI || !window.HyAppAPI.getAccessToken()) {
state.loaded = true;
state.loading = false;
state.error = new Error('missing_token');
render();
return Promise.resolve();
}
var requestedPage = state.page;
var serial = ++state.requestSerial;
state.loading = true;
state.error = null;
render();
return window.HyAppAPI.get(
'/api/v1/activities/user-leaderboards',
{
board_type: BOARD_CONFIG[state.board].apiType,
period: state.period,
page: requestedPage,
page_size: state.pageSize,
}
)
.then(function (payload) {
// 切榜/切周期会产生并发请求;只接受最后一次序号,避免慢响应把当前榜单覆盖回旧状态。
if (serial !== state.requestSerial) return;
normalizePayload(payload, reset);
})
.catch(function (error) {
if (serial !== state.requestSerial) return;
state.loaded = true;
state.error = error;
// 翻页失败要回滚页码,否则下一次触底会永久跳过失败页的数据。
if (!reset)
state.page = Math.max(1, requestedPage - 1);
})
.finally(function () {
if (serial !== state.requestSerial) return;
state.loading = false;
render();
});
}
function switchBoard(board) {
if (!BOARD_CONFIG[board] || state.board === board) return;
state.board = board;
state.requestSerial += 1;
updateURLState();
window.scrollTo(0, 0);
load(true);
}
function switchPeriod(period) {
if (PERIODS.indexOf(period) < 0 || state.period === period)
return;
state.period = period;
state.requestSerial += 1;
updateURLState();
window.scrollTo(0, 0);
load(true);
}
function openItem(item) {
if (!item) return;
if (state.board === 'room') {
if (item.roomId)
window.HyAppBridge.openRoom(item.roomId);
return;
}
if (item.userId) window.HyAppBridge.openUser(item.userId);
}
function itemByRank(rank) {
return visibleItems().find(function (item) {
return String(item.rank) === String(rank);
});
}
function resizeCanvas() {
// 设计稿基准宽度为 375更窄设备整体等比缩放避免绝对坐标被挤压后破坏皇冠和榜单框对齐。
var zoom = Math.min(1, window.innerWidth / 375);
document.documentElement.style.setProperty(
'--rank-zoom',
String(zoom)
);
}
byId('boardTabs').addEventListener('click', function (event) {
var target = event.target.closest('[data-board]');
if (target) switchBoard(target.getAttribute('data-board'));
});
byId('periodTabs').addEventListener('click', function (event) {
var target = event.target.closest('[data-period]');
if (target)
switchPeriod(target.getAttribute('data-period'));
});
byId('podium').addEventListener('click', function (event) {
var target = event.target.closest('[data-rank]');
if (target)
openItem(itemByRank(target.getAttribute('data-rank')));
});
byId('rankList').addEventListener('click', function (event) {
var target = event.target.closest('[data-rank]');
if (target)
openItem(itemByRank(target.getAttribute('data-rank')));
});
byId('myRank').addEventListener('click', function () {
openItem(currentMineItem());
});
byId('backButton').addEventListener('click', function () {
window.HyAppBridge.back();
});
document.addEventListener(
'error',
function (event) {
var image = event.target;
if (!image || image.tagName !== 'IMG') return;
if (image.classList.contains('rank-badge')) {
// 远端徽章失效时移除损坏节点;不能用本地图替代,否则会伪造用户实际拥有的徽章。
image.remove();
return;
}
if (
image.classList.contains('frame') ||
image.classList.contains('banner') ||
image.classList.contains('row-ornament') ||
image.classList.contains('score-coin')
) {
return;
}
// 头像地址可能因 CDN 失效而加载失败;只回退头像,不替换徽章或装饰,避免掩盖真实权益/资源问题。
image.src =
ASSET_ROOT +
(image.classList.contains('avatar')
? 'default-top.webp'
: 'default-row.webp');
},
true
);
window.addEventListener('scroll', function () {
if (
mockEnabled() ||
state.loading ||
!state.loaded ||
!state.total ||
state.items.length >= state.total
) {
return;
}
if (
window.innerHeight + window.scrollY >=
document.documentElement.scrollHeight - 180
) {
state.page += 1;
load(false);
}
});
window.addEventListener('resize', resizeCanvas);
window.addEventListener('hyapp:i18n-ready', function () {
// 用户名兜底文案依赖当前语言,因此语言切换后需要重绘动态节点而不只是替换静态标签。
render();
});
// WebView/file 预览刷新时会恢复旧滚动位置,导致首屏直接落在皇冠中段;排行榜入口必须始终从吸顶导航开始。
if ('scrollRestoration' in window.history) {
window.history.scrollRestoration = 'manual';
}
window.scrollTo(0, 0);
resizeCanvas();
render();
window.HyAppBridge.ready({ page: 'rank/fami' });
// 先让公共参数层同步 token/app_code 并尝试取当前用户;用户概览失败不应阻断榜单主请求。
Promise.resolve(
window.HyAppParams && window.HyAppParams.loadUser
? window.HyAppParams.loadUser()
: null
)
.then(function (current) {
state.currentUser = current
? normalizeCurrentUser(current)
: null;
render();
})
.catch(function () {
return null;
})
.finally(function () {
load(true);
});
})();
</script>
</body>
</html>