1468 lines
54 KiB
HTML
1468 lines
54 KiB
HTML
<!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.webp');
|
||
}
|
||
|
||
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;
|
||
/* 页面宽度跟随 WebView,375px 只作为设计基准,不在大屏设备两侧留下空白。 */
|
||
width: 100%;
|
||
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.webp');
|
||
}
|
||
|
||
.level-page[data-theme='charm'] {
|
||
--level-bg: #201a16;
|
||
--header-bg: rgba(32, 26, 22, 0.94);
|
||
--accent: #f718e9;
|
||
--atmosphere: url('./fami-assets/charm-bg.webp');
|
||
}
|
||
|
||
.level-page[data-theme='game'] {
|
||
--level-bg: #08140c;
|
||
--header-bg: rgba(8, 20, 12, 0.94);
|
||
--accent: #11ef67;
|
||
--atmosphere: url('./fami-assets/game-bg.webp');
|
||
}
|
||
|
||
.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;
|
||
right: 34px;
|
||
left: 125px;
|
||
width: auto;
|
||
}
|
||
|
||
.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: 100%;
|
||
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: 100%;
|
||
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 {
|
||
/* 三列升级方式在 320px 小屏仍需保留列间留白,375px 起恢复设计稿字号。 */
|
||
font-size: clamp(12px, 3.733vw, 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);
|
||
}
|
||
|
||
.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.webp"
|
||
alt=""
|
||
/>
|
||
<img
|
||
id="avatar"
|
||
class="avatar"
|
||
src="./fami-assets/avatar.webp"
|
||
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.webp"
|
||
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.send_gift"
|
||
>
|
||
Send 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.webp',
|
||
charm: './fami-assets/charm-card-overlay.webp',
|
||
game: './fami-assets/game-card-overlay.webp',
|
||
};
|
||
var LOCAL_AVATAR = './fami-assets/avatar.webp';
|
||
var LOCAL_BADGE = './fami-assets/level-badge.webp';
|
||
var LOCAL_FRAME = './fami-assets/level-ring.webp';
|
||
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 {
|
||
'&': '&',
|
||
'<': '<',
|
||
'>': '>',
|
||
'"': '"',
|
||
"'": ''',
|
||
}[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') +
|
||
' 0–5,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>
|