This commit is contained in:
zhx 2026-06-18 20:12:34 +08:00
parent e4124b14f9
commit e06d013ddd
47 changed files with 848 additions and 119 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 271 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 358 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 439 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 405 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 367 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

View File

@ -1,46 +0,0 @@
<svg width="375" height="812" viewBox="0 0 375 812" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="375" height="812" fill="#26084E"/>
<rect width="375" height="812" fill="url(#bgGlow)"/>
<path d="M236 -12C229 38 244 78 287 111C328 143 365 161 393 212" stroke="#8C43D2" stroke-opacity=".18" stroke-width="2"/>
<path d="M251 -8C257 33 278 63 315 80C351 96 377 122 392 159" stroke="#A35AE7" stroke-opacity=".16" stroke-width="1.4"/>
<path d="M67 830C73 760 52 707 4 671C-35 642 -58 599 -61 542" stroke="#9C4DDA" stroke-opacity=".2" stroke-width="2"/>
<path d="M34 812C26 757 4 720 -31 699C-61 680 -75 647 -72 599" stroke="#B65BEE" stroke-opacity=".16" stroke-width="1.4"/>
<g opacity=".42" stroke="#A55BE9" stroke-width="1.1">
<path d="M296 11c29-9 57 9 60 39 2 23-15 43-38 45 12-12 13-29 3-40-11-13-31-11-41 3"/>
<path d="M323 13c17 11 20 35 6 49"/>
<path d="M351 46c-8-14-24-21-40-16"/>
<path d="M18 716c31-12 63 8 66 41 3 26-17 49-43 51 14-14 15-33 4-46-13-15-36-13-49 4"/>
<path d="M47 716c19 13 23 40 7 56"/>
<path d="M80 755c-10-16-29-24-47-18"/>
<path d="M325 728c24-4 45 12 47 36 2 19-12 36-31 38 10-10 10-23 2-32-9-10-25-9-33 3"/>
</g>
<g opacity=".95">
<circle cx="18" cy="401" r="1.3" fill="#FFFFFF"/>
<circle cx="61" cy="659" r="1.9" fill="#FFFFFF"/>
<circle cx="133" cy="512" r="1.2" fill="#DFB8FF"/>
<circle cx="187" cy="710" r="1.6" fill="#FFFFFF"/>
<circle cx="216" cy="438" r="1.3" fill="#FFFFFF"/>
<circle cx="268" cy="596" r="1.5" fill="#CFA4FF"/>
<circle cx="327" cy="505" r="1.9" fill="#FFFFFF"/>
<circle cx="356" cy="420" r="1.3" fill="#E8D1FF"/>
<circle cx="334" cy="735" r="1.7" fill="#FFFFFF"/>
<circle cx="7" cy="218" r="1.2" fill="#FFFFFF"/>
<circle cx="242" cy="133" r="1.4" fill="#FFFFFF"/>
<circle cx="318" cy="76" r="1.2" fill="#DAB5FF"/>
<circle cx="36" cy="185" r="1.6" fill="#DDBDFF"/>
<circle cx="184" cy="83" r="1.3" fill="#FFFFFF"/>
</g>
<g opacity=".72">
<path d="M8 430l2.2 5.2 5.3 2.2-5.3 2.1L8 445l-2.2-5.5-5.3-2.1 5.3-2.2L8 430Z" fill="#FFFFFF"/>
<path d="M341 497l2.8 6.6 6.6 2.8-6.6 2.7-2.8 6.7-2.7-6.7-6.7-2.7 6.7-2.8 2.7-6.6Z" fill="#FFFFFF"/>
<path d="M204 641l2 4.8 4.8 2-4.8 2-2 4.8-2-4.8-4.8-2 4.8-2 2-4.8Z" fill="#F2E3FF"/>
<path d="M356 704l1.9 4.4 4.4 1.9-4.4 1.8-1.9 4.5-1.8-4.5-4.5-1.8 4.5-1.9 1.8-4.4Z" fill="#DAB6FF"/>
</g>
<defs>
<radialGradient id="bgGlow" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(309 52) rotate(116) scale(227 143)">
<stop stop-color="#6E239D" stop-opacity=".62"/>
<stop offset=".44" stop-color="#3A0E68" stop-opacity=".82"/>
<stop offset="1" stop-color="#230747"/>
</radialGradient>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 MiB

