569 lines
20 KiB
JavaScript
569 lines
20 KiB
JavaScript
(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, '&')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>')
|
||
.replace(/"/g, '"')
|
||
.replace(/'/g, ''');
|
||
}
|
||
|
||
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' });
|
||
}
|
||
})();
|