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

1470 lines
54 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"
/>
<title>Level</title>
<link rel="stylesheet" href="../common/theme.css" />
<style>
* {
box-sizing: border-box;
-webkit-tap-highlight-color: transparent;
}
:root {
--level-bg: #0d0b20;
--header-bg: rgba(13, 11, 32, 0.94);
--row-bg: rgba(255, 255, 255, 0.04);
--accent: #ffad3d;
--atmosphere: url('./fami-assets/wealth-bg.png');
}
html,
body {
margin: 0;
width: 100%;
min-height: 100%;
overflow-x: clip;
overflow-y: visible;
background: #080611;
color: #fff;
font-family:
-apple-system, BlinkMacSystemFont, 'SF Pro Text',
'Source Han Sans SC', 'Segoe UI', Arial, sans-serif;
}
body {
display: flex;
justify-content: center;
}
button {
margin: 0;
border: 0;
padding: 0;
background: transparent;
color: inherit;
font: inherit;
cursor: pointer;
}
.level-page {
position: relative;
isolation: isolate;
width: min(100%, 375px);
min-height: 100vh;
overflow: visible;
padding-bottom: calc(38px + env(safe-area-inset-bottom));
background: var(--level-bg);
direction: ltr;
}
.level-page[data-theme='wealth'] {
--level-bg: #0d0b20;
--header-bg: rgba(13, 11, 32, 0.94);
--accent: #ffad3d;
--atmosphere: url('./fami-assets/wealth-bg.png');
}
.level-page[data-theme='charm'] {
--level-bg: #201a16;
--header-bg: rgba(32, 26, 22, 0.94);
--accent: #f718e9;
--atmosphere: url('./fami-assets/charm-bg.png');
}
.level-page[data-theme='game'] {
--level-bg: #08140c;
--header-bg: rgba(8, 20, 12, 0.94);
--accent: #11ef67;
--atmosphere: url('./fami-assets/game-bg.png');
}
.atmosphere {
position: absolute;
z-index: -2;
inset: 0 0 auto;
height: 375px;
background: var(--atmosphere) top center / cover no-repeat;
pointer-events: none;
}
.atmosphere::after {
content: '';
position: absolute;
inset: auto 0 0;
height: 158px;
background: linear-gradient(
to bottom,
rgba(0, 0, 0, 0),
var(--level-bg) 86%
);
}
/* 导航栏和 Tab 作为同一个吸顶层移动,避免 iOS 回弹滚动时两层之间出现缝隙。 */
.sticky-header {
position: sticky;
z-index: 50;
top: 0;
height: 88px;
isolation: isolate;
}
.sticky-header::before {
content: '';
position: absolute;
z-index: -1;
inset: 0;
background: var(--header-bg);
opacity: 0;
backdrop-filter: blur(14px);
-webkit-backdrop-filter: blur(14px);
transition: opacity 160ms ease;
}
.sticky-header.is-scrolled::before {
opacity: 1;
}
.top-nav {
position: relative;
height: 44px;
}
.back {
position: absolute;
top: 10px;
left: 16px;
width: 24px;
height: 24px;
overflow: hidden;
text-indent: -999px;
}
.back::before {
content: '';
position: absolute;
top: 5px;
left: 7px;
width: 10px;
height: 10px;
border-bottom: 1.8px solid #fff;
border-left: 1.8px solid #fff;
transform: rotate(45deg);
}
.title {
position: absolute;
top: 11px;
right: 48px;
left: 48px;
font-size: 18px;
font-weight: 500;
line-height: 22px;
text-align: center;
text-transform: capitalize;
}
.tabs {
height: 44px;
display: grid;
grid-template-columns: repeat(3, 1fr);
}
.tab {
position: relative;
height: 44px;
padding-top: 11px;
color: rgba(255, 255, 255, 0.5);
font-size: 18px;
font-weight: 700;
line-height: 20px;
text-align: center;
text-transform: capitalize;
}
.tab.active {
color: #fff;
}
.tab.active::after {
content: '';
position: absolute;
bottom: 3px;
left: 50%;
width: 16px;
height: 3px;
border-radius: 31px;
background: #fff;
transform: translateX(-50%);
}
.current-card {
position: relative;
height: 114px;
margin: 24px 16px 0;
}
.current-card__bg {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: fill;
pointer-events: none;
}
.avatar {
position: absolute;
z-index: 1;
top: 21px;
left: 25px;
width: 67px;
height: 67px;
border-radius: 50%;
object-fit: cover;
}
.avatar-frame {
position: absolute;
z-index: 2;
top: 13px;
left: 17px;
display: none;
width: 83px;
height: 83px;
object-fit: contain;
pointer-events: none;
}
.current-copy {
position: absolute;
top: 19px;
left: 125px;
width: 184px;
}
.current-heading {
height: 20px;
display: flex;
align-items: center;
gap: 8px;
font-size: 16px;
font-weight: 600;
line-height: 20px;
text-transform: uppercase;
}
.mini-badge {
position: relative;
width: 42px;
height: 16px;
flex: 0 0 42px;
}
.mini-badge img {
display: block;
width: 42px;
height: 16px;
}
.mini-badge span {
position: absolute;
top: 2px;
left: 22px;
width: 17px;
font-family: 'DIN Alternate', Arial, sans-serif;
font-size: 8px;
font-weight: 700;
line-height: 11px;
text-align: center;
}
.progress {
position: absolute;
top: 30px;
left: 0;
width: 184px;
height: 6px;
overflow: hidden;
border-radius: 32px;
background: rgba(255, 255, 255, 0.5);
}
.progress span {
display: block;
width: 25%;
height: 100%;
border-radius: inherit;
background: #ffc977;
}
.next-level {
position: absolute;
top: 46px;
left: 0;
width: 184px;
font-size: 11px;
font-weight: 600;
line-height: 14px;
}
.next-level strong {
color: #ffb805;
}
.rewards {
margin: 26px 16px 0;
}
.section-title {
height: 20px;
margin: 0;
font-size: 16px;
font-weight: 500;
line-height: 20px;
}
.timeline {
position: relative;
display: grid;
gap: 12px;
margin-top: 13px;
}
.timeline::before {
content: '';
position: absolute;
top: 3px;
bottom: 0;
left: 13px;
width: 1px;
background: rgba(255, 255, 255, 0.22);
}
.timeline[aria-busy='true'] {
opacity: 0.72;
}
.tier-empty {
min-height: 46px;
margin-left: 32px;
padding: 15px 12px;
border-radius: 8px;
background: var(--row-bg);
color: rgba(255, 255, 255, 0.6);
font-size: 12px;
line-height: 16px;
text-align: center;
}
.level-row {
position: relative;
height: 46px;
margin-left: 32px;
border-radius: 8px;
background: var(--row-bg);
}
.level-row::before {
content: '';
position: absolute;
top: 1px;
left: -37px;
width: 4px;
height: 4px;
border-radius: 50%;
background: #4f4f4f;
}
.level-row:first-child::before {
top: 0;
left: -38px;
width: 6px;
height: 6px;
background: var(--accent);
box-shadow: 0 0 4px var(--accent);
}
.level-name {
position: absolute;
top: 7px;
left: 12px;
font-size: 12px;
font-weight: 500;
line-height: 14px;
white-space: nowrap;
}
.requirement {
position: absolute;
top: 28px;
left: 12px;
color: rgba(255, 255, 255, 0.6);
font-size: 8px;
line-height: 10px;
white-space: nowrap;
}
.row-badge {
position: absolute;
top: 15px;
right: 74px;
width: 42px;
height: 16px;
}
.row-ring {
position: absolute;
top: 2px;
right: 16px;
width: 42px;
height: 42px;
object-fit: contain;
}
.upgrade {
margin-top: 30px;
text-align: center;
}
.upgrade-title {
margin: 0;
color: var(--accent);
font-size: 20px;
font-weight: 700;
line-height: 24px;
}
.upgrade-methods {
display: none;
margin-top: 18px;
}
[data-theme='wealth'] .methods-wealth {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
}
[data-theme='charm'] .methods-charm,
[data-theme='game'] .methods-game {
display: block;
}
.method img {
display: block;
width: 54px;
height: 54px;
margin: 0 auto;
}
.method-name,
.method-rate {
font-size: 14px;
line-height: 18px;
white-space: nowrap;
}
.method-name {
margin-top: 10px;
font-weight: 500;
}
.method-rate {
margin-top: 1px;
}
[dir='rtl'] .current-copy,
[dir='rtl'] .level-name,
[dir='rtl'] .requirement {
direction: rtl;
text-align: left;
}
@media (max-width: 374px) {
.current-copy {
right: 18px;
left: auto;
width: calc(100% - 143px);
}
.progress,
.next-level {
width: 100%;
}
.row-badge {
right: 68px;
}
.row-ring {
right: 12px;
}
}
@media (prefers-reduced-motion: reduce) {
.sticky-header::before {
transition: none;
}
}
</style>
</head>
<body>
<main id="levelPage" class="level-page" data-theme="wealth">
<div class="atmosphere" aria-hidden="true"></div>
<header id="stickyHeader" class="sticky-header">
<nav class="top-nav" aria-label="Level navigation">
<button
id="backButton"
class="back"
type="button"
aria-label="Back"
data-i18n="fami_level.back"
data-i18n-aria="fami_level.back"
>
Back
</button>
<div class="title" data-i18n="fami_level.title">Level</div>
</nav>
<div id="tabs" class="tabs" role="tablist">
<button
class="tab active"
type="button"
role="tab"
data-tab="wealth"
aria-selected="true"
data-i18n="fami_level.wealth"
>
Wealth
</button>
<button
class="tab"
type="button"
role="tab"
data-tab="charm"
aria-selected="false"
data-i18n="fami_level.charm"
>
Charm
</button>
<button
class="tab"
type="button"
role="tab"
data-tab="game"
aria-selected="false"
data-i18n="fami_level.game"
>
Game
</button>
</div>
</header>
<section class="current-card" aria-label="Current level">
<img
id="cardBackground"
class="current-card__bg"
src="./fami-assets/wealth-card.png"
alt=""
/>
<img
id="avatar"
class="avatar"
src="./fami-assets/avatar.png"
alt=""
/>
<img id="avatarFrame" class="avatar-frame" alt="" />
<div class="current-copy">
<div class="current-heading">
<span id="currentLevel">LV20</span>
<span
id="miniBadge"
class="mini-badge"
aria-label="Badge level 81"
>
<img
id="currentBadgeImage"
src="./fami-assets/level-badge.png"
alt=""
/>
<span id="badgeLevel">81</span>
</span>
</div>
<div
class="progress"
role="progressbar"
aria-valuemin="0"
aria-valuemax="100"
aria-valuenow="25"
>
<span id="progressValue"></span>
</div>
<div class="next-level">
<span id="nextLevelProgress">
<span data-i18n="fami_level.next_prefix"
>To the next level need</span
>
<strong id="nextExperience">5000.0</strong>
<span data-i18n="fami_level.experience_points"
>experience points</span
>
</span>
<span
id="maxLevelText"
data-i18n="fami_level.max_level"
hidden
>Max level reached</span
>
</div>
</div>
</section>
<section class="rewards">
<h2 class="section-title" data-i18n="fami_level.rewards">
Level Rewards
</h2>
<div id="timeline" class="timeline"></div>
</section>
<section class="upgrade">
<h2 class="upgrade-title" data-i18n="fami_level.how_to_upgrade">
How to upgrade?
</h2>
<div class="upgrade-methods methods-wealth">
<article class="method">
<img src="./fami-assets/upgrade-gift.svg" alt="" />
<div
class="method-name"
data-i18n="fami_level.receive_gift"
>
Receive a Gift
</div>
<div
class="method-rate"
data-i18n="fami_level.coin_rate"
>
1 Coins = 1 EXP
</div>
</article>
<article class="method">
<img src="./fami-assets/upgrade-car.svg" alt="" />
<div class="method-name" data-i18n="fami_level.buy_car">
Buy a car
</div>
<div
class="method-rate"
data-i18n="fami_level.coin_rate"
>
1 Coins = 1 EXP
</div>
</article>
<article class="method">
<img src="./fami-assets/upgrade-frame.svg" alt="" />
<div
class="method-name"
data-i18n="fami_level.buy_avatar_frame"
>
Buy Avatar Frame
</div>
<div
class="method-rate"
data-i18n="fami_level.coin_rate"
>
1 Coins = 1 EXP
</div>
</article>
</div>
<div class="upgrade-methods methods-charm">
<article class="method">
<img src="./fami-assets/upgrade-charm.svg" alt="" />
<div
class="method-name"
data-i18n="fami_level.receive_gift"
>
Receive a gift
</div>
<div
class="method-rate"
data-i18n="fami_level.coin_rate"
>
1 Coins = 1 EXP
</div>
</article>
</div>
<div class="upgrade-methods methods-game">
<article class="method">
<img src="./fami-assets/upgrade-game.svg" alt="" />
<div
class="method-name"
data-i18n="fami_level.earned_gameplay"
>
Earned through gameplay
</div>
<div
class="method-rate"
data-i18n="fami_level.coin_rate"
>
1 Coins = 1 EXP
</div>
</article>
</div>
</section>
</main>
<script>
/* 该入口只服务 Fami。先固化 URL 与页面默认 App再加载公共 API确保请求头和用户资料接口始终使用同一租户。 */
(function () {
window.HyAppDefaultAppCode = 'fami';
var url = new URL(window.location.href);
var changed =
url.searchParams.has('app') ||
url.searchParams.has('appCode');
url.searchParams.delete('app');
url.searchParams.delete('appCode');
if (url.searchParams.get('app_code') !== 'fami') {
url.searchParams.set('app_code', 'fami');
changed = true;
}
if (changed && window.history && window.history.replaceState) {
window.history.replaceState(null, '', url.toString());
}
})();
window.HyAppI18nSupported = ['en', 'ar', 'tr', 'es'];
</script>
<script src="../common/api.js"></script>
<script src="../common/toast.js"></script>
<script src="../common/params.js"></script>
<script src="../common/jsbridge.js"></script>
<script src="../common/i18n.js"></script>
<script>
(function () {
var THEMES = ['wealth', 'charm', 'game'];
var PREVIEW_LEVEL_RANGES = [
'0',
'1-9',
'10-19',
'20-29',
'30-39',
'40-49',
'50-59',
'60-69',
'70-79',
'80-89',
'90-99',
'100',
];
var CARD_ASSETS = {
wealth: './fami-assets/wealth-card.png',
charm: './fami-assets/charm-card-overlay.png',
game: './fami-assets/game-card-overlay.png',
};
var LOCAL_AVATAR = './fami-assets/avatar.png';
var LOCAL_BADGE = './fami-assets/level-badge.png';
var LOCAL_FRAME = './fami-assets/level-ring.png';
var page = document.getElementById('levelPage');
var header = document.getElementById('stickyHeader');
var timeline = document.getElementById('timeline');
var tabs = Array.prototype.slice.call(
document.querySelectorAll('[data-tab]')
);
var params = new URLSearchParams(window.location.search);
var state = {
theme: normalizeTheme(
params.get('tab') || params.get('type')
),
details: {},
loadedTracks: {},
loadingTracks: {},
requestIDs: {},
nextRequestID: 0,
userAvatar: '',
};
function byId(id) {
return document.getElementById(id);
}
function translated(key, fallback) {
return window.HyAppI18n
? window.HyAppI18n.t(key, fallback)
: fallback;
}
function formatNumber(value) {
var locale = window.HyAppI18n
? window.HyAppI18n.lang()
: 'en';
return Number(value || 0).toLocaleString(locale, {
maximumFractionDigits: 0,
});
}
function readField(source, snakeKey, camelKey) {
if (!source) return null;
return source[snakeKey] != null
? source[snakeKey]
: source[camelKey];
}
function escapeHTML(value) {
return String(value == null ? '' : value).replace(
/[&<>"']/g,
function (character) {
return {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;',
}[character];
}
);
}
function staticImageURL(resource) {
var candidates = [
readField(resource, 'preview_url', 'previewUrl'),
readField(resource, 'asset_url', 'assetUrl'),
readField(resource, 'animation_url', 'animationUrl'),
];
for (var index = 0; index < candidates.length; index += 1) {
var url = String(candidates[index] || '').trim();
/* 列表使用 img 展示静态素材;动画主资源不能直接塞入 img必须等待接口提供静态预览图。 */
if (
/^(https?:\/\/|\/|\.\.\/|\.\/)/i.test(url) &&
/\.(avif|gif|jpe?g|png|svg|webp)(\?|#|$)/i.test(url)
) {
return url;
}
}
return '';
}
function tierResource(tier, snakeKey, camelKey) {
return readField(tier, snakeKey, camelKey) || {};
}
function renderPreviewTimeline() {
timeline.innerHTML = PREVIEW_LEVEL_RANGES.map(
function (range) {
var requirement =
translated('fami_level.requires', 'Requires') +
' 05,000 ' +
translated(
'fami_level.experience_points',
'experience points'
);
return (
'<article class="level-row">' +
'<div class="level-name">' +
escapeHTML(
translated('fami_level.level', 'Level')
) +
' ' +
escapeHTML(range) +
'</div>' +
'<div class="requirement">' +
escapeHTML(requirement) +
'</div>' +
'<img class="row-badge" src="' +
LOCAL_BADGE +
'" alt="">' +
'<img class="row-ring" src="' +
LOCAL_FRAME +
'" alt="">' +
'</article>'
);
}
).join('');
}
function requirementForTier(detail, tier) {
var minLevel = Number(
readField(tier, 'min_level', 'minLevel') || 0
);
var maxLevel = Number(
readField(tier, 'max_level', 'maxLevel') || 0
);
var values = ((detail && detail.rules) || [])
.filter(function (rule) {
var level = Number(
readField(rule, 'level', 'level') || 0
);
var status = String(
readField(rule, 'status', 'status') || 'active'
);
return (
status === 'active' &&
level >= minLevel &&
level <= maxLevel
);
})
.map(function (rule) {
return Number(
readField(
rule,
'required_value',
'requiredValue'
) || 0
);
})
.filter(function (value) {
return Number.isFinite(value) && value >= 0;
})
.sort(function (left, right) {
return left - right;
});
if (!values.length) return '—';
var first = formatNumber(values[0]);
var last = formatNumber(values[values.length - 1]);
return first === last ? first : first + '' + last;
}
function renderAPITimeline(detail) {
var tiers = ((detail && detail.tiers) || [])
.slice()
.filter(function (tier) {
return (
String(
readField(tier, 'status', 'status') ||
'active'
) === 'active'
);
})
.sort(function (left, right) {
return (
Number(
readField(left, 'min_level', 'minLevel') ||
0
) -
Number(
readField(right, 'min_level', 'minLevel') ||
0
)
);
});
if (!tiers.length) {
timeline.innerHTML =
'<div class="tier-empty">' +
escapeHTML(
translated(
'fami_level.no_data',
'No level data'
)
) +
'</div>';
return;
}
timeline.innerHTML = tiers
.map(function (tier) {
var minLevel = Number(
readField(tier, 'min_level', 'minLevel') || 0
);
var maxLevel = Number(
readField(tier, 'max_level', 'maxLevel') || 0
);
var range =
minLevel === maxLevel
? String(minLevel)
: minLevel + '-' + maxLevel;
var badgeURL = staticImageURL(
tierResource(
tier,
'display_badge_resource',
'displayBadgeResource'
)
);
var frameURL = staticImageURL(
tierResource(
tier,
'display_avatar_frame_resource',
'displayAvatarFrameResource'
)
);
var requirement =
translated('fami_level.requires', 'Requires') +
' ' +
requirementForTier(detail, tier) +
' ' +
translated(
'fami_level.experience_points',
'experience points'
);
return (
'<article class="level-row">' +
'<div class="level-name">' +
escapeHTML(
translated('fami_level.level', 'Level')
) +
' ' +
escapeHTML(range) +
'</div>' +
'<div class="requirement">' +
escapeHTML(requirement) +
'</div>' +
(badgeURL
? '<img class="row-badge" src="' +
escapeHTML(badgeURL) +
'" alt="">'
: '') +
(frameURL
? '<img class="row-ring" src="' +
escapeHTML(frameURL) +
'" alt="">'
: '') +
'</article>'
);
})
.join('');
timeline.querySelectorAll('img').forEach(function (image) {
image.addEventListener(
'error',
function () {
/* 单个 CDN 素材失效时只隐藏对应素材,等级数据和其他行仍保持可读。 */
image.style.display = 'none';
},
{ once: true }
);
});
}
function renderTimeline() {
timeline.setAttribute(
'aria-busy',
state.loadingTracks[state.theme] ? 'true' : 'false'
);
if (!state.loadedTracks[state.theme]) {
renderPreviewTimeline();
return;
}
renderAPITimeline(state.details[state.theme] || {});
}
function normalizeTheme(value) {
var theme = String(value || '')
.trim()
.toLowerCase();
return THEMES.indexOf(theme) >= 0 ? theme : 'wealth';
}
function clampNumber(value, fallback, min, max) {
if (value === null || String(value).trim() === '')
return fallback;
var parsed = Number(value);
if (!Number.isFinite(parsed)) return fallback;
return Math.min(max, Math.max(min, parsed));
}
function validAvatarURL(value) {
var url = String(value || '').trim();
return /^(https?:\/\/|\/\/|\/|\.\.\/|\.\/)/i.test(url)
? url
: '';
}
function setAvatarSource(value) {
var avatar = byId('avatar');
var source = validAvatarURL(value) || LOCAL_AVATAR;
avatar.onerror = function () {
if (avatar.getAttribute('src') !== LOCAL_AVATAR) {
avatar.src = LOCAL_AVATAR;
}
};
if (avatar.getAttribute('src') !== source)
avatar.src = source;
}
function setOptionalImage(element, source) {
if (!source) {
element.removeAttribute('src');
element.style.display = 'none';
return;
}
element.style.display = 'block';
element.onerror = function () {
element.style.display = 'none';
};
element.src = source;
}
function overviewRatio(overview) {
var current = Number(
readField(
overview,
'current_level_required_value',
'currentLevelRequiredValue'
) || 0
);
var next = Number(
readField(
overview,
'next_level_required_value',
'nextLevelRequiredValue'
) || 0
);
var total = Number(
readField(overview, 'total_value', 'totalValue') || 0
);
if (next <= current)
return total >= current && current > 0 ? 1 : 0;
return Math.max(
0,
Math.min(1, (total - current) / (next - current))
);
}
function renderPreviewOverview() {
/* 没有 App token 时保留设计稿预览能力,便于本地核对;真实登录态会被接口数据完整覆盖。 */
var level = Math.round(
clampNumber(params.get('level'), 20, 0, 100)
);
var badge = Math.round(
clampNumber(params.get('badge_level'), 81, 0, 999)
);
var progress = clampNumber(
params.get('progress'),
25,
0,
100
);
var nextExperience = clampNumber(
params.get('next_exp'),
5000,
0,
999999999
);
byId('currentLevel').textContent = 'LV' + level;
byId('badgeLevel').textContent = badge;
byId('miniBadge').style.display = 'block';
byId('currentBadgeImage').src = LOCAL_BADGE;
byId('nextExperience').textContent =
nextExperience.toFixed(1);
byId('nextLevelProgress').hidden = false;
byId('maxLevelText').hidden = true;
byId('progressValue').style.width = progress + '%';
document
.querySelector('.progress')
.setAttribute(
'aria-valuenow',
String(Math.round(progress))
);
setOptionalImage(byId('avatarFrame'), '');
setAvatarSource(state.userAvatar || params.get('avatar'));
}
function renderAPIOverview(overview) {
var level = Number(
readField(overview, 'level', 'level') || 0
);
var nextLevel = Number(
readField(overview, 'next_level', 'nextLevel') || 0
);
var currentRequired = Number(
readField(
overview,
'current_level_required_value',
'currentLevelRequiredValue'
) || 0
);
var nextRequired = Number(
readField(
overview,
'next_level_required_value',
'nextLevelRequiredValue'
) || 0
);
var total = Number(
readField(overview, 'total_value', 'totalValue') || 0
);
var isMaxLevel =
nextLevel <= 0 || nextRequired <= currentRequired;
var progress = isMaxLevel
? 100
: Math.round(overviewRatio(overview) * 100);
var badgeURL = staticImageURL(
readField(
overview,
'display_badge_resource',
'displayBadgeResource'
) || {}
);
var badgeSourceLevel = Number(
readField(
overview,
'display_badge_source_level',
'displayBadgeSourceLevel'
) || level
);
var badge = byId('miniBadge');
var badgeImage = byId('currentBadgeImage');
byId('currentLevel').textContent = 'LV' + level;
byId('progressValue').style.width = progress + '%';
document
.querySelector('.progress')
.setAttribute('aria-valuenow', String(progress));
byId('nextLevelProgress').hidden = isMaxLevel;
byId('maxLevelText').hidden = !isMaxLevel;
if (!isMaxLevel) {
byId('nextExperience').textContent = formatNumber(
Math.max(0, nextRequired - total)
);
}
if (badgeURL) {
badge.style.display = 'block';
byId('badgeLevel').textContent = badgeSourceLevel;
badgeImage.style.display = 'block';
badgeImage.onerror = function () {
badge.style.display = 'none';
};
badgeImage.src = badgeURL;
} else {
badge.style.display = 'none';
}
setOptionalImage(
byId('avatarFrame'),
staticImageURL(
readField(
overview,
'display_avatar_frame_resource',
'displayAvatarFrameResource'
) || {}
)
);
setAvatarSource(state.userAvatar || params.get('avatar'));
}
function renderOverview() {
var detail = state.details[state.theme];
if (!detail || !detail.overview) {
renderPreviewOverview();
return;
}
renderAPIOverview(detail.overview);
}
function render() {
byId('cardBackground').src = CARD_ASSETS[state.theme];
renderOverview();
renderTimeline();
}
function applyTheme(value, syncURL) {
var theme = normalizeTheme(value);
state.theme = theme;
page.dataset.theme = theme;
tabs.forEach(function (tab) {
var active = tab.dataset.tab === theme;
tab.classList.toggle('active', active);
tab.setAttribute('aria-selected', String(active));
});
/* 切换主题时保留滚动位置;用户对照等级区间时不会被强制拉回页面顶部。 */
if (syncURL && window.history.replaceState) {
params.set('tab', theme);
window.history.replaceState(
null,
'',
window.location.pathname +
'?' +
params.toString() +
window.location.hash
);
}
render();
}
function profileFromCurrent(current) {
var user = current && current.user;
return (user && (user.profile || user.user || user)) || {};
}
function profileAvatar(profile) {
return String(
(profile &&
(profile.avatar ||
profile.user_avatar ||
profile.userAvatar ||
profile.avatar_url ||
profile.avatarUrl ||
profile.profile_avatar ||
profile.profileAvatar)) ||
''
);
}
function showNetworkError() {
if (!window.HyAppToast) return;
window.HyAppToast.show(
translated(
'fami_level.network_error',
'Unable to load level data. Please try again.'
)
);
}
function canRequestLevel() {
return Boolean(
window.HyAppAPI &&
window.HyAppAPI.level &&
window.HyAppAPI.getAccessToken &&
window.HyAppAPI.getAccessToken()
);
}
function loadOverview() {
if (!canRequestLevel()) return Promise.resolve(null);
return window.HyAppAPI.level
.overview()
.then(function (data) {
((data && data.tracks) || []).forEach(
function (overview) {
var theme = normalizeTheme(
overview && overview.track
);
if (
!overview ||
THEMES.indexOf(overview.track) < 0
)
return;
if (!state.details[theme])
state.details[theme] = {};
state.details[theme].overview = overview;
}
);
renderOverview();
return data;
})
.catch(function () {
/* 单轨详情仍可独立完成页面渲染,总览失败不重复弹错干扰用户。 */
return null;
});
}
function loadTrack(value, syncURL) {
var theme = normalizeTheme(value);
var requestID = ++state.nextRequestID;
state.requestIDs[theme] = requestID;
applyTheme(theme, syncURL);
if (!canRequestLevel()) return Promise.resolve(null);
state.loadingTracks[theme] = true;
renderTimeline();
return window.HyAppAPI.level
.track(theme)
.then(function (data) {
/* 快速切换 Tab 时旧响应只能写回自己的缓存,不能覆盖同轨道较新的请求。 */
if (state.requestIDs[theme] !== requestID)
return null;
state.details[theme] = data || {};
state.loadedTracks[theme] = true;
if (state.theme === theme) render();
return data;
})
.catch(function () {
if (
state.requestIDs[theme] === requestID &&
state.theme === theme
) {
showNetworkError();
}
return null;
})
.finally(function () {
if (state.requestIDs[theme] !== requestID) return;
state.loadingTracks[theme] = false;
if (state.theme === theme) renderTimeline();
});
}
function loadUserProfile() {
if (!window.HyAppParams || !window.HyAppParams.loadUser) {
return Promise.resolve(null);
}
return window.HyAppParams.loadUser()
.then(function (current) {
state.userAvatar = profileAvatar(
profileFromCurrent(current)
);
renderOverview();
return current;
})
.catch(function () {
/* 用户头像失败不影响等级主接口,继续使用设计稿头像兜底。 */
return null;
});
}
function hasNativeBridge() {
return Boolean(
(window.flutter_inappwebview &&
window.flutter_inappwebview.callHandler) ||
(window.HyAppBridgeChannel &&
window.HyAppBridgeChannel.postMessage) ||
(window.FlutterBridge &&
window.FlutterBridge.postMessage) ||
(window.webkit &&
window.webkit.messageHandlers &&
window.webkit.messageHandlers.HyAppBridge)
);
}
byId('tabs').addEventListener('click', function (event) {
var tab = event.target.closest('[data-tab]');
if (!tab || tab.dataset.tab === state.theme) return;
loadTrack(tab.dataset.tab, true);
});
byId('backButton').addEventListener('click', function () {
/* App 内交给原生关闭 WebView普通浏览器预览才走 history避免空桥接吞掉返回。 */
if (hasNativeBridge() && window.HyAppBridge) {
window.HyAppBridge.back();
return;
}
if (window.history.length > 1) window.history.back();
});
window.addEventListener(
'scroll',
function () {
header.classList.toggle(
'is-scrolled',
window.scrollY > 8
);
},
{ passive: true }
);
window.addEventListener('hyapp:i18n-ready', function () {
/* 接口行是在语言包之后动态生成的,语言切换后需要按当前数据重新排版文案和数字。 */
render();
});
applyTheme(state.theme, false);
if (window.HyAppBridge) {
window.HyAppBridge.ready({
page: 'fami-level',
app_code: 'fami',
tab: state.theme,
});
}
loadTrack(state.theme, false).finally(function () {
loadOverview();
loadUserProfile();
});
})();
</script>
</body>
</html>