(function () { 'use strict'; var DESIGN_WIDTH = 1080; var DESIGN_HEIGHTS = { history: 2651, weekly: 2460, rewards: 3532, }; // Weekly Star 的公开 DTO 只提供 UTC epoch,不携带展示时区;Fami 设计与产品文案明确固定 UTC+3, // 因此集中维护 +180 分钟展示规则,不能根据浏览器本地时区推断,否则同一周期会在不同国家显示不同日期。 var DISPLAY_OFFSET_MINUTES = 180; var MAX_VISIBLE_RANKS = 5; var ASSET_BASE = './fami/assets/'; var LAYER_BASE = ASSET_BASE + 'layers/'; var FIXTURE_BASE = ASSET_BASE + 'fixtures/'; var root = document.getElementById('famiWeekStar'); var stageWrap = document.getElementById('famiStageWrap'); var cycleRangeNode = document.getElementById('famiCycleRange'); var historyRangeNode = document.getElementById('famiHistoryRange'); var giftListNode = document.getElementById('famiGiftList'); var weeklyListNode = document.getElementById('famiWeeklyList'); var historyListNode = document.getElementById('famiHistoryList'); var rewardGroupsNode = document.getElementById('famiRewardGroups'); var tabs = array(document.querySelectorAll('[data-tab-target]')); var panels = array(document.querySelectorAll('[role="tabpanel"]')); var giftTemplate = document.getElementById('famiGiftTemplate'); var rankTemplate = document.getElementById('famiRankTemplate'); var rewardGroupTemplate = document.getElementById( 'famiRewardGroupTemplate' ); var rewardItemTemplate = document.getElementById('famiRewardItemTemplate'); var params = new URLSearchParams(window.location.search || ''); // 设计示例头像/礼物只在显式 mock=1 的验收入口使用;生产接口失败必须展示错误态,不能拿示例业务媒体伪装真实数据。 var useMock = params.get('mock') === '1'; var state = { tab: normalizeTab(params.get('tab') || window.location.hash.slice(1)), currentLoading: true, weeklyLoading: true, historyLoading: true, current: null, weeklyEntries: [], historyCycles: [], currentError: '', historyError: '', }; function array(value) { return Array.prototype.slice.call(value || []); } function text(value) { return String( value === undefined || value === null ? '' : value ).trim(); } function number(value, fallback) { var result = Number(value); return Number.isFinite(result) ? result : Number(fallback || 0); } function first(source, keys, fallback) { source = source || {}; for (var index = 0; index < keys.length; index += 1) { var value = source[keys[index]]; if (value !== undefined && value !== null && value !== '') { return value; } } return fallback; } function firstObject(source, keys) { var value = first(source, keys, null); return value && typeof value === 'object' ? value : {}; } function firstArray(source, keys) { var value = first(source, keys, null); return Array.isArray(value) ? value : []; } function t(key, fallback) { if (window.HyAppI18n && window.HyAppI18n.t) { return window.HyAppI18n.t(key, fallback); } return fallback || key; } function formatMessage(key, fallback, values) { var result = t(key, fallback); Object.keys(values || {}).forEach(function (name) { result = result.replace( new RegExp('\\{' + name + '\\}', 'g'), String(values[name]) ); }); return result; } function currentLocale() { return window.HyAppI18n && window.HyAppI18n.lang ? window.HyAppI18n.lang() : 'en'; } function formatNumber(value) { try { return new Intl.NumberFormat(currentLocale()).format( number(value, 0) ); } catch (error) { return String(number(value, 0)); } } function formatScore(value) { // 设计稿的榜单分数是不带千分位的原始整数;不能复用奖励数量的本地化格式,否则会变成 337,700。 return String(Math.trunc(number(value, 0))); } function pad(value) { return String(value).padStart(2, '0'); } function shiftedDate(value) { var timestamp = number(value, 0); if (!timestamp) return null; return new Date(timestamp + DISPLAY_OFFSET_MINUTES * 60 * 1000); } function formatCycleRange(start, end) { var from = shiftedDate(start); var to = shiftedDate(end); if (!from || !to) return '--.-- --:-----.-- --:--(utc+3)'; return ( pad(from.getUTCMonth() + 1) + '.' + pad(from.getUTCDate()) + ' ' + pad(from.getUTCHours()) + ':' + pad(from.getUTCMinutes()) + '-' + pad(to.getUTCMonth() + 1) + '.' + pad(to.getUTCDate()) + ' ' + pad(to.getUTCHours()) + ':' + pad(to.getUTCMinutes()) + '(utc+3)' ); } function formatHistoryRange(start, end) { var from = shiftedDate(start); var to = shiftedDate(end); if (!from || !to) return '----/--/-------/--/--'; return ( from.getUTCFullYear() + '/' + pad(from.getUTCMonth() + 1) + '/' + pad(from.getUTCDate()) + '-' + to.getUTCFullYear() + '/' + pad(to.getUTCMonth() + 1) + '/' + pad(to.getUTCDate()) ); } function normalizeTab(value) { if (value === 'weekly') return 'weekly'; if (value === 'reward' || value === 'rewards') return 'rewards'; return 'history'; } function updateStageSize() { var scale = stageWrap.clientWidth / DESIGN_WIDTH; var height = DESIGN_HEIGHTS[state.tab] || DESIGN_HEIGHTS.history; stageWrap.style.setProperty('--design-scale', String(scale)); stageWrap.style.height = Math.ceil(height * scale) + 'px'; document.body.style.minHeight = stageWrap.offsetHeight + 'px'; } function updateURL(tab) { if (!window.history || !window.history.replaceState) return; try { var url = new URL(window.location.href); url.searchParams.set('tab', tab); url.hash = ''; window.history.replaceState(null, '', url.toString()); } catch (error) { // 受限 WebView 可能禁用 History API;Tab 本身仍可正常切换,不让 URL 同步失败阻断页面。 } } function setTab(nextTab, syncURL) { var next = normalizeTab(nextTab); state.tab = next; root.setAttribute('data-tab', next); tabs.forEach(function (tab) { var active = tab.getAttribute('data-tab-target') === next; tab.classList.toggle('is-active', active); tab.setAttribute('aria-selected', active ? 'true' : 'false'); tab.setAttribute('tabindex', active ? '0' : '-1'); }); panels.forEach(function (panel) { panel.hidden = panel.id !== 'famiPanel' + next.charAt(0).toUpperCase() + next.slice(1); }); updateStageSize(); if (syncURL) updateURL(next); } function imageURL(source) { var resource = firstObject(source, ['resource']); return text( first( source, [ 'image_url', 'imageUrl', 'cover_url', 'coverUrl', 'preview_url', 'previewUrl', 'asset_url', 'assetUrl', 'animation_url', 'animationUrl', 'icon_url', 'iconUrl', 'image', ], first( resource, [ 'preview_url', 'previewUrl', 'asset_url', 'assetUrl', 'animation_url', 'animationUrl', 'image_url', 'imageUrl', ], '' ) ) ); } function setImage(node, url, alt) { node.alt = text(alt); if (!text(url)) { node.removeAttribute('src'); return; } node.src = text(url); node.onerror = function () { // 资源 CDN 失败时移除坏图标,由同一独立卡位内的 CSS 占位层接管,不能回退到设计示例业务媒体。 node.removeAttribute('src'); }; } function initial(value) { var normalized = text(value); return normalized ? normalized.charAt(0) : '?'; } function normalizeEntry(entry, index) { entry = entry || {}; var user = firstObject(entry, ['user']); return { rank: number( first(entry, ['rank_no', 'rankNo', 'rank'], index + 1), index + 1 ), name: text( first( user, ['username', 'display_user_id', 'displayUserId'], first(entry, ['user_id', 'userId'], '') ) ), score: number(first(entry, ['score'], 0), 0), avatar: text( first(user, ['avatar', 'avatar_url', 'avatarUrl'], '') ), }; } function normalizeEntries(entries) { return (Array.isArray(entries) ? entries : []).map(normalizeEntry); } function rankMedal(rank) { if (rank === 1) return LAYER_BASE + 'medal-1.webp'; if (rank === 2) return LAYER_BASE + 'medal-2.webp'; if (rank === 3) return LAYER_BASE + 'medal-3.webp'; return ''; } function renderListState(target, message) { target.innerHTML = ''; var stateNode = document.createElement('div'); stateNode.className = 'fami-list-state'; stateNode.textContent = message; target.appendChild(stateNode); } function renderRankList( target, entries, emptyMessage, errorMessage, loading ) { if (loading) { renderListState(target, t('fami_week_star.loading', 'Loading...')); return; } if (errorMessage) { renderListState( target, t('fami_week_star.load_failed', 'Unable to load rankings.') ); return; } var rows = normalizeEntries(entries).slice(0, MAX_VISIBLE_RANKS); if (!rows.length) { renderListState(target, emptyMessage); return; } target.innerHTML = ''; rows.forEach(function (entry) { var fragment = rankTemplate.content.cloneNode(true); var row = fragment.querySelector('.fami-rank-row'); var medal = fragment.querySelector('.fami-rank-medal'); var rankNumber = fragment.querySelector('.fami-rank-number'); var avatar = fragment.querySelector('.fami-rank-avatar img'); var avatarPlaceholder = fragment.querySelector( '.fami-avatar-placeholder' ); var name = fragment.querySelector('.fami-rank-name'); var score = fragment.querySelector('.fami-rank-score'); var displayName = entry.name || t('fami_week_star.unknown', 'Unknown'); setImage( medal, rankMedal(entry.rank), formatMessage('fami_week_star.rank_aria', 'Rank {rank}', { rank: entry.rank, }) ); rankNumber.textContent = String(entry.rank); setImage(avatar, entry.avatar, displayName); avatarPlaceholder.textContent = initial(displayName); name.textContent = displayName; score.textContent = formatScore(entry.score); row.setAttribute( 'aria-label', formatMessage( 'fami_week_star.entry_aria', 'Rank {rank}, {name}, {score}', { rank: entry.rank, name: displayName, score: formatScore(entry.score), } ) ); target.appendChild(fragment); }); } function normalizeGift(gift) { gift = gift || {}; var resource = firstObject(gift, ['resource']); return { name: text( first( gift, ['name'], first(resource, ['name'], t('fami_week_star.gift', 'Gift')) ) ), image: imageURL(gift), }; } function renderGifts(gifts, stateMessage) { var normalized = (Array.isArray(gifts) ? gifts : []) .map(normalizeGift) .slice(0, 3); giftListNode.innerHTML = ''; giftListNode.classList.toggle('has-state', Boolean(stateMessage)); if (stateMessage) { var stateNode = document.createElement('div'); stateNode.className = 'fami-current-state'; stateNode.setAttribute('role', 'status'); stateNode.textContent = stateMessage; giftListNode.appendChild(stateNode); return; } for (var index = 0; index < 3; index += 1) { var gift = normalized[index] || { name: t('fami_week_star.gift', 'Gift'), image: '', }; var fragment = giftTemplate.content.cloneNode(true); var card = fragment.querySelector('.fami-gift-card'); var image = fragment.querySelector('.fami-gift-media img'); fragment.querySelector('[data-gift-name]').textContent = gift.name; setImage(image, gift.image, gift.name); card.setAttribute('aria-label', gift.name); giftListNode.appendChild(fragment); } } function rewardImage(item) { return imageURL(item); } function rewardLabel(item, group) { item = item || {}; group = group || {}; var resource = firstObject(item, ['resource']); var walletType = text( first(item, ['wallet_asset_type', 'walletAssetType'], '') ).toUpperCase(); var label = text( first( item, ['label', 'name'], first(resource, ['name'], first(group, ['name'], '')) ) ); if (!label && walletType === 'COIN') { label = t('fami_week_star.coins', 'Coins'); } if (!label) label = t('fami_week_star.reward', 'Reward'); var durationMS = number( first(item, ['duration_ms', 'durationMs'], 0), 0 ); var days = Math.round(durationMS / 86400000); if (durationMS > 0 && days > 0) { label = formatMessage( 'fami_week_star.reward_days', '{name} ({days} days)', { name: label, days: days } ); } var quantity = number(first(item, ['quantity'], 0), 0); var walletAmount = number( first(item, ['wallet_asset_amount', 'walletAssetAmount'], 0), 0 ); if (quantity > 1) label += ' ×' + formatNumber(quantity); else if (walletAmount > 0) label += ' ×' + formatNumber(walletAmount); return label; } function normalizeRewardGroups(rewards) { var source = Array.isArray(rewards) ? rewards : []; return [1, 2, 3].map(function (rank) { var reward = source.find(function (candidate) { return ( number( first(candidate, ['rank_no', 'rankNo', 'rank'], 0), 0 ) === rank ); }); reward = reward || {}; var group = firstObject(reward, [ 'resource_group', 'resourceGroup', 'reward_resource_group', 'rewardResourceGroup', ]); var items = firstArray(reward, ['items', 'resources']); if (!items.length) items = firstArray(group, ['items', 'resources']); return { rank: rank, group: group, items: items.slice(0, 3), }; }); } function renderRewards(rewards, stateMessage) { rewardGroupsNode.innerHTML = ''; var groups = normalizeRewardGroups(rewards); if ( !stateMessage && !groups.some(function (groupData) { return groupData.items.length > 0; }) ) { stateMessage = t( 'fami_week_star.empty_rewards', 'No reward data yet.' ); } if (stateMessage) { var stateNode = document.createElement('div'); stateNode.className = 'fami-reward-state'; stateNode.setAttribute('role', 'status'); stateNode.textContent = stateMessage; rewardGroupsNode.appendChild(stateNode); return; } groups.forEach(function (groupData) { var groupFragment = rewardGroupTemplate.content.cloneNode(true); var groupNode = groupFragment.querySelector('.fami-reward-group'); var heading = groupFragment.querySelector( '.fami-reward-heading h2' ); var itemsNode = groupFragment.querySelector('.fami-reward-items'); heading.textContent = 'TOP' + groupData.rank; itemsNode.setAttribute( 'data-count', String(groupData.items.length) ); groupData.items.forEach(function (item) { var itemFragment = rewardItemTemplate.content.cloneNode(true); var itemNode = itemFragment.querySelector('.fami-reward-item'); var image = itemFragment.querySelector( '.fami-reward-media img' ); var label = rewardLabel(item, groupData.group); setImage(image, rewardImage(item), label); itemFragment.querySelector('.fami-reward-label').textContent = label; itemNode.setAttribute('aria-label', label); itemsNode.appendChild(itemFragment); }); groupNode.setAttribute( 'aria-label', formatMessage( 'fami_week_star.top_rewards_aria', 'Top {rank} rewards', { rank: groupData.rank } ) ); rewardGroupsNode.appendChild(groupFragment); }); } function cycleOf(value) { return firstObject(value, ['cycle']); } function render() { var current = state.current || {}; var cycle = cycleOf(current); var history = state.historyCycles[0] || {}; var historyCycle = cycleOf(history); var currentGifts = firstArray(cycle, ['gifts']); var currentRewards = firstArray(cycle, ['rewards']); var historyEntries = firstArray(history, [ 'top_entries', 'topEntries', 'entries', ]); var hasCycle = Boolean( text(first(cycle, ['cycle_id', 'cycleId'], '')) || number(first(cycle, ['start_ms', 'startMs'], 0), 0) ); var currentStateMessage = ''; if (state.currentLoading) { currentStateMessage = t('fami_week_star.loading', 'Loading...'); } else if (state.currentError) { currentStateMessage = t( 'fami_week_star.activity_load_failed', 'Unable to load Weekly Star.' ); } else if (!hasCycle) { currentStateMessage = t( 'fami_week_star.no_active_cycle', 'No active cycle.' ); } var rewardStateMessage = currentStateMessage; if (!rewardStateMessage && !currentRewards.length) { rewardStateMessage = t( 'fami_week_star.empty_rewards', 'No reward data yet.' ); } cycleRangeNode.textContent = formatCycleRange( first(cycle, ['start_ms', 'startMs'], 0), first(cycle, ['end_ms', 'endMs'], 0) ); historyRangeNode.textContent = formatHistoryRange( first(historyCycle, ['start_ms', 'startMs'], 0), first(historyCycle, ['end_ms', 'endMs'], 0) ); renderGifts(currentGifts, currentStateMessage); renderRankList( weeklyListNode, state.weeklyEntries, t('fami_week_star.empty_weekly', 'No ranking data yet.'), state.currentError, state.weeklyLoading ); renderRankList( historyListNode, historyEntries, t('fami_week_star.empty_history', 'No previous ranking records.'), state.historyError, state.historyLoading ); renderRewards(currentRewards, rewardStateMessage); } function mockData() { var start = Date.UTC(2026, 4, 27, 21, 0, 0); var end = Date.UTC(2026, 5, 5, 20, 59, 0); var weekly = [ { rank_no: 1, score: 337700, user: { username: 'hanneem', avatar: FIXTURE_BASE + 'mock-avatar-top.webp', }, }, { rank_no: 2, score: 337700, user: { username: 'hanneem', avatar: FIXTURE_BASE + 'mock-avatar-user.webp', }, }, { rank_no: 3, score: 337700, user: { username: 'hanneem', avatar: FIXTURE_BASE + 'mock-avatar-user.webp', }, }, { rank_no: 4, score: 337700, user: { username: 'hanneem', avatar: FIXTURE_BASE + 'mock-avatar-user.webp', }, }, { rank_no: 5, score: 337700, user: { username: 'hanneem', avatar: FIXTURE_BASE + 'mock-avatar-user.webp', }, }, ]; var rewardItems = [1, 2, 3].map(function () { return { name: t('fami_week_star.mock_reward_name', 'Frame'), duration_ms: 7 * 86400000, image: FIXTURE_BASE + 'mock-gift.webp', }; }); return { current: { cycle: { cycle_id: 'mock-fami-week-star', start_ms: start, end_ms: end, gifts: [1, 2, 3].map(function () { return { name: t('fami_week_star.gift', 'Gift'), image: FIXTURE_BASE + 'mock-gift.webp', }; }), rewards: [1, 2, 3].map(function (rank) { return { rank_no: rank, resource_group: { items: rewardItems }, }; }), }, top_entries: weekly, server_time_ms: Date.now(), }, weekly: weekly, history: [ { cycle: { start_ms: Date.UTC(2025, 11, 6, 21, 0, 0), end_ms: Date.UTC(2025, 11, 14, 20, 59, 0), }, top_entries: weekly, }, ], }; } function errorText(error) { return text(error && error.message) || 'request_failed'; } function loadMock() { var mock = mockData(); state.currentLoading = false; state.weeklyLoading = false; state.historyLoading = false; state.current = mock.current; state.weeklyEntries = mock.weekly; state.historyCycles = mock.history; state.currentError = ''; state.historyError = ''; render(); } function loadReal() { var api = window.HyAppAPI && window.HyAppAPI.weeklyStar; state.currentLoading = true; state.weeklyLoading = true; state.historyLoading = true; state.currentError = ''; state.historyError = ''; render(); if (!api) { state.currentLoading = false; state.weeklyLoading = false; state.historyLoading = false; state.currentError = 'api_unavailable'; state.historyError = 'api_unavailable'; render(); return; } // history 与 current/leaderboard 使用独立 loading/error 状态;慢榜单不能阻塞已经成功的历史记录,任一接口失败也不清空另一块数据。 api.history(10).then( function (data) { state.historyCycles = firstArray(data || {}, ['cycles']); state.historyError = ''; state.historyLoading = false; render(); }, function (error) { state.historyCycles = []; state.historyError = errorText(error); state.historyLoading = false; render(); } ); api.current() .then(function (data) { state.current = data || {}; state.currentError = ''; state.currentLoading = false; // current 一到就先展示周期、礼物和奖励,不等待只影响本周榜的 leaderboard。 render(); var embedded = firstArray(state.current, [ 'top_entries', 'topEntries', ]); var cycle = cycleOf(state.current); var cycleID = text(first(cycle, ['cycle_id', 'cycleId'], '')); if (!cycleID) return embedded; // current.top_entries 可直接兜底;leaderboard 额外请求只取设计允许展示的前 5 名。 // 排行接口独立失败时保留 current 数据,而不是把整个活动判为不可用。 return api .leaderboard({ cycle_id: cycleID, page_size: MAX_VISIBLE_RANKS, }) .then(function (data) { var entries = firstArray(data || {}, ['entries']); return entries.length ? entries : embedded; }) .catch(function () { return embedded; }); }) .then(function (entries) { state.weeklyEntries = Array.isArray(entries) ? entries : []; state.weeklyLoading = false; render(); }) .catch(function (error) { // current 失败时周期、礼物、本周榜和奖励统一进入明确错误态;历史请求仍独立完成并保留可用结果。 state.current = null; state.currentLoading = false; state.weeklyLoading = false; state.currentError = errorText(error); state.weeklyEntries = []; render(); }); } function bindTabs() { tabs.forEach(function (tab, index) { tab.addEventListener('click', function () { setTab(tab.getAttribute('data-tab-target'), true); }); tab.addEventListener('keydown', function (event) { if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') { return; } event.preventDefault(); var direction = event.key === 'ArrowRight' ? 1 : -1; if (document.documentElement.dir === 'rtl') direction *= -1; var nextIndex = (index + direction + tabs.length) % tabs.length; tabs[nextIndex].focus(); setTab(tabs[nextIndex].getAttribute('data-tab-target'), true); }); }); } function bindResize() { var scheduled = false; function schedule() { if (scheduled) return; scheduled = true; window.requestAnimationFrame(function () { scheduled = false; updateStageSize(); }); } window.addEventListener('resize', schedule); window.addEventListener('orientationchange', schedule); if (window.ResizeObserver) { new ResizeObserver(schedule).observe(stageWrap); } } function init() { bindTabs(); bindResize(); setTab(state.tab, false); window.addEventListener('hyapp:i18n-ready', function () { // common/i18n 已更新静态 data-i18n 节点;这里只重算动态 API 文案与数字,避免重复 apply 触发事件循环。 render(); }); if (useMock) loadMock(); else loadReal(); } init(); })();