(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, }); } })();