595 lines
21 KiB
JavaScript
595 lines
21 KiB
JavaScript
(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, '<')
|
||
.replace(/>/g, '>')
|
||
.replace(/"/g, '"')
|
||
.replace(/'/g, ''');
|
||
}
|
||
|
||
function escapeAttr(value) {
|
||
return escapeHTML(value).replace(/`/g, '`');
|
||
}
|
||
|
||
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 不猜测原生房间/钱包/礼物页面的具体路由,只把后台配置的跳转元数据原样交给 App;App 侧可按 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' });
|
||
}
|
||
})();
|