hyapp-h5/activity/tasks/script.js
2026-06-11 13:33:10 +08:00

595 lines
21 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 () {
var viewport = document.getElementById('appViewport');
var page = document.getElementById('page');
var dailyRoot = document.getElementById('dailyTasks');
var exclusiveRoot = document.getElementById('exclusiveTasks');
var stateRoot = document.getElementById('taskState');
var backButton = document.getElementById('backButton');
var COMMAND_PREFIX = 'task_claim_command:';
var COIN_IMAGE = './assets/coin.png';
var GAME_IMAGE = './assets/figma-game-icon.png';
var state = {
loading: true,
error: '',
sections: [],
claiming: {},
};
var mockData = {
server_time_ms: Date.now(),
next_refresh_at_ms: Date.now() + 6 * 60 * 60 * 1000,
sections: [
{
section: 'daily',
items: [
mockTask(
'daily-mic',
'daily',
'Use the microphone for 1 min',
'mic_online_minute',
0,
1000000,
1100,
'in_progress',
false,
'mic'
),
mockTask(
'daily-game',
'daily',
'Use the microphone for 1 min',
'game_spend_coin',
1000000,
1000000,
1100,
'completed',
true,
'gamepad'
),
mockTask(
'daily-gift',
'daily',
'Use the microphone for 1 min',
'gift_spend_coin',
1000000,
1000000,
1100,
'claimed',
false,
'gift'
),
],
},
{
section: 'exclusive',
items: [
mockTask(
'exclusive-game-preview',
'exclusive',
'Use the microphone for 1 min',
'game_spend_coin',
0,
1000000,
1100,
'in_progress',
false,
'game_preview'
),
mockTask(
'exclusive-game',
'exclusive',
'Use the microphone for 1 min',
'game_spend_coin',
1000000,
1000000,
1100,
'completed',
true,
'gamepad'
),
mockTask(
'exclusive-gift',
'exclusive',
'Use the microphone for 1 min',
'gift_spend_coin',
1000000,
1000000,
1100,
'claimed',
false,
'gift'
),
],
},
],
};
function t(key, fallback) {
if (window.HyAppI18n && window.HyAppI18n.t) {
return window.HyAppI18n.t(key, fallback);
}
return fallback || key;
}
function mockTask(
id,
type,
title,
metric,
progress,
target,
reward,
status,
claimable,
iconKey
) {
return {
task_id: id,
task_type: type,
task_day:
type === 'daily'
? new Date().toISOString().slice(0, 10)
: 'lifetime',
title: title,
metric_type: metric,
progress_value: progress,
target_value: target,
reward_coin_amount: reward,
status: status,
claimable: claimable,
icon_key: iconKey,
icon_url: iconKey === 'game_preview' ? GAME_IMAGE : '',
action_type:
iconKey === 'gift'
? 'gift_panel'
: iconKey === 'gamepad'
? 'room_random_game'
: 'wallet',
action_param: iconKey === 'gamepad' ? 'dice' : '',
action_payload: iconKey === 'gamepad' ? { game_id: 'dice' } : {},
};
}
function isMockMode() {
var params = new URLSearchParams(window.location.search || '');
return params.get('mock') === '1' || params.get('mock') === 'true';
}
function escapeHTML(value) {
return String(value == null ? '' : value)
.replace(/&/g, '&')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function escapeAttr(value) {
return escapeHTML(value).replace(/`/g, '&#96;');
}
function clampProgress(item) {
var target = Math.max(
0,
Number(item.target_value || item.targetValue || 0)
);
var progress = Math.max(
0,
Number(item.progress_value || item.progressValue || 0)
);
return {
target: target,
progress: target > 0 ? Math.min(progress, target) : progress,
};
}
function formatProgress(item) {
var values = clampProgress(item);
return String(values.progress) + '/' + String(values.target);
}
function formatReward(value) {
var amount = Number(value || 0);
if (!Number.isFinite(amount)) return '0';
if (amount >= 1000000) return trimNumber(amount / 1000000) + 'M';
if (amount >= 1000) return trimNumber(amount / 1000) + 'k';
return String(amount);
}
function trimNumber(value) {
var rounded = Math.round(value * 10) / 10;
return String(rounded).replace(/\.0$/, '');
}
function claimKey(item) {
return [
item.task_id || item.taskId,
item.task_type || item.taskType,
item.task_day || item.taskDay,
].join('|');
}
function commandStorageKey(item) {
return COMMAND_PREFIX + claimKey(item);
}
function commandID(item) {
var storageKey = commandStorageKey(item);
var cached = window.sessionStorage.getItem(storageKey);
if (cached) return cached;
// 领取命令以任务主键和页面时间组成,保存在 sessionStorage如果请求超时但服务端已入账用户重试会继续用同一 command_id 命中后端幂等。
var next =
'h5-task-claim-' +
Date.now() +
'-' +
Math.random().toString(16).slice(2) +
'-' +
String(item.task_id || item.taskId || '');
window.sessionStorage.setItem(storageKey, next);
return next;
}
function clearCommandID(item) {
window.sessionStorage.removeItem(commandStorageKey(item));
}
function normalizeSections(payload) {
var sections =
payload && Array.isArray(payload.sections) ? payload.sections : [];
var byName = sections.reduce(function (map, section) {
map[String(section.section || '')] = Array.isArray(section.items)
? section.items
: [];
return map;
}, {});
return [
{ section: 'daily', items: byName.daily || [] },
{ section: 'exclusive', items: byName.exclusive || [] },
];
}
function iconKind(item) {
var key = String(item.icon_key || item.iconKey || '').toLowerCase();
var metric = String(
item.metric_type || item.metricType || ''
).toLowerCase();
if (key.indexOf('mic') >= 0 || metric.indexOf('mic') >= 0) return 'mic';
if (key.indexOf('gift') >= 0 || metric.indexOf('gift') >= 0)
return 'gift';
if (key.indexOf('game') >= 0 || metric.indexOf('game') >= 0)
return 'gamepad';
if (key.indexOf('cp') >= 0 || metric.indexOf('cp') >= 0) return 'cp';
if (key.indexOf('mood') >= 0 || metric.indexOf('mood') >= 0)
return 'mood';
return 'coin';
}
function iconHTML(item) {
var iconURL = item.icon_url || item.iconUrl || '';
if (iconURL) {
// 后台 icon_url 是可配置资源,可能因为地址过期、跨域或资源未上传而加载失败;失败时用内置图标兜底,不能让任务列表留下空方块。
return [
'<span class="task-icon is-image" data-icon-fallback="',
escapeAttr(iconKind(item)),
'"><img src="',
escapeAttr(iconURL),
'" alt="" /></span>',
].join('');
}
return (
'<span class="task-icon">' + svgForIcon(iconKind(item)) + '</span>'
);
}
function svgForIcon(kind) {
if (kind === 'mic') {
return '<svg viewBox="0 0 24 24" aria-hidden="true"><path fill="#fff" d="M12 3a4 4 0 0 0-4 4v5a4 4 0 0 0 8 0V7a4 4 0 0 0-4-4Zm-6 8.5a1 1 0 0 1 2 0V12a4 4 0 0 0 8 0v-.5a1 1 0 1 1 2 0V12a6 6 0 0 1-5 5.92V20h3a1 1 0 1 1 0 2H8a1 1 0 1 1 0-2h3v-2.08A6 6 0 0 1 6 12v-.5Z"/></svg>';
}
if (kind === 'gift') {
return '<svg viewBox="0 0 24 24" aria-hidden="true"><path fill="#fff" d="M8.2 4.2A3 3 0 0 1 12 5a3 3 0 0 1 3.8-.8A3.1 3.1 0 0 1 17 8h2a1 1 0 0 1 1 1v3h-7V8h-2v4H4V9a1 1 0 0 1 1-1h2a3.1 3.1 0 0 1 1.2-3.8ZM9 6a1 1 0 0 0 .2 2H11a2 2 0 0 0-2-2Zm4 2h1.8A1 1 0 1 0 13 6a2 2 0 0 0 0 2ZM5 14h6v6H6a1 1 0 0 1-1-1v-5Zm8 6v-6h6v5a1 1 0 0 1-1 1h-5Z"/></svg>';
}
if (kind === 'cp') {
return '<svg viewBox="0 0 24 24" aria-hidden="true"><path fill="#fff" d="M7.5 6A3.5 3.5 0 0 1 12 9.35 3.5 3.5 0 1 1 16.5 6c2.45 0 4.5 1.9 4.5 4.35 0 4.25-6.05 8.05-8.35 9.35a1.3 1.3 0 0 1-1.3 0C9.05 18.4 3 14.6 3 10.35 3 7.9 5.05 6 7.5 6Z"/></svg>';
}
if (kind === 'mood') {
return '<svg viewBox="0 0 24 24" aria-hidden="true"><path fill="#fff" d="M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18Zm-3 7.2a1.2 1.2 0 1 1 0-2.4 1.2 1.2 0 0 1 0 2.4Zm6 0a1.2 1.2 0 1 1 0-2.4 1.2 1.2 0 0 1 0 2.4Zm-6.3 3.35a1 1 0 0 1 1.4.1 2.5 2.5 0 0 0 3.8 0 1 1 0 0 1 1.5 1.3 4.5 4.5 0 0 1-6.8 0 1 1 0 0 1 .1-1.4Z"/></svg>';
}
if (kind === 'coin') {
return '<svg viewBox="0 0 24 24" aria-hidden="true"><path fill="#fff" d="M12 3c4.42 0 8 2.02 8 4.5V16c0 2.76-3.58 5-8 5s-8-2.24-8-5V7.5C4 5.02 7.58 3 12 3Zm0 2C8.14 5 6 6.47 6 7.5S8.14 10 12 10s6-1.47 6-2.5S15.86 5 12 5Zm6 5.3c-1.45 1.05-3.6 1.7-6 1.7s-4.55-.65-6-1.7V12c0 1.2 2.33 3 6 3s6-1.8 6-3v-1.7Zm0 5c-1.45 1.05-3.6 1.7-6 1.7s-4.55-.65-6-1.7V16c0 1.2 2.33 3 6 3s6-1.8 6-3v-.7Z"/></svg>';
}
return '<svg viewBox="0 0 24 24" aria-hidden="true"><path fill="#fff" d="M8 9h8a4 4 0 0 1 0 8h-.5a2 2 0 0 1-1.6-.8l-.9-1.2h-2l-.9 1.2a2 2 0 0 1-1.6.8H8a4 4 0 0 1 0-8Zm.5 3H7v1.5h1.5V15H10v-1.5h1.5V12H10v-1.5H8.5V12Zm6.5.5a1 1 0 1 0 0-2 1 1 0 0 0 0 2Zm2.5 2.5a1 1 0 1 0 0-2 1 1 0 0 0 0 2Z"/></svg>';
}
function actionState(item) {
if (item.status === 'claimed') {
return {
label: t('tasks.claimed', 'Claimed'),
className: 'is-claimed',
disabled: true,
};
}
if (item.claimable) {
return {
label: t('tasks.claim', 'Claim'),
className: 'is-claimable',
disabled: false,
};
}
return {
label: t('tasks.go', 'Go'),
className: 'is-go',
disabled: false,
};
}
function renderSection(root, items) {
if (!items.length) {
root.innerHTML =
'<div class="task-state">' +
escapeHTML(t('tasks.empty', 'No tasks yet.')) +
'</div>';
return;
}
root.innerHTML = items.map(renderTaskRow).join('');
}
function renderTaskRow(item) {
var action = actionState(item);
var key = claimKey(item);
var loading = Boolean(state.claiming[key]);
var label = loading ? t('tasks.claiming', 'Claiming...') : action.label;
return [
'<article class="task-row" data-task-key="',
escapeAttr(key),
'">',
iconHTML(item),
'<div class="task-main">',
'<strong class="task-title">',
escapeHTML(
item.title || item.description || t('tasks.untitled', 'Task')
),
'</strong>',
'<span class="task-progress">',
escapeHTML(formatProgress(item)),
'</span>',
'<span class="task-reward"><img src="',
COIN_IMAGE,
'" alt="" aria-hidden="true" /><span>',
escapeHTML(
formatReward(item.reward_coin_amount || item.rewardCoinAmount)
),
'</span></span>',
'</div>',
'<button class="task-action ',
action.className,
loading ? ' is-loading' : '',
'" type="button" data-task-action="',
escapeAttr(key),
'"',
action.disabled || loading ? ' disabled' : '',
'>',
escapeHTML(label),
'</button>',
'</article>',
].join('');
}
function render() {
var sections = normalizeSections({ sections: state.sections });
var daily = sections[0].items;
var exclusive = sections[1].items;
renderSection(dailyRoot, daily);
renderSection(exclusiveRoot, exclusive);
bindIconFallbacks();
renderState();
updateScale();
}
function bindIconFallbacks() {
Array.prototype.slice
.call(document.querySelectorAll('[data-icon-fallback] img'))
.forEach(function (image) {
image.addEventListener(
'error',
function () {
var wrapper = image.parentElement;
if (!wrapper) return;
wrapper.classList.remove('is-image');
wrapper.innerHTML = svgForIcon(
wrapper.getAttribute('data-icon-fallback') || 'coin'
);
},
{ once: true }
);
});
}
function renderState() {
if (state.loading) {
stateRoot.hidden = false;
stateRoot.textContent = t('tasks.loading', 'Loading...');
return;
}
if (state.error) {
stateRoot.hidden = false;
stateRoot.textContent = state.error;
return;
}
stateRoot.hidden = true;
stateRoot.textContent = '';
}
function loadTasks() {
state.loading = true;
state.error = '';
renderState();
if (isMockMode()) {
applyPayload(mockData);
return;
}
if (!window.HyAppAPI || !window.HyAppAPI.task) {
state.loading = false;
state.error = t('tasks.apiUnavailable', 'Task API unavailable.');
render();
return;
}
window.HyAppAPI.task
.tabs()
.then(applyPayload)
.catch(function (error) {
state.loading = false;
state.error =
error.message || t('tasks.loadFailed', 'Load failed.');
state.sections = [];
render();
});
}
function applyPayload(payload) {
var sections = normalizeSections(payload);
state.sections = sections.map(function (section) {
return {
section: section.section,
items: section.items,
};
});
state.loading = false;
state.error = '';
render();
}
function findTask(key) {
var sections = normalizeSections({ sections: state.sections });
for (
var sectionIndex = 0;
sectionIndex < sections.length;
sectionIndex += 1
) {
var items = sections[sectionIndex].items;
for (var itemIndex = 0; itemIndex < items.length; itemIndex += 1) {
if (claimKey(items[itemIndex]) === key) return items[itemIndex];
}
}
return null;
}
function claimTask(item) {
var key = claimKey(item);
state.claiming[key] = true;
render();
if (isMockMode()) {
// mock 只服务本地视觉和交互验收,不触发真实发奖;真实页面仍必须通过后端 claim 接口刷新钱包和任务状态。
item.status = 'claimed';
item.claimable = false;
state.claiming[key] = false;
showToast(t('tasks.claimSuccess', 'Reward claimed.'));
render();
return;
}
// H5 只提交任务列表返回的 task_day/task_type/task_id避免页面自行推算每日刷新周期服务端负责判断当天是否仍可领取。
window.HyAppAPI.task
.claim({
task_id: item.task_id || item.taskId,
task_type: item.task_type || item.taskType,
task_day: item.task_day || item.taskDay,
command_id: commandID(item),
})
.then(function () {
clearCommandID(item);
showToast(t('tasks.claimSuccess', 'Reward claimed.'));
return window.HyAppAPI.task.tabs();
})
.then(applyPayload)
.catch(function (error) {
state.claiming[key] = false;
showToast(
error.message || t('tasks.claimFailed', 'Claim failed.')
);
render();
});
}
function goTask(item) {
var actionType = item.action_type || item.actionType || 'none';
if (!actionType || actionType === 'none') {
showToast(t('tasks.noAction', 'No action configured.'));
return;
}
var payload = {
task_id: item.task_id || item.taskId || '',
task_type: item.task_type || item.taskType || '',
task_day: item.task_day || item.taskDay || '',
action_type: actionType,
action_param: item.action_param || item.actionParam || '',
action_payload: item.action_payload || item.actionPayload || {},
};
// H5 不猜测原生房间/钱包/礼物页面的具体路由,只把后台配置的跳转元数据原样交给 AppApp 侧可按 action_type 做统一分发。
if (window.HyAppBridge && window.HyAppBridge.openTaskAction) {
window.HyAppBridge.openTaskAction(payload);
return;
}
if (window.HyAppBridge && window.HyAppBridge.post) {
window.HyAppBridge.post('taskAction', payload);
return;
}
window.dispatchEvent(
new CustomEvent('hyapp:task-action', {
detail: payload,
})
);
}
function showToast(message) {
if (window.HyAppToast && window.HyAppToast.show) {
window.HyAppToast.show(message);
return;
}
window.dispatchEvent(
new CustomEvent('hyapp:toast', {
detail: { message: message },
})
);
}
function onTaskAction(event) {
var button = event.target.closest('[data-task-action]');
if (!button) return;
var item = findTask(button.getAttribute('data-task-action'));
if (!item) return;
if (item.claimable) {
claimTask(item);
return;
}
goTask(item);
}
function updateScale() {
document.documentElement.style.setProperty('--app-scale', '1');
if (!viewport || !page) return;
var height = Math.max(page.scrollHeight, window.innerHeight || 0);
viewport.style.minHeight = height + 'px';
document.body.style.minHeight = height + 'px';
}
function bindEvents() {
page.addEventListener('click', onTaskAction);
window.addEventListener('resize', updateScale);
if (backButton) {
backButton.addEventListener('click', function () {
if (window.HyAppBridge && window.HyAppBridge.back) {
window.HyAppBridge.back();
return;
}
window.history.back();
});
}
}
bindEvents();
loadTasks();
if (window.HyAppBridge && window.HyAppBridge.ready) {
window.HyAppBridge.ready({ page: 'tasks' });
}
})();