View File

@ -10,9 +10,9 @@
<link rel="icon" href="data:," />
<link
rel="stylesheet"
href="../../common/theme.css?v=20260618-cp-space"
href="../../common/theme.css?v=20260618-cp-space-parts"
/>
<link rel="stylesheet" href="./style.css?v=20260618-cp-space" />
<link rel="stylesheet" href="./style.css?v=20260618-cp-space-parts" />
</head>
<body>
<div class="app-shell">
@ -68,10 +68,10 @@
</main>
</div>
<script src="../../common/i18n.js?v=20260618-cp-space"></script>
<script src="../../common/api.js?v=20260618-cp-space"></script>
<script src="../../common/jsbridge.js?v=20260618-cp-space"></script>
<script src="../../common/toast.js?v=20260618-cp-space"></script>
<script src="./script.js?v=20260618-cp-space"></script>
<script src="../../common/i18n.js?v=20260618-cp-space-parts"></script>
<script src="../../common/api.js?v=20260618-cp-space-parts"></script>
<script src="../../common/jsbridge.js?v=20260618-cp-space-parts"></script>
<script src="../../common/toast.js?v=20260618-cp-space-parts"></script>
<script src="./script.js?v=20260618-cp-space-parts"></script>
</body>
</html>

View File

@ -1,12 +1,24 @@
(function () {
var VALID_TABS = ['cp', 'brother', 'sister'];
var LEVEL_IMAGES = {
1: './assets/level-1@4x.png',
2: './assets/level-2@4x.png',
3: './assets/level-3@4x.png',
4: './assets/level-4@4x.png',
5: './assets/level-5@4x.png',
};
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');
@ -17,13 +29,24 @@
var state = {
activeTab: normalizeTab(readParam('tab')) || 'cp',
requestSeq: 0,
cancelingId: '',
tabs: {
cp: [normalizeLevel(readParam('level')) || 1],
brother: [2, 2],
sister: [4, 5],
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);
@ -40,7 +63,16 @@
var level = Number(value);
return Number.isFinite(level) && level >= 1 && level <= 5
? Math.floor(level)
: 0;
: 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) {
@ -59,6 +91,123 @@
.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;
@ -70,36 +219,184 @@
);
});
render();
if (!state.tabs[nextTab].loaded && !state.tabs[nextTab].loading) {
loadTab(nextTab);
}
}
function cardHTML(level, index) {
var safeLevel = normalizeLevel(level) || 1;
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(safeLevel)
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="relation-card" data-level="',
safeLevel,
'<article class="',
cardClass,
'" data-level="',
visualLevel(item),
'">',
'<img src="',
LEVEL_IMAGES[safeLevel],
'<img class="relation-card-bg" src="',
parts.bg,
'" alt="',
escapeHTML(label),
'" draggable="false" />',
'<button class="cancel-hotspot" type="button" data-cancel-index="',
index,
'" aria-label="',
escapeHTML(t('cpSpace.cancel', 'Cancel')),
'"></button>',
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 levels = state.tabs[state.activeTab] || [];
if (!levels.length) {
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.')) +
@ -109,45 +406,124 @@
contentPanel.innerHTML =
'<div class="relation-list">' +
levels.map(cardHTML).join('') +
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 || {};
var nextTabs = {};
VALID_TABS.forEach(function (tab) {
var raw = data[tab];
if (!Array.isArray(raw)) return;
var normalized = raw
state.tabs[tab].relationships = raw
.map(function (item) {
return normalizeLevel(
typeof item === 'object' ? item.level : item
);
return normalizeRelationship(item || {}, tab);
})
.filter(Boolean);
if (normalized.length) nextTabs[tab] = normalized;
});
// Flutter 可以在 WebView 注入 window.HyAppCPSpaceData 或派发 hyapp:cp-space-data
// 页面只接受 1-5 的等级编号,避免 H5 自己猜测关系状态或渲染后端未确认的数据。
Object.keys(nextTabs).forEach(function (tab) {
state.tabs[tab] = nextTabs[tab];
.filter(function (item) {
return item.relationshipId;
});
state.tabs[tab].loaded = true;
});
if (normalizeTab(data.activeTab)) state.activeTab = data.activeTab;
setActiveTab(state.activeTab);
}
function handleCancel(index) {
var level = (state.tabs[state.activeTab] || [])[Number(index)] || 0;
if (window.HyAppBridge && window.HyAppBridge.post) {
window.HyAppBridge.post('cpSpaceCancel', {
relation_type: state.activeTab,
level: level,
});
}
}
function bindEvents() {
if (backButton) {
backButton.addEventListener('click', function () {
@ -166,9 +542,13 @@
});
contentPanel.addEventListener('click', function (event) {
var target = event.target.closest('[data-cancel-index]');
if (!target) return;
handleCancel(target.getAttribute('data-cancel-index'));
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) {

View File

@ -59,7 +59,7 @@ button {
.cp-space::before {
z-index: 0;
background: url('./assets/space-bg.svg') center top / 100% auto repeat-y;
background: url('./assets/space-bg@4x.png') center top / 100% auto repeat-y;
}
.cp-space::after {
@ -168,26 +168,317 @@ button {
.relation-card {
position: relative;
width: min(355px, calc(100vw - 20px));
aspect-ratio: 355 / 203;
color: #ff0979;
--heart-size: 33.8%;
--button-width: 35.8%;
--button-height: 14.8%;
--accent-color: #ff0979;
--accent-shadow: rgba(110, 17, 108, 0.75);
--avatar-y: 6.9%;
--heart-y: 2.5%;
--button-y: 73.9%;
border-radius: 10px;
isolation: isolate;
}
.relation-card img {
.relation-card[data-level='2'] {
--accent-color: #0967ff;
--heart-size: 33.8%;
--button-width: 35.8%;
--button-height: 18.7%;
}
.relation-card[data-level='3'] {
--accent-color: #7c09ff;
--heart-size: 40.6%;
--button-width: 45.9%;
--button-height: 17.2%;
--avatar-y: 9.9%;
--heart-y: 0;
--button-y: 76.4%;
}
.relation-card[data-level='4'] {
--accent-color: #ff090d;
--heart-size: 37.7%;
--button-width: 36.6%;
--button-height: 20.7%;
}
.relation-card[data-level='5'] {
--accent-color: #ff090d;
--heart-size: 42.8%;
--button-width: 37.5%;
--button-height: 17.2%;
--heart-y: 0;
--button-y: 76.8%;
}
.relation-card-bg {
position: absolute;
inset: 0;
z-index: 0;
display: block;
width: 100%;
height: auto;
height: 100%;
border-radius: 10px;
object-fit: fill;
user-select: none;
-webkit-user-drag: none;
}
.cancel-hotspot {
.relation-avatar {
position: absolute;
left: 35%;
bottom: 10%;
width: 30%;
height: 18%;
z-index: 2;
top: var(--avatar-y);
width: 28.7%;
aspect-ratio: 1;
margin: 0;
}
.relation-avatar--left {
left: 5.1%;
}
.relation-avatar--right {
right: 5.1%;
}
.relation-avatar-photo,
.relation-avatar-frame {
position: absolute;
display: block;
user-select: none;
-webkit-user-drag: none;
}
.relation-avatar-photo {
left: 21.6%;
top: 17.6%;
z-index: 1;
width: 56.9%;
aspect-ratio: 1;
border-radius: 50%;
object-fit: cover;
}
.relation-avatar-photo--placeholder {
display: grid;
place-items: center;
background: linear-gradient(135deg, #f65ca8, #7631dc);
color: #fff;
font-size: 22px;
font-weight: 700;
line-height: 1;
}
.relation-avatar-frame {
inset: 0;
z-index: 2;
width: 100%;
height: 100%;
object-fit: contain;
}
.relation-name {
position: absolute;
z-index: 3;
top: 67%;
width: 84%;
overflow: hidden;
color: #fff;
font-size: 11px;
font-weight: 700;
line-height: 14px;
text-align: center;
text-overflow: ellipsis;
text-shadow: 0 1px 4px rgba(93, 23, 88, 0.75);
white-space: nowrap;
}
.relation-name--left {
left: 8%;
}
.relation-name--right {
right: 8%;
}
.relation-heart-art {
position: absolute;
z-index: 1;
left: 50%;
top: var(--heart-y);
display: block;
width: var(--heart-size);
aspect-ratio: 1;
object-fit: contain;
transform: translateX(-50%);
user-select: none;
-webkit-user-drag: none;
}
.relation-days {
position: absolute;
z-index: 2;
left: 50%;
top: 23.5%;
display: flex;
align-items: baseline;
justify-content: center;
width: 28%;
height: 14%;
color: #fff;
text-align: center;
text-shadow:
0 1px 0 var(--accent-color),
0 2px 7px var(--accent-shadow);
transform: translateX(-50%);
}
.relation-days strong {
font-size: 20px;
font-weight: 800;
line-height: 20px;
}
.relation-days span {
margin-left: 3px;
font-size: 10px;
font-weight: 700;
line-height: 12px;
}
.relation-level {
position: absolute;
z-index: 2;
top: 55.6%;
width: 8.45%;
aspect-ratio: 30 / 16;
color: #fff;
font-size: 10px;
font-weight: 700;
line-height: 1;
text-align: center;
}
.relation-level--current {
left: 8.45%;
}
.relation-level--next {
right: 8.45%;
}
.relation-level img {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.relation-level span {
position: absolute;
inset: 0;
display: grid;
place-items: center;
text-shadow: 0 0 1px #fc8ab1;
}
.relation-progress {
position: absolute;
z-index: 2;
left: 19.15%;
top: 58.1%;
width: 62%;
height: 3%;
overflow: hidden;
border-radius: 999px;
}
.relation-progress span {
position: absolute;
left: 0;
top: 0;
display: block;
width: 0;
height: 100%;
border-radius: inherit;
overflow: hidden;
}
.relation-progress-track,
.relation-progress-fill {
position: absolute;
left: 0;
top: 0;
display: block;
width: 100%;
height: 100%;
user-select: none;
-webkit-user-drag: none;
}
.relation-progress-fill {
width: auto;
min-width: 100%;
}
.relation-exp {
position: absolute;
z-index: 2;
left: 25%;
top: 62.6%;
width: 50%;
overflow: hidden;
color: var(--accent-color);
font-size: 11px;
font-weight: 700;
line-height: 14px;
text-align: center;
text-overflow: ellipsis;
text-shadow: 0 0 1px rgba(255, 255, 255, 0.72);
white-space: nowrap;
}
.cancel-button {
position: absolute;
z-index: 3;
left: 50%;
top: var(--button-y);
width: var(--button-width);
height: var(--button-height);
color: var(--accent-color);
font-size: 13px;
font-weight: 700;
line-height: 1;
text-align: center;
text-shadow: 0 0 1px rgba(255, 255, 255, 0.8);
transform: translateX(-50%);
}
.cancel-button-art {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
user-select: none;
-webkit-user-drag: none;
}
.cancel-button span {
position: relative;
z-index: 1;
}
.cancel-button:disabled {
opacity: 0.7;
cursor: wait;
}
.empty-state {
width: min(355px, calc(100vw - 20px));
margin: 0 auto;
@ -201,6 +492,11 @@ button {
text-align: center;
}
.empty-state--button {
display: block;
cursor: pointer;
}
[dir='rtl'] .back-button span {
transform: rotate(225deg);
}

View File

@ -756,6 +756,26 @@ var default_api = 'https://api.global-interaction.com/';
},
};
var cpSpaceAPI = {
relationships: function (params) {
return request('/api/v1/cp/relationships', {
method: 'GET',
query: params || {},
});
},
breakRelationship: function (relationshipID, payload) {
return request(
'/api/v1/cp/relationships/' +
encodeURIComponent(relationshipID || '') +
'/break',
{
method: 'POST',
body: payload || {},
}
);
},
};
// 转盘 H5 只通过 gateway 活动接口读配置、抽奖、查记录和跑马灯提示;页面不再内置 mock 奖品,避免后台资源组未配置时前端仍展示假奖池。
// 抽奖 command_id 由页面点击时生成并随请求提交,服务端用它做幂等,网络重试不会重复扣费或重复发奖。
var activityWheelAPI = {
@ -990,6 +1010,7 @@ var default_api = 'https://api.global-interaction.com/';
task: taskAPI,
weeklyStar: weeklyStarAPI,
cpWeeklyRank: cpWeeklyRankAPI,
cpSpace: cpSpaceAPI,
inviteActivityReward: inviteActivityRewardAPI,
game: gameAPI,
};

View File

@ -607,5 +607,18 @@
"cpSpace.tabSister": "Sister",
"cpSpace.levelCard": "مستوى أفضل صديق {level}",
"cpSpace.cancel": "إلغاء",
"cpSpace.empty": "لا توجد بيانات علاقة بعد."
"cpSpace.empty": "لا توجد بيانات علاقة بعد.",
"cpSpace.loading": "جار التحميل...",
"cpSpace.loadFailed": "فشل التحميل. اضغط للمحاولة مرة أخرى.",
"cpSpace.unknownUser": "User",
"cpSpace.daysUnit": "Days",
"cpSpace.daysValue": "{days} Days",
"cpSpace.levelValue": "Lv.{level}",
"cpSpace.expProgress": "{current}/{next} EXP",
"cpSpace.maxLevelExp": "{exp} EXP",
"cpSpace.canceling": "جار الإلغاء...",
"cpSpace.cancelConfirmWithCost": "إلغاء علاقة CP سيكلف {coins} عملة. هل تريد المتابعة؟",
"cpSpace.cancelConfirmFree": "هل تريد إلغاء علاقة CP؟",
"cpSpace.cancelSuccess": "تم الإلغاء.",
"cpSpace.cancelFailed": "فشل الإلغاء."
}

View File

@ -613,5 +613,18 @@
"cpSpace.tabSister": "Sister",
"cpSpace.levelCard": "Best friend level {level}",
"cpSpace.cancel": "Cancel",
"cpSpace.empty": "No relationship data yet."
"cpSpace.empty": "No relationship data yet.",
"cpSpace.loading": "Loading...",
"cpSpace.loadFailed": "Load failed. Tap to retry.",
"cpSpace.unknownUser": "User",
"cpSpace.daysUnit": "Days",
"cpSpace.daysValue": "{days} Days",
"cpSpace.levelValue": "Lv.{level}",
"cpSpace.expProgress": "{current}/{next} EXP",
"cpSpace.maxLevelExp": "{exp} EXP",
"cpSpace.canceling": "Canceling...",
"cpSpace.cancelConfirmWithCost": "Canceling this CP will cost {coins} coins. Continue?",
"cpSpace.cancelConfirmFree": "Cancel this CP relationship?",
"cpSpace.cancelSuccess": "Canceled.",
"cpSpace.cancelFailed": "Cancel failed."
}

View File

@ -607,5 +607,18 @@
"cpSpace.tabSister": "Sister",
"cpSpace.levelCard": "Nivel de mejor amigo {level}",
"cpSpace.cancel": "Cancelar",
"cpSpace.empty": "Aún no hay datos de relación."
"cpSpace.empty": "Aún no hay datos de relación.",
"cpSpace.loading": "Cargando...",
"cpSpace.loadFailed": "Error al cargar. Toca para reintentar.",
"cpSpace.unknownUser": "User",
"cpSpace.daysUnit": "Days",
"cpSpace.daysValue": "{days} Days",
"cpSpace.levelValue": "Lv.{level}",
"cpSpace.expProgress": "{current}/{next} EXP",
"cpSpace.maxLevelExp": "{exp} EXP",
"cpSpace.canceling": "Cancelando...",
"cpSpace.cancelConfirmWithCost": "Cancelar este CP costará {coins} monedas. ¿Continuar?",
"cpSpace.cancelConfirmFree": "¿Cancelar esta relación CP?",
"cpSpace.cancelSuccess": "Cancelado.",
"cpSpace.cancelFailed": "Error al cancelar."
}

View File

@ -574,5 +574,18 @@
"cpSpace.tabSister": "Sister",
"cpSpace.levelCard": "Level sahabat terbaik {level}",
"cpSpace.cancel": "Batal",
"cpSpace.empty": "Belum ada data hubungan."
"cpSpace.empty": "Belum ada data hubungan.",
"cpSpace.loading": "Memuat...",
"cpSpace.loadFailed": "Gagal memuat. Ketuk untuk mencoba lagi.",
"cpSpace.unknownUser": "User",
"cpSpace.daysUnit": "Days",
"cpSpace.daysValue": "{days} Days",
"cpSpace.levelValue": "Lv.{level}",
"cpSpace.expProgress": "{current}/{next} EXP",
"cpSpace.maxLevelExp": "{exp} EXP",
"cpSpace.canceling": "Membatalkan...",
"cpSpace.cancelConfirmWithCost": "Membatalkan CP ini akan memakai {coins} koin. Lanjutkan?",
"cpSpace.cancelConfirmFree": "Batalkan hubungan CP ini?",
"cpSpace.cancelSuccess": "Dibatalkan.",
"cpSpace.cancelFailed": "Gagal membatalkan."
}

View File

@ -574,5 +574,18 @@
"cpSpace.tabSister": "Sister",
"cpSpace.levelCard": "En iyi arkadaş seviyesi {level}",
"cpSpace.cancel": "İptal",
"cpSpace.empty": "Henüz ilişki verisi yok."
"cpSpace.empty": "Henüz ilişki verisi yok.",
"cpSpace.loading": "Yükleniyor...",
"cpSpace.loadFailed": "Yüklenemedi. Tekrar denemek için dokun.",
"cpSpace.unknownUser": "User",
"cpSpace.daysUnit": "Days",
"cpSpace.daysValue": "{days} Days",
"cpSpace.levelValue": "Lv.{level}",
"cpSpace.expProgress": "{current}/{next} EXP",
"cpSpace.maxLevelExp": "{exp} EXP",
"cpSpace.canceling": "İptal ediliyor...",
"cpSpace.cancelConfirmWithCost": "Bu CP ilişkisini iptal etmek {coins} coin tutar. Devam edilsin mi?",
"cpSpace.cancelConfirmFree": "Bu CP ilişkisi iptal edilsin mi?",
"cpSpace.cancelSuccess": "İptal edildi.",
"cpSpace.cancelFailed": "İptal başarısız."
}

View File

@ -580,5 +580,18 @@
"cpSpace.tabSister": "Sister",
"cpSpace.levelCard": "挚友等级 {level}",
"cpSpace.cancel": "取消",
"cpSpace.empty": "暂无关系数据"
"cpSpace.empty": "暂无关系数据",
"cpSpace.loading": "加载中...",
"cpSpace.loadFailed": "加载失败,点击重试",
"cpSpace.unknownUser": "用户",
"cpSpace.daysUnit": "Days",
"cpSpace.daysValue": "{days} Days",
"cpSpace.levelValue": "Lv.{level}",
"cpSpace.expProgress": "{current}/{next} EXP",
"cpSpace.maxLevelExp": "{exp} EXP",
"cpSpace.canceling": "取消中...",
"cpSpace.cancelConfirmWithCost": "取消 CP 关系需要支付 {coins} 金币,是否继续?",
"cpSpace.cancelConfirmFree": "确定取消 CP 关系?",
"cpSpace.cancelSuccess": "已取消",
"cpSpace.cancelFailed": "取消失败"
}