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

674 lines
23 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 VALID_TABS = ['cp', 'brother', 'sister'];
var LAYER_BASE = './fami/assets/layers/';
var THEMES = {
cp: {
background: 'bg-cp.png',
tab: 'tab-cp.png',
card: 'card-cp.png',
center: 'center-cp.png',
level: 'level-cp.png',
},
brother: {
background: 'bg-brother.png',
tab: 'tab-brother.png',
card: 'card-brother.png',
center: 'center-brother.png',
level: 'level-brother.png',
},
sister: {
background: 'bg-sister.png',
tab: 'tab-sister.png',
card: 'card-sister.png',
center: 'center-sister.png',
level: 'level-sister.png',
},
};
var page = document.getElementById('famiPage');
var stage = document.getElementById('famiStage');
var design = document.getElementById('famiDesign');
var background = document.getElementById('famiBackground');
var tabJewel = document.getElementById('famiTabJewel');
var relationList = document.getElementById('famiRelationList');
var stateButton = document.getElementById('famiStateButton');
var content = document.getElementById('famiContent');
var template = document.getElementById('famiRelationTemplate');
var backButton = document.getElementById('famiBackButton');
var helpButton = document.getElementById('famiHelpButton');
var tabButtons = Array.prototype.slice.call(
document.querySelectorAll('.fami-tabs [data-tab]')
);
var params = new URLSearchParams(window.location.search || '');
var resizeObserver = null;
var state = {
activeTab: normalizeTab(params.get('tab')) || 'cp',
mock: params.get('mock') === '1',
tabs: {
cp: createTabState(),
brother: createTabState(),
sister: createTabState(),
},
};
function createTabState() {
return {
loading: false,
loaded: false,
error: '',
relationships: [],
requestId: 0,
};
}
function normalizeTab(value) {
var tab = String(value || '')
.trim()
.toLowerCase();
return VALID_TABS.indexOf(tab) >= 0 ? tab : '';
}
function readNumber(value, fallback) {
var number = Number(value);
return Number.isFinite(number) ? number : fallback || 0;
}
function readString(value) {
return String(value == null ? '' : value).trim();
}
function firstValue() {
for (var index = 0; index < arguments.length; index += 1) {
var value = arguments[index];
if (value !== undefined && value !== null && value !== '') {
return value;
}
}
return undefined;
}
function hasValue(value) {
return value !== undefined && value !== null && value !== '';
}
function t(key, fallback) {
return window.HyAppI18n && window.HyAppI18n.t
? window.HyAppI18n.t(key, fallback)
: fallback || key;
}
function formatNumber(value) {
var number = Math.max(0, Math.floor(readNumber(value, 0)));
return String(number).replace(/\B(?=(\d{3})+(?!\d))/g, ',');
}
function layer(fileName) {
return LAYER_BASE + fileName;
}
function setSource(node, fileName) {
if (!node) return;
var source = layer(fileName);
if (node.getAttribute('src') !== source) {
node.setAttribute('src', source);
}
}
function profileFromData(raw) {
var profile = raw || {};
return {
id: readString(profile.user_id || profile.userId || profile.id),
displayId: readString(
profile.display_user_id || profile.displayUserId
),
name: readString(
profile.username ||
profile.nickname ||
profile.display_name ||
profile.name
),
avatar: readString(
profile.avatar ||
profile.avatar_url ||
profile.avatarUrl ||
profile.avatar_path
),
};
}
function daysFromTimestamp(value) {
var formedAt = readNumber(value, 0);
if (formedAt <= 0) return 0;
if (formedAt < 100000000000) formedAt *= 1000;
if (Date.now() <= formedAt) return 1;
return Math.floor((Date.now() - formedAt) / 86400000) + 1;
}
function normalizeLevel(value) {
var level = Math.floor(readNumber(value, 1));
return Math.max(1, Math.min(99, level));
}
function normalizeRelationship(raw, tab) {
var item = raw || {};
var level = normalizeLevel(item.level || item.current_level);
var intimacy = Math.max(
0,
readNumber(item.intimacy_value || item.intimacyValue, 0)
);
var currentThreshold = Math.max(
0,
readNumber(
item.current_level_threshold || item.currentLevelThreshold,
0
)
);
var nextThreshold = Math.max(
currentThreshold,
readNumber(item.next_level_threshold || item.nextLevelThreshold, 0)
);
var currentExp = hasValue(firstValue(item.current_exp, item.currentExp))
? Math.max(
0,
readNumber(firstValue(item.current_exp, item.currentExp))
)
: Math.max(0, intimacy - currentThreshold);
var nextExp = hasValue(firstValue(item.next_exp, item.nextExp))
? Math.max(0, readNumber(firstValue(item.next_exp, item.nextExp)))
: Math.max(0, nextThreshold - currentThreshold);
if (!nextExp && intimacy > 0) nextExp = intimacy;
var neededExp = hasValue(
firstValue(item.needed_for_next_level, item.neededExp)
)
? Math.max(
0,
readNumber(
firstValue(item.needed_for_next_level, item.neededExp)
)
)
: Math.max(0, nextExp - currentExp);
var explicitProgress = firstValue(
item.level_progress_percent,
item.progress_percent,
item.progressPercent
);
var progressPercent = hasValue(explicitProgress)
? readNumber(explicitProgress, 0)
: nextExp > 0
? (currentExp / nextExp) * 100
: item.max_level
? 100
: 0;
var days = Math.max(
0,
readNumber(
firstValue(
item.days,
item.relationship_days,
daysFromTimestamp(item.formed_at_ms || item.formedAtMS)
),
0
)
);
return {
id: readString(
item.relationship_id || item.relationshipId || item.id
),
relationType:
normalizeTab(item.relation_type || item.relationType) || tab,
status: readString(item.status || 'active').toLowerCase(),
level: level,
nextLevel: normalizeLevel(
firstValue(item.next_level, item.nextLevel, level + 1)
),
days: days,
togetherDays: Math.max(
0,
readNumber(
firstValue(
item.together_days,
item.togetherDays,
item.time_spent_days,
days
),
days
)
),
currentExp: currentExp,
nextExp: nextExp,
neededExp: neededExp,
progressPercent: Math.max(0, Math.min(100, progressPercent)),
maxLevel: Boolean(item.max_level || item.maxLevel),
me: profileFromData(item.me || item.me_profile),
partner: profileFromData(
item.partner || item.partner_profile || item.cp_user
),
};
}
function activeRelationships(payload, tab) {
var data = payload || {};
var rows = Array.isArray(data)
? data
: data.relationships || data.items || [];
return (Array.isArray(rows) ? rows : [])
.map(function (item) {
return normalizeRelationship(item, tab);
})
.filter(function (item) {
return (
item.relationType === tab &&
(!item.status || item.status === 'active')
);
});
}
function mockRelationship(tab) {
return normalizeRelationship(
{
relationship_id: 'figma-' + tab,
relation_type: tab,
status: 'active',
level: 1,
next_level: 2,
days: 999,
together_days: 4,
current_exp: 1000,
next_exp: 200000,
needed_for_next_level: 888,
level_progress_percent: 22.3175965665,
me: {
user_id: 'figma-left',
username: 'Fami',
avatar: layer('mock-avatar.png'),
},
partner: {
user_id: 'figma-right',
username: 'Fami',
avatar: layer('mock-avatar.png'),
},
},
tab
);
}
function renderAvatar(node, profile) {
if (!node) return;
node.replaceChildren();
var data = profile || {};
if (data.avatar) {
var image = document.createElement('img');
image.src = data.avatar;
image.alt = data.name || '';
image.addEventListener('error', function () {
image.remove();
node.textContent = (data.name || data.displayId || '?')
.charAt(0)
.toUpperCase();
});
node.appendChild(image);
return;
}
node.textContent = (data.name || data.displayId || '?')
.charAt(0)
.toUpperCase();
}
function renderRelationship(item, tab) {
var theme = THEMES[tab];
var fragment = template.content.cloneNode(true);
var card = fragment.querySelector('.fami-relation-card');
var levelText = t('cpSpace.levelValue', 'Lv.{level}').replace(
'{level}',
formatNumber(item.level)
);
var nextLevel = item.maxLevel ? item.level : item.nextLevel;
var expAwayText = item.maxLevel
? t('cpSpace.maxLevelExp', '{exp} EXP').replace(
'{exp}',
formatNumber(item.currentExp)
)
: t('cpSpace.expAway', '{exp} Exp away from Lv. {level}')
.replace('{exp}', formatNumber(item.neededExp))
.replace('{level}', formatNumber(nextLevel));
setSource(card.querySelector('[data-layer="card"]'), theme.card);
setSource(card.querySelector('[data-layer="center"]'), theme.center);
card.querySelectorAll('[data-layer="level"]').forEach(function (node) {
setSource(node, theme.level);
});
renderAvatar(card.querySelector('[data-avatar="me"]'), item.me);
renderAvatar(
card.querySelector('[data-avatar="partner"]'),
item.partner
);
card.querySelector('[data-value="days"]').textContent = formatNumber(
item.days
);
card.querySelector('[data-value="exp-away"]').textContent = expAwayText;
// 两侧徽标在 Figma 中表示这段关系当前等级,不是“当前/下一等级”;下一等级只出现在经验提示里。
card.querySelector('[data-value="level-left"]').textContent = levelText;
card.querySelector('[data-value="level-right"]').textContent =
levelText;
card.querySelector('[data-value="progress-text"]').textContent =
String(Math.max(0, Math.floor(item.currentExp))) +
'/' +
String(Math.max(0, Math.floor(item.nextExp)));
card.querySelector('[data-value="progress"]').style.width =
Math.max(1, Math.min(233, (item.progressPercent / 100) * 233)) +
'px';
card.querySelector('[data-value="time"]').textContent = t(
'cpSpace.timeTogether',
'Time spent together: {days} days'
).replace('{days}', formatNumber(item.togetherDays));
card.setAttribute(
'aria-label',
t('cpSpace.levelCard', 'Best friend level {level}').replace(
'{level}',
formatNumber(item.level)
)
);
return fragment;
}
function renderTheme() {
var tab = state.activeTab;
var theme = THEMES[tab];
page.setAttribute('data-tab', tab);
setSource(background, theme.background);
setSource(tabJewel, theme.tab);
tabButtons.forEach(function (button) {
var selected = button.getAttribute('data-tab') === tab;
button.setAttribute('aria-selected', String(selected));
button.tabIndex = selected ? 0 : -1;
if (selected) content.setAttribute('aria-labelledby', button.id);
});
}
function setStateMessage(message, retry) {
stateButton.hidden = !message;
stateButton.textContent = message || '';
stateButton.setAttribute('data-retry', String(Boolean(retry)));
stateButton.disabled = !retry;
}
function updateDesignHeight(cardCount) {
var count = Math.max(1, Number(cardCount || 0));
var designHeight = Math.max(
812,
169 + count * 199 + (count - 1) * 14 + 40
);
design.style.setProperty('--design-height', designHeight + 'px');
stage.style.setProperty('--design-height', designHeight + 'px');
syncScale();
}
function render() {
renderTheme();
var tabState = state.tabs[state.activeTab];
content.setAttribute('aria-busy', String(tabState.loading));
relationList.replaceChildren();
if (tabState.loading && !tabState.loaded) {
setStateMessage(t('cpSpace.loading', 'Loading...'), false);
updateDesignHeight(0);
return;
}
if (tabState.error) {
setStateMessage(
tabState.error ||
t('cpSpace.loadFailed', 'Load failed. Tap to retry.'),
true
);
updateDesignHeight(0);
return;
}
if (!tabState.relationships.length) {
setStateMessage(
t('cpSpace.empty', 'No relationship data yet.'),
false
);
updateDesignHeight(0);
return;
}
setStateMessage('', false);
tabState.relationships.forEach(function (item) {
relationList.appendChild(renderRelationship(item, state.activeTab));
});
updateDesignHeight(tabState.relationships.length);
}
function syncScale() {
if (!stage) return;
var width = stage.getBoundingClientRect().width || window.innerWidth;
var scale = Math.max(0.1, width / 375);
var designHeight = parseFloat(
stage.style.getPropertyValue('--design-height') || '812'
);
stage.style.setProperty('--design-scale', String(scale));
stage.style.setProperty('--stage-height', designHeight * scale + 'px');
}
function bindScale() {
syncScale();
if (window.ResizeObserver) {
resizeObserver = new ResizeObserver(syncScale);
resizeObserver.observe(stage);
return;
}
window.addEventListener('resize', syncScale);
window.addEventListener('orientationchange', syncScale);
}
function syncURL(tab) {
if (!window.history || !window.history.replaceState) return;
var url = new URL(window.location.href);
url.searchParams.set('tab', tab);
// tab 只属于本页视觉和查询状态replaceState 避免每次切换都污染 App WebView 的返回栈。
window.history.replaceState(null, '', url.toString());
}
function loadTab(tab, force) {
var target = normalizeTab(tab) || 'cp';
var tabState = state.tabs[target];
if (!force && (tabState.loading || tabState.loaded)) {
render();
return Promise.resolve(tabState.relationships);
}
var requestId = ++tabState.requestId;
tabState.loading = true;
tabState.error = '';
render();
if (state.mock) {
tabState.relationships = [mockRelationship(target)];
tabState.loaded = true;
tabState.loading = false;
render();
return Promise.resolve(tabState.relationships);
}
if (
!window.HyAppAPI ||
!window.HyAppAPI.cpSpace ||
!window.HyAppAPI.cpSpace.relationships
) {
tabState.loading = false;
tabState.error = t(
'cpSpace.loadFailed',
'Load failed. Tap to retry.'
);
render();
return Promise.reject(new Error('cp_space_api_unavailable'));
}
// 关系类型和分页值原样交给 gateway页面不猜测用户关系也不把一个 tab 的结果复制到另一个 tab。
return window.HyAppAPI.cpSpace
.relationships({
relation_type: target,
page: 1,
page_size: target === 'cp' ? 1 : 20,
})
.then(function (payload) {
if (requestId !== tabState.requestId) return [];
tabState.relationships = activeRelationships(payload, target);
tabState.loaded = true;
return tabState.relationships;
})
.catch(function (error) {
if (requestId !== tabState.requestId) return [];
tabState.error =
(error && error.message) ||
t('cpSpace.loadFailed', 'Load failed. Tap to retry.');
return [];
})
.then(function (rows) {
if (requestId !== tabState.requestId) return rows;
tabState.loading = false;
render();
return rows;
});
}
function setActiveTab(tab, options) {
var target = normalizeTab(tab) || 'cp';
state.activeTab = target;
render();
if (!options || options.syncURL !== false) syncURL(target);
return loadTab(target, Boolean(options && options.force));
}
function hasNativeBridge() {
return Boolean(
(window.flutter_inappwebview &&
window.flutter_inappwebview.callHandler) ||
(window.HyAppBridgeChannel &&
window.HyAppBridgeChannel.postMessage) ||
(window.FlutterBridge && window.FlutterBridge.postMessage) ||
(window.webkit &&
window.webkit.messageHandlers &&
window.webkit.messageHandlers.HyAppBridge)
);
}
function contextualURL(path) {
var url = new URL(path, window.location.href);
['token', 'access_token', 'env', 'language', 'lang', 'locale'].forEach(
function (key) {
if (params.has(key)) url.searchParams.set(key, params.get(key));
}
);
url.searchParams.set('app_code', 'fami');
return url.toString();
}
function applyExternalData(payload) {
var data = payload || {};
VALID_TABS.forEach(function (tab) {
if (!data[tab]) return;
state.tabs[tab].relationships = activeRelationships(
Array.isArray(data[tab])
? { relationships: data[tab] }
: data[tab],
tab
);
state.tabs[tab].loaded = true;
state.tabs[tab].loading = false;
state.tabs[tab].error = '';
});
if (normalizeTab(data.activeTab)) state.activeTab = data.activeTab;
render();
}
function bindEvents() {
tabButtons.forEach(function (button) {
button.addEventListener('click', function () {
setActiveTab(button.getAttribute('data-tab'));
});
button.addEventListener('keydown', function (event) {
var current = VALID_TABS.indexOf(
button.getAttribute('data-tab')
);
var next = current;
if (event.key === 'ArrowLeft') next = current - 1;
else if (event.key === 'ArrowRight') next = current + 1;
else if (event.key === 'Home') next = 0;
else if (event.key === 'End') next = VALID_TABS.length - 1;
else return;
event.preventDefault();
// 标签视觉顺序固定为从左到右键盘同样按可见顺序循环RTL 只翻转返回箭头而不倒置关系类型。
next = (next + VALID_TABS.length) % VALID_TABS.length;
var targetButton = tabButtons[next];
targetButton.focus();
setActiveTab(targetButton.getAttribute('data-tab'));
});
});
stateButton.addEventListener('click', function () {
if (stateButton.getAttribute('data-retry') === 'true') {
loadTab(state.activeTab, true);
}
});
backButton.addEventListener('click', function () {
if (
window.FlutterPageControl &&
window.FlutterPageControl.postMessage
) {
window.FlutterPageControl.postMessage('close_page');
return;
}
if (hasNativeBridge() && window.HyAppBridge) {
window.HyAppBridge.back();
return;
}
window.history.back();
});
helpButton.addEventListener('click', function () {
if (hasNativeBridge() && window.HyAppBridge) {
window.HyAppBridge.post('cpSpaceHelpTap', {
page: 'fami-cp-space',
relation_type: state.activeTab,
});
return;
}
window.location.href = contextualURL('../../cp-rules/');
});
window.addEventListener('hyapp:i18n-ready', render);
window.addEventListener('hyapp:cp-space-fami-data', function (event) {
applyExternalData(event.detail || {});
});
window.addEventListener('hyapp:cp-space-data', function (event) {
applyExternalData(event.detail || {});
});
}
if (hasNativeBridge() && params.get('figma_status') !== '1') {
page.classList.add('has-native-status');
}
bindScale();
bindEvents();
if (window.HyAppCPSpaceFamiData) {
applyExternalData(window.HyAppCPSpaceFamiData);
setActiveTab(state.activeTab, { syncURL: false });
} else {
setActiveTab(state.activeTab, { syncURL: false });
}
if (window.HyAppBridge && window.HyAppBridge.ready) {
window.HyAppBridge.ready({
page: 'fami-cp-space',
app_code: 'fami',
tabs: VALID_TABS.slice(),
mock: state.mock,
});
}
})();