2026-07-17 11:54:43 +08:00

873 lines
30 KiB
JavaScript
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.

(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 APITab 本身仍可正常切换,不让 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();
})();