2026-06-18 20:12:34 +08:00

569 lines
20 KiB
JavaScript
Raw Permalink 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 VALID_TABS = ['cp', 'brother', 'sister'];
var PARTS_BASE = './assets/parts/';
var PART_NAMES = [
'bg',
'avatar',
'heart',
'button',
'xp-track',
'xp-fill',
'level-pill',
];
var LEVEL_PARTS = {};
for (var partLevel = 1; partLevel <= 5; partLevel += 1) {
LEVEL_PARTS[partLevel] = {};
PART_NAMES.forEach(function (partName) {
LEVEL_PARTS[partLevel][partName] =
PARTS_BASE + 'cp-lv' + partLevel + '-' + partName + '@2x.png';
});
}
var page = document.getElementById('page');
var contentPanel = document.getElementById('contentPanel');
var backButton = document.getElementById('backButton');
var tabButtons = Array.prototype.slice.call(
document.querySelectorAll('.tab-button[data-tab]')
);
var state = {
activeTab: normalizeTab(readParam('tab')) || 'cp',
requestSeq: 0,
cancelingId: '',
tabs: {
cp: createTabState(),
brother: createTabState(),
sister: createTabState(),
},
};
function createTabState() {
return {
loading: false,
loaded: false,
error: '',
relationships: [],
};
}
function readParam(name) {
var params = new URLSearchParams(window.location.search || '');
return params.get(name);
}
function normalizeTab(value) {
var tab = String(value || '')
.trim()
.toLowerCase();
return VALID_TABS.indexOf(tab) >= 0 ? tab : '';
}
function normalizeLevel(value) {
var level = Number(value);
return Number.isFinite(level) && level >= 1 && level <= 5
? Math.floor(level)
: 1;
}
function readNumber(value) {
var number = Number(value);
return Number.isFinite(number) ? number : 0;
}
function readString(value) {
return String(value == null ? '' : value).trim();
}
function t(key, fallback) {
if (window.HyAppI18n && window.HyAppI18n.t) {
return window.HyAppI18n.t(key, fallback);
}
return fallback || key;
}
function escapeHTML(value) {
return String(value == null ? '' : value)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function showToast(message) {
if (window.HyAppToast && window.HyAppToast.show) {
window.HyAppToast.show(message);
return;
}
window.dispatchEvent(
new CustomEvent('hyapp:toast', { detail: { message: message } })
);
}
function profileFromData(data) {
var json = data || {};
return {
userId: readString(json.user_id || json.id),
displayUserId: readString(json.display_user_id),
username: readString(json.username || json.nickname || json.name),
avatar: readString(
json.avatar ||
json.avatar_url ||
json.avatar_path ||
(json.profile && json.profile.avatar)
),
};
}
function displayName(profile) {
return (
readString(profile.username) ||
readString(profile.displayUserId) ||
readString(profile.userId) ||
t('cpSpace.unknownUser', 'User')
);
}
function daysFromFormedAt(formedAtMs) {
var formedAt = readNumber(formedAtMs);
if (formedAt <= 0) return 0;
var now = Date.now();
if (now <= formedAt) return 1;
return Math.floor((now - formedAt) / 86400000) + 1;
}
function currentLevelExp(item) {
var total = Math.max(0, readNumber(item.intimacy_value));
var currentThreshold = readNumber(item.current_level_threshold);
if (currentThreshold <= 0 || currentThreshold >= total) return total;
return total - currentThreshold;
}
function nextLevelExp(item) {
if (item.max_level) return 0;
var nextThreshold = readNumber(item.next_level_threshold);
var currentThreshold = readNumber(item.current_level_threshold);
if (nextThreshold > 0) {
if (currentThreshold > 0 && nextThreshold > currentThreshold) {
return nextThreshold - currentThreshold;
}
return nextThreshold;
}
var total = Math.max(0, readNumber(item.intimacy_value));
var remaining = readNumber(item.needed_for_next_level);
return remaining > 0 ? total + remaining : 0;
}
function progressPercent(item) {
if (item.max_level) return 100;
var explicit = readNumber(item.level_progress_percent);
if (explicit > 0) return Math.max(0, Math.min(100, explicit));
var next = nextLevelExp(item);
if (next <= 0) return 0;
return Math.max(0, Math.min(100, (currentLevelExp(item) / next) * 100));
}
function normalizeRelationship(item, tab) {
var relationType = normalizeTab(item && item.relation_type) || tab;
var level = normalizeLevel(item && item.level);
var me = profileFromData(item && item.me);
var partner = profileFromData(item && item.partner);
var currentExp = currentLevelExp(item || {});
var nextExp = nextLevelExp(item || {});
return {
relationshipId: readString(item && item.relationship_id),
relationType: relationType,
status: readString(item && item.status) || 'active',
level: level,
me: me,
partner: partner,
days: daysFromFormedAt(item && item.formed_at_ms),
intimacyValue: Math.max(0, readNumber(item && item.intimacy_value)),
currentExp: currentExp,
nextExp: nextExp,
progressPercent: progressPercent(item || {}),
maxLevel: Boolean(item && item.max_level),
breakupCostCoins: Math.max(
0,
readNumber(item && item.breakup_cost_coins)
),
};
}
function activeRelationships(data, tab) {
var list = Array.isArray(data && data.relationships)
? data.relationships
: [];
return list
.map(function (item) {
return normalizeRelationship(item || {}, tab);
})
.filter(function (item) {
return (
item.relationshipId &&
item.relationType === tab &&
item.status === 'active'
);
});
}
function setActiveTab(tab) {
var nextTab = normalizeTab(tab) || 'cp';
state.activeTab = nextTab;
page.setAttribute('data-tab', nextTab);
tabButtons.forEach(function (button) {
button.classList.toggle(
'is-active',
button.getAttribute('data-tab') === nextTab
);
});
render();
if (!state.tabs[nextTab].loaded && !state.tabs[nextTab].loading) {
loadTab(nextTab);
}
}
function visualLevel(item) {
return item.relationType === 'cp' ? item.level : 1;
}
function partsFor(item) {
return LEVEL_PARTS[visualLevel(item)] || LEVEL_PARTS[1];
}
function nextLevelLabel(item) {
if (item.maxLevel) return 'Max';
return t('cpSpace.levelValue', 'Lv.{level}').replace(
'{level}',
String(Math.min(5, item.level + 1))
);
}
function expLabel(item) {
if (item.maxLevel || item.nextExp <= 0) {
return t('cpSpace.maxLevelExp', '{exp} EXP').replace(
'{exp}',
formatNumber(item.intimacyValue)
);
}
return t('cpSpace.expProgress', '{current}/{next} EXP')
.replace('{current}', formatNumber(item.currentExp))
.replace('{next}', formatNumber(item.nextExp));
}
function formatNumber(value) {
var number = Math.max(0, Math.floor(readNumber(value)));
return String(number).replace(/\B(?=(\d{3})+(?!\d))/g, ',');
}
function avatarHTML(profile, side, frameSrc) {
var name = displayName(profile);
var avatar = readString(profile.avatar);
return [
'<figure class="relation-avatar relation-avatar--',
side,
'">',
avatar
? [
'<img class="relation-avatar-photo" src="',
escapeHTML(avatar),
'" alt="',
escapeHTML(name),
'" />',
].join('')
: [
'<div class="relation-avatar-photo relation-avatar-photo--placeholder" aria-label="',
escapeHTML(name),
'">',
escapeHTML(name.charAt(0).toUpperCase() || '?'),
'</div>',
].join(''),
'<img class="relation-avatar-frame" src="',
escapeHTML(frameSrc),
'" alt="" draggable="false" />',
'<figcaption class="relation-name relation-name--',
side,
'">',
escapeHTML(name),
'</figcaption>',
'</figure>',
].join('');
}
function cardHTML(item, index) {
var parts = partsFor(item);
var label = t('cpSpace.levelCard', 'Best friend level {level}').replace(
'{level}',
String(item.level)
);
var daysText = t('cpSpace.daysValue', '{days} Days').replace(
'{days}',
String(item.days)
);
var levelTitle = t('cpSpace.levelValue', 'Lv.{level}').replace(
'{level}',
String(item.level)
);
var cardClass =
'relation-card relation-card--' + escapeHTML(item.relationType);
if (item.relationType !== 'cp') cardClass += ' relation-card--shared';
return [
'<article class="',
cardClass,
'" data-level="',
visualLevel(item),
'">',
'<img class="relation-card-bg" src="',
parts.bg,
'" alt="',
escapeHTML(label),
'" draggable="false" />',
avatarHTML(item.me, 'left', parts.avatar),
avatarHTML(item.partner, 'right', parts.avatar),
'<img class="relation-heart-art" src="',
parts.heart,
'" alt="" draggable="false" />',
'<div class="relation-days"><strong>',
escapeHTML(String(item.days)),
'</strong><span>',
escapeHTML(t('cpSpace.daysUnit', 'Days')),
'</span></div>',
'<div class="relation-level relation-level--current"><img src="',
parts['level-pill'],
'" alt="" draggable="false" /><span>',
escapeHTML(levelTitle),
'</span></div>',
'<div class="relation-level relation-level--next"><img src="',
parts['level-pill'],
'" alt="" draggable="false" /><span>',
escapeHTML(nextLevelLabel(item)),
'</span></div>',
'<div class="relation-exp">',
escapeHTML(expLabel(item)),
'</div>',
'<div class="relation-progress" aria-label="',
escapeHTML(daysText),
'"><img class="relation-progress-track" src="',
parts['xp-track'],
'" alt="" draggable="false" /><span style="width:',
item.progressPercent,
'%"><img class="relation-progress-fill" src="',
parts['xp-fill'],
'" alt="" draggable="false" /></span></div>',
item.relationType === 'cp'
? [
'<button class="cancel-button" type="button" data-cancel-index="',
index,
'"',
state.cancelingId === item.relationshipId
? ' disabled'
: '',
'>',
'<img class="cancel-button-art" src="',
parts.button,
'" alt="" draggable="false" />',
'<span>',
escapeHTML(
state.cancelingId === item.relationshipId
? t('cpSpace.canceling', 'Canceling...')
: t('cpSpace.cancel', 'Cancel')
),
'</span>',
'</button>',
].join('')
: '',
'</article>',
].join('');
}
function render() {
var tabState = state.tabs[state.activeTab];
if (tabState.loading && !tabState.loaded) {
contentPanel.innerHTML =
'<div class="empty-state">' +
escapeHTML(t('cpSpace.loading', 'Loading...')) +
'</div>';
return;
}
if (tabState.error) {
contentPanel.innerHTML =
'<button class="empty-state empty-state--button" type="button" data-retry-tab="' +
escapeHTML(state.activeTab) +
'">' +
escapeHTML(tabState.error) +
'</button>';
return;
}
if (!tabState.relationships.length) {
contentPanel.innerHTML =
'<div class="empty-state">' +
escapeHTML(t('cpSpace.empty', 'No relationship data yet.')) +
'</div>';
return;
}
contentPanel.innerHTML =
'<div class="relation-list">' +
tabState.relationships.map(cardHTML).join('') +
'</div>';
}
function loadTab(tab) {
var tabState = state.tabs[tab];
var seq = ++state.requestSeq;
tabState.loading = true;
tabState.error = '';
render();
// 关系事实全部从 gateway profile 接口读取H5 只负责按 relation_type 分页拉取,不本地推断用户是否有 CP/兄弟/姐妹。
return window.HyAppAPI.cpSpace
.relationships({
relation_type: tab,
page: 1,
page_size: tab === 'cp' ? 1 : 20,
})
.then(function (data) {
if (seq < state.requestSeq && tab === state.activeTab) return;
tabState.relationships = activeRelationships(data, tab);
tabState.loaded = true;
})
.catch(function (error) {
tabState.error =
(error && error.message) ||
t('cpSpace.loadFailed', 'Load failed. Tap to retry.');
})
.then(function () {
tabState.loading = false;
render();
});
}
function buildCommandId(relationshipId) {
var random =
window.crypto && window.crypto.randomUUID
? window.crypto.randomUUID()
: String(Date.now()) + '_' + Math.random().toString(16).slice(2);
return ['h5_cp_break', relationshipId, random]
.join('_')
.replace(/[^A-Za-z0-9_-]/g, '_');
}
function confirmBreakup(item) {
var message =
item.breakupCostCoins > 0
? t(
'cpSpace.cancelConfirmWithCost',
'Canceling this CP will cost {coins} coins. Continue?'
).replace('{coins}', formatNumber(item.breakupCostCoins))
: t(
'cpSpace.cancelConfirmFree',
'Cancel this CP relationship?'
);
return Promise.resolve(window.confirm(message));
}
function handleCancel(index) {
var item =
state.tabs[state.activeTab].relationships[Number(index)] || null;
if (!item || !item.relationshipId || state.cancelingId) return;
confirmBreakup(item).then(function (confirmed) {
if (!confirmed) return;
state.cancelingId = item.relationshipId;
render();
// command_id 必须由本次点击生成并随请求提交,服务端用它串联占位、钱包扣费和确认解除,网络重试不会重复扣费。
window.HyAppAPI.cpSpace
.breakRelationship(item.relationshipId, {
command_id: buildCommandId(item.relationshipId),
})
.then(function (data) {
showToast(t('cpSpace.cancelSuccess', 'Canceled.'));
if (window.HyAppBridge && window.HyAppBridge.post) {
window.HyAppBridge.post('cpSpaceRelationCanceled', {
relationship_id: item.relationshipId,
relation_type: item.relationType,
breakup_cost_coins: readNumber(
data && data.breakup_cost_coins
),
});
}
state.tabs.cp.loaded = false;
return loadTab('cp');
})
.catch(function (error) {
showToast(
(error && error.message) ||
t('cpSpace.cancelFailed', 'Cancel failed.')
);
})
.then(function () {
state.cancelingId = '';
render();
});
});
}
function applyExternalData(payload) {
var data = payload || {};
VALID_TABS.forEach(function (tab) {
var raw = data[tab];
if (!Array.isArray(raw)) return;
state.tabs[tab].relationships = raw
.map(function (item) {
return normalizeRelationship(item || {}, tab);
})
.filter(function (item) {
return item.relationshipId;
});
state.tabs[tab].loaded = true;
});
if (normalizeTab(data.activeTab)) state.activeTab = data.activeTab;
setActiveTab(state.activeTab);
}
function bindEvents() {
if (backButton) {
backButton.addEventListener('click', function () {
if (window.HyAppBridge && window.HyAppBridge.back) {
window.HyAppBridge.back();
return;
}
window.history.back();
});
}
tabButtons.forEach(function (button) {
button.addEventListener('click', function () {
setActiveTab(button.getAttribute('data-tab'));
});
});
contentPanel.addEventListener('click', function (event) {
var cancelTarget = event.target.closest('[data-cancel-index]');
if (cancelTarget) {
handleCancel(cancelTarget.getAttribute('data-cancel-index'));
return;
}
var retryTarget = event.target.closest('[data-retry-tab]');
if (retryTarget) loadTab(retryTarget.getAttribute('data-retry-tab'));
});
window.addEventListener('hyapp:cp-space-data', function (event) {
applyExternalData(event.detail || {});
});
window.addEventListener('hyapp:i18n-ready', render);
}
bindEvents();
if (window.HyAppCPSpaceData) applyExternalData(window.HyAppCPSpaceData);
else setActiveTab(state.activeTab);
if (window.HyAppBridge && window.HyAppBridge.ready) {
window.HyAppBridge.ready({ page: 'cp-space' });
}
})();