feat: update h5 guild center pages

This commit is contained in:
zhx 2026-07-09 13:25:54 +08:00
parent d5bc7ee440
commit ae39abaa02
24 changed files with 4036 additions and 542 deletions

11
.claude/launch.json Normal file
View File

@ -0,0 +1,11 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "h5-static",
"runtimeExecutable": "npx",
"runtimeArgs": ["--yes", "http-server", "-p", "18080", "-c-1"],
"port": 18080
}
]
}

View File

@ -34,6 +34,10 @@ button {
} }
.yumi-cp-stage { .yumi-cp-stage {
--stage-height: 812px;
--design-width: 375px;
--design-height: 812px;
--design-scale: 1;
--accent: #ff0979; --accent: #ff0979;
--accent-soft: #ff5762; --accent-soft: #ff5762;
--tab-bg: rgba(94, 13, 35, 0.8); --tab-bg: rgba(94, 13, 35, 0.8);
@ -47,14 +51,22 @@ button {
--progress-to: #ffcf67; --progress-to: #ffcf67;
--exp-pill-bg: rgba(255, 102, 169, 0.74); --exp-pill-bg: rgba(255, 102, 169, 0.74);
position: relative; position: relative;
width: min(100vw, 375px); width: 100%;
height: min(812px, 216.533333vw); height: var(--stage-height);
max-height: 812px;
overflow: hidden; overflow: hidden;
background: #440b23; background: #440b23;
flex: 0 0 auto; flex: 0 0 auto;
} }
.yumi-stage-design {
/* 资源按 375x812 设计稿导出;统一缩放内层画布,避免逐个重算绝对定位和素材尺寸。 */
position: relative;
width: var(--design-width);
height: var(--design-height);
transform: scale(var(--design-scale));
transform-origin: left top;
}
.yumi-cp-page[data-tab='brother'] .yumi-cp-stage { .yumi-cp-page[data-tab='brother'] .yumi-cp-stage {
--accent: #5709ff; --accent: #5709ff;
--accent-soft: #5709ff; --accent-soft: #5709ff;
@ -108,7 +120,6 @@ button {
object-fit: fill; object-fit: fill;
} }
.yumi-status,
.yumi-nav, .yumi-nav,
.yumi-tabs, .yumi-tabs,
.yumi-card { .yumi-card {
@ -116,146 +127,9 @@ button {
z-index: 1; z-index: 1;
} }
.yumi-status {
left: 0;
top: 0;
width: 375px;
height: 44px;
color: #fff;
pointer-events: none;
transform-origin: left top;
}
.yumi-status-time {
position: absolute;
left: 33.87px;
top: 13px;
width: 54px;
font-family: Helvetica, Arial, sans-serif;
font-size: 15px;
line-height: 15px;
text-align: center;
}
.yumi-status-cellular {
position: absolute;
left: 294.34px;
top: 15px;
display: flex;
align-items: flex-end;
gap: 2px;
width: 17px;
height: 12px;
}
.yumi-status-cellular i {
display: block;
width: 3px;
border-radius: 2px;
background: #fff;
}
.yumi-status-cellular i:nth-child(1) {
height: 4px;
}
.yumi-status-cellular i:nth-child(2) {
height: 7px;
}
.yumi-status-cellular i:nth-child(3) {
height: 10px;
}
.yumi-status-cellular i:nth-child(4) {
height: 12px;
}
.yumi-status-wifi {
position: absolute;
left: 316.34px;
top: 16px;
width: 16px;
height: 12px;
overflow: hidden;
}
.yumi-status-wifi::before,
.yumi-status-wifi::after,
.yumi-status-wifi i {
position: absolute;
left: 50%;
border: 2px solid #fff;
border-right-color: transparent;
border-bottom-color: transparent;
border-left-color: transparent;
border-radius: 50%;
content: '';
transform: translateX(-50%);
}
.yumi-status-wifi::before {
top: 0;
width: 16px;
height: 16px;
}
.yumi-status-wifi::after {
top: 4px;
width: 10px;
height: 10px;
}
.yumi-status-wifi i {
top: 8px;
width: 4px;
height: 4px;
background: #fff;
}
.yumi-status-battery {
position: absolute;
left: 336.67px;
top: 16.33px;
width: 24.33px;
height: 11.33px;
}
.yumi-status-battery::before {
position: absolute;
left: 0;
top: 0;
width: 21px;
height: 11px;
border: 1.2px solid #fff;
border-radius: 2.7px;
content: '';
}
.yumi-status-battery::after {
position: absolute;
right: 0;
top: 3.4px;
width: 1.4px;
height: 4.5px;
border-radius: 0 1px 1px 0;
background: #fff;
content: '';
}
.yumi-status-battery i {
position: absolute;
left: 2.3px;
top: 2.3px;
width: 17px;
height: 6.8px;
border-radius: 1.5px;
background: #fff;
}
.yumi-nav { .yumi-nav {
left: 0; left: 0;
top: 44px; top: 0;
width: 375px; width: 375px;
height: 44px; height: 44px;
} }
@ -318,7 +192,7 @@ button {
.yumi-tabs { .yumi-tabs {
left: 16px; left: 16px;
top: 109px; top: 65px;
display: grid; display: grid;
grid-template-columns: repeat(3, 1fr); grid-template-columns: repeat(3, 1fr);
width: 343px; width: 343px;
@ -362,7 +236,7 @@ button {
.yumi-card { .yumi-card {
left: 14px; left: 14px;
top: 169px; top: 125px;
width: 347px; width: 347px;
height: 199px; height: 199px;
color: var(--accent); color: var(--accent);
@ -636,13 +510,3 @@ button {
[dir='rtl'] .yumi-tabs { [dir='rtl'] .yumi-tabs {
direction: ltr; direction: ltr;
} }
@media (max-width: 374.98px) {
.yumi-status,
.yumi-nav,
.yumi-tabs,
.yumi-card {
transform: scale(calc(100vw / 375));
transform-origin: left top;
}
}

View File

@ -12,7 +12,7 @@
rel="stylesheet" rel="stylesheet"
href="../../common/theme.css?v=20260708-yumi-layered" href="../../common/theme.css?v=20260708-yumi-layered"
/> />
<link rel="stylesheet" href="./yumi.css?v=20260708-yumi-layered" /> <link rel="stylesheet" href="./yumi.css?v=20260708-yumi-fullwidth" />
</head> </head>
<body> <body>
<main <main
@ -27,173 +27,176 @@
aria-label="Best Friend" aria-label="Best Friend"
data-i18n-aria="cpSpace.pageLabel" data-i18n-aria="cpSpace.pageLabel"
> >
<img <div class="yumi-stage-design">
class="yumi-bg-layer"
id="yumiBgLayer"
src="./assets/yumi/layers/bg-cp.png"
alt=""
draggable="false"
/>
<div class="yumi-status" aria-hidden="true">
<span class="yumi-status-time">9:41</span>
<span class="yumi-status-cellular"
><i></i><i></i><i></i><i></i
></span>
<span class="yumi-status-wifi"><i></i></span>
<span class="yumi-status-battery"><i></i></span>
</div>
<nav
class="yumi-nav"
aria-label="Best Friend navigation"
data-i18n-aria="cpSpace.navLabel"
>
<button
class="yumi-nav-button yumi-nav-button--back"
id="yumiBackButton"
type="button"
aria-label="Back"
data-i18n-aria="cpSpace.back"
></button>
<h1 class="yumi-title" data-i18n="cpSpace.title">
Best Friend
</h1>
<button
class="yumi-nav-button yumi-nav-button--help"
id="yumiHelpButton"
type="button"
aria-label="Help"
data-i18n-aria="cpSpace.help"
></button>
</nav>
<nav
class="yumi-tabs"
aria-label="Best Friend tabs"
data-i18n-aria="cpSpace.tabsLabel"
>
<img <img
class="yumi-tab-jewel" class="yumi-bg-layer"
id="yumiTabJewel" id="yumiBgLayer"
src="./assets/yumi/layers/tab-cp.png" src="./assets/yumi/layers/bg-cp.png"
alt=""
draggable="false"
/>
<button class="yumi-tab-button" type="button" data-tab="cp">
<span data-i18n="cpSpace.tabCp">CP</span>
</button>
<button
class="yumi-tab-button"
type="button"
data-tab="brother"
>
<span data-i18n="cpSpace.tabBrother">Brother</span>
</button>
<button
class="yumi-tab-button"
type="button"
data-tab="sister"
>
<span data-i18n="cpSpace.tabSister">Sister</span>
</button>
</nav>
<article class="yumi-card" aria-label="Best friend level 1">
<img
class="yumi-card-skin"
id="yumiCardSkin"
src="./assets/yumi/layers/card-cp.png"
alt="" alt=""
draggable="false" draggable="false"
/> />
<figure class="yumi-avatar yumi-avatar--left"> <nav
<span class="yumi-nav"
class="yumi-avatar-photo" aria-label="Best Friend navigation"
id="yumiLeftAvatar" data-i18n-aria="cpSpace.navLabel"
></span>
<img
class="yumi-avatar-frame"
src="./assets/yumi/layers/avatar-frame.png"
alt=""
draggable="false"
/>
</figure>
<figure class="yumi-avatar yumi-avatar--right">
<span
class="yumi-avatar-photo"
id="yumiRightAvatar"
></span>
<img
class="yumi-avatar-frame"
src="./assets/yumi/layers/avatar-frame.png"
alt=""
draggable="false"
/>
</figure>
<div class="yumi-center-art-wrap" id="yumiCenterWrap">
<img
class="yumi-center-art"
id="yumiCenterArt"
src="./assets/yumi/layers/center-cp.png"
alt=""
draggable="false"
/>
<strong class="yumi-days-value" id="yumiDaysValue"
>999</strong
>
<span
class="yumi-days-unit"
data-i18n="cpSpace.daysUnit"
>Days</span
>
</div>
<div class="yumi-exp-pill">
<span id="yumiExpAway">888 Exp away from Lv. 2</span>
</div>
<section
class="yumi-progress-block"
aria-label="1000/200000"
> >
<div class="yumi-level-badge yumi-level-badge--left"> <button
class="yumi-nav-button yumi-nav-button--back"
id="yumiBackButton"
type="button"
aria-label="Back"
data-i18n-aria="cpSpace.back"
></button>
<h1 class="yumi-title" data-i18n="cpSpace.title">
Best Friend
</h1>
<button
class="yumi-nav-button yumi-nav-button--help"
id="yumiHelpButton"
type="button"
aria-label="Help"
data-i18n-aria="cpSpace.help"
></button>
</nav>
<nav
class="yumi-tabs"
aria-label="Best Friend tabs"
data-i18n-aria="cpSpace.tabsLabel"
>
<img
class="yumi-tab-jewel"
id="yumiTabJewel"
src="./assets/yumi/layers/tab-cp.png"
alt=""
draggable="false"
/>
<button
class="yumi-tab-button"
type="button"
data-tab="cp"
>
<span data-i18n="cpSpace.tabCp">CP</span>
</button>
<button
class="yumi-tab-button"
type="button"
data-tab="brother"
>
<span data-i18n="cpSpace.tabBrother">Brother</span>
</button>
<button
class="yumi-tab-button"
type="button"
data-tab="sister"
>
<span data-i18n="cpSpace.tabSister">Sister</span>
</button>
</nav>
<article class="yumi-card" aria-label="Best friend level 1">
<img
class="yumi-card-skin"
id="yumiCardSkin"
src="./assets/yumi/layers/card-cp.png"
alt=""
draggable="false"
/>
<figure class="yumi-avatar yumi-avatar--left">
<span
class="yumi-avatar-photo"
id="yumiLeftAvatar"
></span>
<img <img
class="yumi-level-bg" class="yumi-avatar-frame"
src="./assets/yumi/layers/level-cp.png" src="./assets/yumi/layers/avatar-frame.png"
alt="" alt=""
draggable="false" draggable="false"
/> />
<span id="yumiCurrentLevel">Lv1</span> </figure>
</div>
<div class="yumi-progress-track"> <figure class="yumi-avatar yumi-avatar--right">
<span <span
class="yumi-progress-fill" class="yumi-avatar-photo"
id="yumiProgressFill" id="yumiRightAvatar"
></span> ></span>
<strong <img
class="yumi-progress-text" class="yumi-avatar-frame"
id="yumiProgressText" src="./assets/yumi/layers/avatar-frame.png"
>1000/200000</strong alt=""
draggable="false"
/>
</figure>
<div class="yumi-center-art-wrap" id="yumiCenterWrap">
<img
class="yumi-center-art"
id="yumiCenterArt"
src="./assets/yumi/layers/center-cp.png"
alt=""
draggable="false"
/>
<strong class="yumi-days-value" id="yumiDaysValue"
>999</strong
>
<span
class="yumi-days-unit"
data-i18n="cpSpace.daysUnit"
>Days</span
> >
</div> </div>
<div class="yumi-level-badge yumi-level-badge--right">
<img <div class="yumi-exp-pill">
class="yumi-level-bg" <span id="yumiExpAway"
src="./assets/yumi/layers/level-cp.png" >888 Exp away from Lv. 2</span
alt="" >
draggable="false"
/>
<span id="yumiNextLevel">Lv1</span>
</div> </div>
<p class="yumi-time-text" id="yumiTimeText">
Time spent together: 4 days <section
</p> class="yumi-progress-block"
</section> aria-label="1000/200000"
</article> >
<div
class="yumi-level-badge yumi-level-badge--left"
>
<img
class="yumi-level-bg"
src="./assets/yumi/layers/level-cp.png"
alt=""
draggable="false"
/>
<span id="yumiCurrentLevel">Lv1</span>
</div>
<div class="yumi-progress-track">
<span
class="yumi-progress-fill"
id="yumiProgressFill"
></span>
<strong
class="yumi-progress-text"
id="yumiProgressText"
>1000/200000</strong
>
</div>
<div
class="yumi-level-badge yumi-level-badge--right"
>
<img
class="yumi-level-bg"
src="./assets/yumi/layers/level-cp.png"
alt=""
draggable="false"
/>
<span id="yumiNextLevel">Lv1</span>
</div>
<p class="yumi-time-text" id="yumiTimeText">
Time spent together: 4 days
</p>
</section>
</article>
</div>
</section> </section>
</main> </main>
@ -201,6 +204,7 @@
<script src="../../common/api.js?v=20260708-yumi-layered"></script> <script src="../../common/api.js?v=20260708-yumi-layered"></script>
<script src="../../common/jsbridge.js?v=20260708-yumi-layered"></script> <script src="../../common/jsbridge.js?v=20260708-yumi-layered"></script>
<script src="../../common/toast.js?v=20260708-yumi-layered"></script> <script src="../../common/toast.js?v=20260708-yumi-layered"></script>
<script src="./yumi.js?v=20260708-yumi-layered"></script> <script src="../../yumi_commmon/yumi-common.js?v=20260708-yumi-api"></script>
<script src="./yumi.js?v=20260708-yumi-api"></script>
</body> </body>
</html> </html>

View File

@ -25,20 +25,21 @@
}, },
}; };
var DEFAULT_RELATION = { var DEFAULT_RELATION = {
days: 999, days: 0,
togetherDays: 4, togetherDays: 0,
currentLevel: 1, currentLevel: 0,
nextLevel: 1, nextLevel: 0,
currentExp: 1000, currentExp: 0,
nextExp: 200000, nextExp: 0,
neededExp: 888, neededExp: 0,
nextExpLevel: 2, nextExpLevel: 0,
progressPercent: 22.3, progressPercent: 0,
me: { avatar: '' }, me: { avatar: '' },
partner: { avatar: '' }, partner: { avatar: '' },
}; };
var page = document.getElementById('yumiCpPage'); var page = document.getElementById('yumiCpPage');
var stage = document.querySelector('.yumi-cp-stage');
var backButton = document.getElementById('yumiBackButton'); var backButton = document.getElementById('yumiBackButton');
var helpButton = document.getElementById('yumiHelpButton'); var helpButton = document.getElementById('yumiHelpButton');
var tabButtons = Array.prototype.slice.call( var tabButtons = Array.prototype.slice.call(
@ -63,7 +64,10 @@
var state = { var state = {
activeTab: normalizeTab(readParam('tab')) || 'cp', activeTab: normalizeTab(readParam('tab')) || 'cp',
data: clone(DEFAULT_RELATION), data: clone(DEFAULT_RELATION),
tabData: {},
requestSeq: 0,
}; };
var stageResizeObserver = null;
function clone(value) { function clone(value) {
return JSON.parse(JSON.stringify(value)); return JSON.parse(JSON.stringify(value));
@ -86,6 +90,15 @@
return Number.isFinite(number) ? number : fallback || 0; return Number.isFinite(number) ? number : fallback || 0;
} }
function firstValue() {
for (var i = 0; i < arguments.length; i += 1) {
var value = arguments[i];
if (value !== undefined && value !== null && value !== '')
return value;
}
return undefined;
}
function t(key, fallback) { function t(key, fallback) {
if (window.HyAppI18n && window.HyAppI18n.t) { if (window.HyAppI18n && window.HyAppI18n.t) {
return window.HyAppI18n.t(key, fallback); return window.HyAppI18n.t(key, fallback);
@ -102,6 +115,28 @@
return LAYER_BASE + name; return LAYER_BASE + name;
} }
function syncStageScale() {
if (!stage) return;
var width = stage.getBoundingClientRect().width || window.innerWidth;
var scale = Math.max(0.1, width / 375);
// Yumi 素材和定位都是 375x812 设计稿坐标;只同步一个缩放值,避免多处百分比换算造成错位。
stage.style.setProperty('--design-scale', String(scale));
stage.style.setProperty('--stage-height', 812 * scale + 'px');
}
function bindStageScale() {
syncStageScale();
if (!stage) return;
if (window.ResizeObserver) {
stageResizeObserver = new ResizeObserver(syncStageScale);
stageResizeObserver.observe(stage);
return;
}
// 低版本 WebView 没有 ResizeObserver只能监听窗口变化来覆盖旋转和容器宽度变更。
window.addEventListener('resize', syncStageScale);
window.addEventListener('orientationchange', syncStageScale);
}
function syncURL(tab) { function syncURL(tab) {
if (!window.history || !window.history.replaceState) return; if (!window.history || !window.history.replaceState) return;
var url = new URL(window.location.href); var url = new URL(window.location.href);
@ -132,28 +167,31 @@
return { return {
days: readNumber(data.days, DEFAULT_RELATION.days), days: readNumber(data.days, DEFAULT_RELATION.days),
togetherDays: readNumber( togetherDays: readNumber(
data.togetherDays || data.together_days, firstValue(data.togetherDays, data.together_days),
DEFAULT_RELATION.togetherDays DEFAULT_RELATION.togetherDays
), ),
currentLevel: readNumber( currentLevel: readNumber(
data.currentLevel || data.current_level || data.level, firstValue(data.currentLevel, data.current_level, data.level),
1 DEFAULT_RELATION.currentLevel
),
nextLevel: readNumber(
firstValue(data.nextLevel, data.next_level),
DEFAULT_RELATION.nextLevel
), ),
nextLevel: readNumber(data.nextLevel || data.next_level, 1),
currentExp: readNumber( currentExp: readNumber(
data.currentExp || data.current_exp, firstValue(data.currentExp, data.current_exp),
DEFAULT_RELATION.currentExp DEFAULT_RELATION.currentExp
), ),
nextExp: readNumber( nextExp: readNumber(
data.nextExp || data.next_exp, firstValue(data.nextExp, data.next_exp),
DEFAULT_RELATION.nextExp DEFAULT_RELATION.nextExp
), ),
neededExp: readNumber( neededExp: readNumber(
data.neededExp || data.needed_exp, firstValue(data.neededExp, data.needed_exp),
DEFAULT_RELATION.neededExp DEFAULT_RELATION.neededExp
), ),
nextExpLevel: readNumber( nextExpLevel: readNumber(
data.nextExpLevel || data.next_exp_level, firstValue(data.nextExpLevel, data.next_exp_level),
DEFAULT_RELATION.nextExpLevel DEFAULT_RELATION.nextExpLevel
), ),
progressPercent: Math.max( progressPercent: Math.max(
@ -161,7 +199,7 @@
Math.min( Math.min(
100, 100,
readNumber( readNumber(
data.progressPercent || data.progress_percent, firstValue(data.progressPercent, data.progress_percent),
DEFAULT_RELATION.progressPercent DEFAULT_RELATION.progressPercent
) )
) )
@ -237,18 +275,59 @@
var data = payload || {}; var data = payload || {};
var tabData = data[state.activeTab] || data.relation || data; var tabData = data[state.activeTab] || data.relation || data;
state.data = normalizedRelation(tabData); state.data = normalizedRelation(tabData);
state.tabData[state.activeTab] = state.data;
renderCopy(); renderCopy();
} }
function showTabData(tab) {
state.data = clone(state.tabData[tab] || DEFAULT_RELATION);
renderCopy();
}
function loadTabData(tab) {
var targetTab = normalizeTab(tab) || 'cp';
if (!window.YumiCommon || !window.YumiCommon.cpSpace) {
return Promise.resolve();
}
var seq = (state.requestSeq += 1);
// 数据由 Java 的 CP 关系接口按 relationType 拉取seq 防止用户快速切 tab 时旧响应覆盖新主题。
return window.YumiCommon.cpSpace
.loadRelation({
tab: targetTab,
userId: readParam('userId') || readParam('user_id') || '',
cpUserId:
readParam('cpUserId') || readParam('cp_user_id') || '',
})
.then(function (data) {
if (seq !== state.requestSeq) return;
state.tabData[targetTab] = normalizedRelation(data);
if (state.activeTab === targetTab) showTabData(targetTab);
})
.catch(function (error) {
if (seq !== state.requestSeq) return;
// WebView 首屏不能因为 token、网关或关系为空白屏失败保留当前可交互页面问题交给日志定位。
if (window.console && console.warn) {
console.warn('Yumi CP relation load failed:', error);
}
});
}
function bindEvents() { function bindEvents() {
tabButtons.forEach(function (button) { tabButtons.forEach(function (button) {
button.addEventListener('click', function () { button.addEventListener('click', function () {
setActiveTab(button.getAttribute('data-tab')); var tab = button.getAttribute('data-tab');
setActiveTab(tab);
showTabData(state.activeTab);
loadTabData(state.activeTab);
}); });
}); });
if (backButton) { if (backButton) {
backButton.addEventListener('click', function () { backButton.addEventListener('click', function () {
if (window.YumiCommon && window.YumiCommon.back) {
window.YumiCommon.back();
return;
}
if (window.HyAppBridge && window.HyAppBridge.back) { if (window.HyAppBridge && window.HyAppBridge.back) {
window.HyAppBridge.back(); window.HyAppBridge.back();
return; return;
@ -274,13 +353,16 @@
}); });
} }
bindStageScale();
bindEvents(); bindEvents();
if (window.HyAppCPSpaceYumiData) if (window.HyAppCPSpaceYumiData)
applyExternalData(window.HyAppCPSpaceYumiData); applyExternalData(window.HyAppCPSpaceYumiData);
setActiveTab(state.activeTab, { syncURL: false }); setActiveTab(state.activeTab, { syncURL: false });
showTabData(state.activeTab);
loadTabData(state.activeTab);
if (window.HyAppBridge && window.HyAppBridge.ready) { if (window.HyAppBridge && window.HyAppBridge.ready) {
// ready 只声明页面能力;真实关系数据由客户端事件或网关注入,避免 H5 自行判断关系状态 // ready 只声明页面能力;真实关系数据由 YumiCommon 接口层拉取,客户端仍可通过事件覆盖
window.HyAppBridge.ready({ page: 'yumi-cp-space' }); window.HyAppBridge.ready({ page: 'yumi-cp-space' });
} }
})(); })();

View File

@ -153,6 +153,14 @@ var default_api = 'https://api.global-interaction.com/';
return normalizeBaseURL(window.location.origin + path); return normalizeBaseURL(window.location.origin + path);
} }
function localDevProxyBase(env) {
if (!isLocalDevOrigin()) return '';
if (env === 'test') return devProxyBase('/__api_test__/');
if (env === 'local') return devProxyBase('/__api_local__/');
// 本地 H5 调试默认仍访问生产数据,但浏览器只能请求同源代理;由 scripts/h5_dev_server.py 代发到生产 API。
return devProxyBase('/__api_prod__/');
}
function resolveEnv() { function resolveEnv() {
var env = readQuery('env'); var env = readQuery('env');
if (env === 'test' || env === 'local') { if (env === 'test' || env === 'local') {
@ -169,10 +177,9 @@ var default_api = 'https://api.global-interaction.com/';
var env = resolveEnv(); var env = resolveEnv();
var configuredAPI = activeAppAPIBaseURL(env); var configuredAPI = activeAppAPIBaseURL(env);
if (configuredAPI) return configuredAPI; if (configuredAPI) return configuredAPI;
if (env === 'test') var devProxy = localDevProxyBase(env);
return isLocalDevOrigin() if (devProxy) return devProxy;
? devProxyBase('/__api_test__/') if (env === 'test') return normalizeBaseURL(test_api);
: normalizeBaseURL(test_api);
if (env === 'local') return normalizeBaseURL(local_api); if (env === 'local') return normalizeBaseURL(local_api);
return normalizeBaseURL(default_api); return normalizeBaseURL(default_api);
} }
@ -597,6 +604,32 @@ var default_api = 'https://api.global-interaction.com/';
body: payload || {}, body: payload || {},
}); });
}, },
getCoinSellerSubSellerPermission: function () {
return request(
'/api/v1/wallet/coin-seller/sub-sellers/permission',
{
method: 'GET',
}
);
},
inviteCoinSellerSubSeller: function (payload) {
return request('/api/v1/wallet/coin-seller/sub-sellers', {
method: 'POST',
body: payload || {},
});
},
listCoinSellerSubSellers: function (params) {
return request('/api/v1/wallet/coin-seller/sub-sellers', {
method: 'GET',
query: params || {},
});
},
transferCoinSellerToSubSeller: function (payload) {
return request('/api/v1/wallet/coin-seller/sub-sellers/transfer', {
method: 'POST',
body: payload || {},
});
},
}; };
// H5 充值 API 只做路径和参数名封装token、app_code、env、错误 envelope 仍由 request 统一处理。 // H5 充值 API 只做路径和参数名封装token、app_code、env、错误 envelope 仍由 request 统一处理。

104
common/loading.js Normal file
View File

@ -0,0 +1,104 @@
(function () {
// 通用全屏 loading拉鲁猫吉祥物 + 转圈)。
// 样式复用 common/theme.css 的 .hy-loading 系列类:新页面按仓库约定必须引入 theme.css
// 组件自身只负责 DOM 注入和显隐状态,不重复维护一份样式副本避免两处漂移。
// 吉祥物图取 theme.css 默认值lalu 紫猫动画 webp其他 app 主题可在
// [data-hy-app-code='...'] 下覆盖 .hy-loading__mascot 的 background 换皮。
var LOADING_ID = 'hy-global-loading';
// show/hide 采用引用计数:并发请求各自 show/hide 时,最后一个 hide 才真正关闭遮罩,
// 避免先完成的请求把还在加载的请求的遮罩提前关掉。
var visibleCount = 0;
function ensureNode() {
var node = document.getElementById(LOADING_ID);
if (node) return node;
node = document.createElement('div');
node.id = LOADING_ID;
node.className = 'hy-loading';
node.setAttribute('role', 'status');
node.setAttribute('aria-live', 'polite');
node.setAttribute('aria-hidden', 'true');
var mascot = document.createElement('div');
mascot.className = 'hy-loading__mascot';
mascot.setAttribute('aria-hidden', 'true');
var spinner = document.createElement('div');
spinner.className = 'hy-loading__spinner';
spinner.setAttribute('aria-hidden', 'true');
var text = document.createElement('div');
text.className = 'hy-loading__text';
text.hidden = true;
node.appendChild(mascot);
node.appendChild(spinner);
node.appendChild(text);
document.body.appendChild(node);
return node;
}
function render() {
var node = ensureNode();
var active = visibleCount > 0;
node.classList.toggle('is-active', active);
node.setAttribute('aria-hidden', active ? 'false' : 'true');
}
function setText(node, message) {
var text = node.querySelector('.hy-loading__text');
if (!text) return;
var value = String(message || '').trim();
// 文案由调用方传入已本地化的字符串组件不内置多语言字典common/i18n.js 归页面消费)。
text.textContent = value;
text.hidden = !value;
}
function show(options) {
if (!document.body) {
// 页面脚本可能在 body 就绪前调用;挂到 DOMContentLoaded 重放,保持调用方无需关心时序。
document.addEventListener(
'DOMContentLoaded',
function () {
show(options);
},
{ once: true }
);
return;
}
visibleCount += 1;
var node = ensureNode();
setText(node, options && options.text);
render();
}
function hide(force) {
if (force) {
visibleCount = 0;
} else if (visibleCount > 0) {
visibleCount -= 1;
}
if (!document.getElementById(LOADING_ID)) return;
render();
}
function isVisible() {
return visibleCount > 0;
}
window.HyAppLoading = {
show: show,
hide: hide,
isVisible: isVisible,
};
// 与 HyAppToast 的 hyapp:toast 对齐的事件桥:原生壳或其他脚本可通过事件控制遮罩。
window.addEventListener('hyapp:loading', function (event) {
var detail = event.detail || {};
if (detail.visible === false) {
hide(detail.force === true);
return;
}
show(detail);
});
})();

View File

@ -479,6 +479,21 @@
"coinseller_center.loading": "جار التحميل...", "coinseller_center.loading": "جار التحميل...",
"coinseller_center.load_failed": "فشل التحميل. حاول مرة أخرى لاحقا.", "coinseller_center.load_failed": "فشل التحميل. حاول مرة أخرى لاحقا.",
"coinseller_center.transfer_failed": "فشل التحويل.", "coinseller_center.transfer_failed": "فشل التحويل.",
"coinseller_center.sub_sellers": "بائعو العملات الفرعيون",
"coinseller_center.sub_sellers_desc": "ادع وأدر بائعي العملات الفرعيين المباشرين",
"coinseller_center.invite_sub_seller": "دعوة بائع عملات فرعي",
"coinseller_center.invite_sub_seller_hint": "أدخل معرف المستخدم لفتح بائع عملات فرعي مباشر.",
"coinseller_center.invite_now": "ادع الآن",
"coinseller_center.invite_success": "تم إرسال الدعوة.",
"coinseller_center.invite_failed": "فشلت الدعوة.",
"coinseller_center.no_sub_sellers": "لا يوجد بائعو عملات فرعيون بعد.",
"coinseller_center.sub_seller_wallet": "المحفظة",
"coinseller_center.transfer": "تحويل",
"coinseller_center.transfer_now": "حوّل الآن",
"coinseller_center.transfer_success": "تم التحويل بنجاح.",
"coinseller_center.transfer_to_sub_seller": "تحويل إلى بائع فرعي",
"coinseller_center.transfer_to_sub_seller_hint": "حوّل العملات إلى محفظة بائع العملات الفرعي.",
"coinseller_center.select_sub_seller_first": "اختر بائع عملات فرعيا أولا.",
"lalu_landing.message": "أرسل لك هدية. تعال والعب معي.", "lalu_landing.message": "أرسل لك هدية. تعال والعب معي.",
"lalu_landing.frame_duration": "3 أيام", "lalu_landing.frame_duration": "3 أيام",
"lalu_landing.join_party": "انضم إلى الحفلة", "lalu_landing.join_party": "انضم إلى الحفلة",
@ -715,5 +730,30 @@
"contact_card.required": "أدخل جهة اتصال WhatsApp.", "contact_card.required": "أدخل جهة اتصال WhatsApp.",
"contact_card.api_unavailable": "واجهة جهة الاتصال غير متاحة.", "contact_card.api_unavailable": "واجهة جهة الاتصال غير متاحة.",
"contact_card.saved": "تم الحفظ.", "contact_card.saved": "تم الحفظ.",
"contact_card.save_failed": "فشل الحفظ." "contact_card.save_failed": "فشل الحفظ.",
"manager_center.title": "مركز الإدارة",
"manager_center.back": "رجوع",
"manager_center.change_language": "تغيير اللغة",
"manager_center.copy_id": "نسخ معرف المدير",
"manager_center.role_manager": "مدير",
"manager_center.this_month": "هذا الشهر",
"manager_center.last_month": "الشهر الماضي",
"manager_center.team_salary": "راتب الفريق",
"manager_center.active_hosts": "المضيفون النشطون",
"manager_center.total_active": "إجمالي النشطين",
"manager_center.growth": "النمو",
"manager_center.vs_last_month": "مقارنة بالشهر الماضي",
"manager_center.wallet": "المحفظة",
"manager_center.bank_cards": "البطاقات البنكية",
"manager_center.admin": "مشرف",
"manager_center.bd_leader": "BD Leader",
"manager_center.invite_admin": "دعوة مشرف",
"manager_center.invite_bd": "دعوة BD",
"manager_center.super_admin": "مشرف عام",
"manager_center.block_users": "حظر المستخدمين",
"manager_center.send_frame": "إرسال إطار",
"manager_center.send_car": "إرسال مركبة",
"manager_center.send_badge": "إرسال شارة",
"manager_center.send_vip": "إرسال VIP",
"manager_center.user_level": "مستوى المستخدم"
} }

View File

@ -485,6 +485,21 @@
"coinseller_center.loading": "Loading...", "coinseller_center.loading": "Loading...",
"coinseller_center.load_failed": "Load failed. Try again later.", "coinseller_center.load_failed": "Load failed. Try again later.",
"coinseller_center.transfer_failed": "Transfer failed.", "coinseller_center.transfer_failed": "Transfer failed.",
"coinseller_center.sub_sellers": "My sub coin sellers",
"coinseller_center.sub_sellers_desc": "Invite and manage direct sub coin sellers",
"coinseller_center.invite_sub_seller": "Invite sub coin seller",
"coinseller_center.invite_sub_seller_hint": "Enter the user's display ID to open a direct sub coin seller.",
"coinseller_center.invite_now": "Invite now",
"coinseller_center.invite_success": "Invitation submitted.",
"coinseller_center.invite_failed": "Invite failed.",
"coinseller_center.no_sub_sellers": "No sub coin sellers yet.",
"coinseller_center.sub_seller_wallet": "Wallet",
"coinseller_center.transfer": "Transfer",
"coinseller_center.transfer_now": "Transfer now",
"coinseller_center.transfer_success": "Transfer success.",
"coinseller_center.transfer_to_sub_seller": "Transfer to sub seller",
"coinseller_center.transfer_to_sub_seller_hint": "Transfer coins into the sub seller coin-seller wallet.",
"coinseller_center.select_sub_seller_first": "Select a sub coin seller first.",
"lalu_landing.message": "Sent you a gift. Come play with me.", "lalu_landing.message": "Sent you a gift. Come play with me.",
"lalu_landing.frame_duration": "3 days", "lalu_landing.frame_duration": "3 days",
"lalu_landing.join_party": "Join party", "lalu_landing.join_party": "Join party",
@ -721,5 +736,30 @@
"contact_card.required": "Enter WhatsApp contact.", "contact_card.required": "Enter WhatsApp contact.",
"contact_card.api_unavailable": "Contact API unavailable.", "contact_card.api_unavailable": "Contact API unavailable.",
"contact_card.saved": "Saved.", "contact_card.saved": "Saved.",
"contact_card.save_failed": "Save failed." "contact_card.save_failed": "Save failed.",
"manager_center.title": "Manage Center",
"manager_center.back": "Back",
"manager_center.change_language": "Change language",
"manager_center.copy_id": "Copy manager ID",
"manager_center.role_manager": "Manager",
"manager_center.this_month": "This Month",
"manager_center.last_month": "Last Month",
"manager_center.team_salary": "Team Salary",
"manager_center.active_hosts": "Active Hosts",
"manager_center.total_active": "Total Active",
"manager_center.growth": "Growth",
"manager_center.vs_last_month": "vs Last Month",
"manager_center.wallet": "Wallet",
"manager_center.bank_cards": "Bank Cards",
"manager_center.admin": "Admin",
"manager_center.bd_leader": "BD Leader",
"manager_center.invite_admin": "Invite Admin",
"manager_center.invite_bd": "Invite BD",
"manager_center.super_admin": "Super Admin",
"manager_center.block_users": "Block Users",
"manager_center.send_frame": "Send Frame",
"manager_center.send_car": "Send Car",
"manager_center.send_badge": "Send Badge",
"manager_center.send_vip": "Send VIP",
"manager_center.user_level": "User Level"
} }

View File

@ -479,6 +479,21 @@
"coinseller_center.loading": "Cargando...", "coinseller_center.loading": "Cargando...",
"coinseller_center.load_failed": "Error al cargar. Inténtalo de nuevo más tarde.", "coinseller_center.load_failed": "Error al cargar. Inténtalo de nuevo más tarde.",
"coinseller_center.transfer_failed": "Transferencia fallida.", "coinseller_center.transfer_failed": "Transferencia fallida.",
"coinseller_center.sub_sellers": "Mis subvendedores de monedas",
"coinseller_center.sub_sellers_desc": "Invita y gestiona subvendedores directos",
"coinseller_center.invite_sub_seller": "Invitar subvendedor",
"coinseller_center.invite_sub_seller_hint": "Ingresa el ID visible del usuario para abrir un subvendedor directo.",
"coinseller_center.invite_now": "Invitar ahora",
"coinseller_center.invite_success": "Invitación enviada.",
"coinseller_center.invite_failed": "Error al invitar.",
"coinseller_center.no_sub_sellers": "Aún no hay subvendedores.",
"coinseller_center.sub_seller_wallet": "Billetera",
"coinseller_center.transfer": "Transferir",
"coinseller_center.transfer_now": "Transferir ahora",
"coinseller_center.transfer_success": "Transferencia exitosa.",
"coinseller_center.transfer_to_sub_seller": "Transferir al subvendedor",
"coinseller_center.transfer_to_sub_seller_hint": "Transfiere monedas a la billetera de vendedor del subvendedor.",
"coinseller_center.select_sub_seller_first": "Selecciona un subvendedor primero.",
"lalu_landing.message": "Te envió un regalo. Ven a jugar conmigo.", "lalu_landing.message": "Te envió un regalo. Ven a jugar conmigo.",
"lalu_landing.frame_duration": "3 días", "lalu_landing.frame_duration": "3 días",
"lalu_landing.join_party": "Unirse a la fiesta", "lalu_landing.join_party": "Unirse a la fiesta",
@ -715,5 +730,30 @@
"contact_card.required": "Ingresa el contacto de WhatsApp.", "contact_card.required": "Ingresa el contacto de WhatsApp.",
"contact_card.api_unavailable": "API de contacto no disponible.", "contact_card.api_unavailable": "API de contacto no disponible.",
"contact_card.saved": "Guardado.", "contact_card.saved": "Guardado.",
"contact_card.save_failed": "No se pudo guardar." "contact_card.save_failed": "No se pudo guardar.",
"manager_center.title": "Centro de gestión",
"manager_center.back": "Atrás",
"manager_center.change_language": "Cambiar idioma",
"manager_center.copy_id": "Copiar ID de manager",
"manager_center.role_manager": "Gerente",
"manager_center.this_month": "Este mes",
"manager_center.last_month": "Mes pasado",
"manager_center.team_salary": "Salario del equipo",
"manager_center.active_hosts": "Anfitriones activos",
"manager_center.total_active": "Total activos",
"manager_center.growth": "Crecimiento",
"manager_center.vs_last_month": "vs mes pasado",
"manager_center.wallet": "Billetera",
"manager_center.bank_cards": "Tarjetas bancarias",
"manager_center.admin": "Admin",
"manager_center.bd_leader": "BD Leader",
"manager_center.invite_admin": "Invitar admin",
"manager_center.invite_bd": "Invitar BD",
"manager_center.super_admin": "Súper Admin",
"manager_center.block_users": "Bloquear usuarios",
"manager_center.send_frame": "Enviar marco",
"manager_center.send_car": "Enviar vehículo",
"manager_center.send_badge": "Enviar insignia",
"manager_center.send_vip": "Enviar VIP",
"manager_center.user_level": "Nivel de usuario"
} }

View File

@ -446,6 +446,21 @@
"coinseller_center.loading": "Memuat...", "coinseller_center.loading": "Memuat...",
"coinseller_center.load_failed": "Gagal memuat. Coba lagi nanti.", "coinseller_center.load_failed": "Gagal memuat. Coba lagi nanti.",
"coinseller_center.transfer_failed": "Transfer gagal.", "coinseller_center.transfer_failed": "Transfer gagal.",
"coinseller_center.sub_sellers": "Sub penjual koin saya",
"coinseller_center.sub_sellers_desc": "Undang dan kelola sub penjual koin langsung",
"coinseller_center.invite_sub_seller": "Undang sub penjual koin",
"coinseller_center.invite_sub_seller_hint": "Masukkan ID tampilan pengguna untuk membuka sub penjual koin langsung.",
"coinseller_center.invite_now": "Undang sekarang",
"coinseller_center.invite_success": "Undangan terkirim.",
"coinseller_center.invite_failed": "Undangan gagal.",
"coinseller_center.no_sub_sellers": "Belum ada sub penjual koin.",
"coinseller_center.sub_seller_wallet": "Dompet",
"coinseller_center.transfer": "Transfer",
"coinseller_center.transfer_now": "Transfer sekarang",
"coinseller_center.transfer_success": "Transfer berhasil.",
"coinseller_center.transfer_to_sub_seller": "Transfer ke sub penjual",
"coinseller_center.transfer_to_sub_seller_hint": "Transfer koin ke dompet penjual koin milik sub penjual.",
"coinseller_center.select_sub_seller_first": "Pilih sub penjual koin terlebih dahulu.",
"lalu_landing.message": "Mengirim hadiah untukmu. Ayo main bersama.", "lalu_landing.message": "Mengirim hadiah untukmu. Ayo main bersama.",
"lalu_landing.frame_duration": "3 hari", "lalu_landing.frame_duration": "3 hari",
"lalu_landing.join_party": "Gabung party", "lalu_landing.join_party": "Gabung party",
@ -643,5 +658,30 @@
"contact_card.required": "Masukkan kontak WhatsApp.", "contact_card.required": "Masukkan kontak WhatsApp.",
"contact_card.api_unavailable": "API kontak tidak tersedia.", "contact_card.api_unavailable": "API kontak tidak tersedia.",
"contact_card.saved": "Tersimpan.", "contact_card.saved": "Tersimpan.",
"contact_card.save_failed": "Gagal menyimpan." "contact_card.save_failed": "Gagal menyimpan.",
"manager_center.title": "Pusat Manajemen",
"manager_center.back": "Kembali",
"manager_center.change_language": "Ubah bahasa",
"manager_center.copy_id": "Salin ID manager",
"manager_center.role_manager": "Manajer",
"manager_center.this_month": "Bulan Ini",
"manager_center.last_month": "Bulan Lalu",
"manager_center.team_salary": "Gaji Tim",
"manager_center.active_hosts": "Host Aktif",
"manager_center.total_active": "Total Aktif",
"manager_center.growth": "Pertumbuhan",
"manager_center.vs_last_month": "vs Bulan Lalu",
"manager_center.wallet": "Dompet",
"manager_center.bank_cards": "Kartu Bank",
"manager_center.admin": "Admin",
"manager_center.bd_leader": "BD Leader",
"manager_center.invite_admin": "Undang Admin",
"manager_center.invite_bd": "Undang BD",
"manager_center.super_admin": "Super Admin",
"manager_center.block_users": "Blokir Pengguna",
"manager_center.send_frame": "Kirim Bingkai",
"manager_center.send_car": "Kirim Kendaraan",
"manager_center.send_badge": "Kirim Lencana",
"manager_center.send_vip": "Kirim VIP",
"manager_center.user_level": "Level Pengguna"
} }

View File

@ -446,6 +446,21 @@
"coinseller_center.loading": "Yükleniyor...", "coinseller_center.loading": "Yükleniyor...",
"coinseller_center.load_failed": "Yükleme başarısız. Daha sonra tekrar deneyin.", "coinseller_center.load_failed": "Yükleme başarısız. Daha sonra tekrar deneyin.",
"coinseller_center.transfer_failed": "Transfer başarısız.", "coinseller_center.transfer_failed": "Transfer başarısız.",
"coinseller_center.sub_sellers": "Alt coin sellerlarım",
"coinseller_center.sub_sellers_desc": "Doğrudan alt coin seller davet edin ve yönetin",
"coinseller_center.invite_sub_seller": "Alt coin seller davet et",
"coinseller_center.invite_sub_seller_hint": "Doğrudan alt coin seller açmak için kullanıcının görünen ID'sini girin.",
"coinseller_center.invite_now": "Şimdi davet et",
"coinseller_center.invite_success": "Davet gönderildi.",
"coinseller_center.invite_failed": "Davet başarısız.",
"coinseller_center.no_sub_sellers": "Henüz alt coin seller yok.",
"coinseller_center.sub_seller_wallet": "Cüzdan",
"coinseller_center.transfer": "Transfer",
"coinseller_center.transfer_now": "Şimdi transfer et",
"coinseller_center.transfer_success": "Transfer başarılı.",
"coinseller_center.transfer_to_sub_seller": "Alt sellera transfer",
"coinseller_center.transfer_to_sub_seller_hint": "Coinleri alt sellerın coin-seller cüzdanına aktarın.",
"coinseller_center.select_sub_seller_first": "Önce bir alt coin seller seçin.",
"lalu_landing.message": "Sana bir hediye gönderdi. Gel birlikte oynayalım.", "lalu_landing.message": "Sana bir hediye gönderdi. Gel birlikte oynayalım.",
"lalu_landing.frame_duration": "3 gün", "lalu_landing.frame_duration": "3 gün",
"lalu_landing.join_party": "Partiye katıl", "lalu_landing.join_party": "Partiye katıl",
@ -682,5 +697,30 @@
"contact_card.required": "WhatsApp iletişimini gir.", "contact_card.required": "WhatsApp iletişimini gir.",
"contact_card.api_unavailable": "İletişim API kullanılamıyor.", "contact_card.api_unavailable": "İletişim API kullanılamıyor.",
"contact_card.saved": "Kaydedildi.", "contact_card.saved": "Kaydedildi.",
"contact_card.save_failed": "Kaydetme başarısız." "contact_card.save_failed": "Kaydetme başarısız.",
"manager_center.title": "Yönetim Merkezi",
"manager_center.back": "Geri",
"manager_center.change_language": "Dili değiştir",
"manager_center.copy_id": "Manager ID kopyala",
"manager_center.role_manager": "Yönetici",
"manager_center.this_month": "Bu Ay",
"manager_center.last_month": "Geçen Ay",
"manager_center.team_salary": "Ekip Maaşı",
"manager_center.active_hosts": "Aktif Yayıncılar",
"manager_center.total_active": "Toplam Aktif",
"manager_center.growth": "Büyüme",
"manager_center.vs_last_month": "Geçen Aya Göre",
"manager_center.wallet": "Cüzdan",
"manager_center.bank_cards": "Banka Kartları",
"manager_center.admin": "Admin",
"manager_center.bd_leader": "BD Leader",
"manager_center.invite_admin": "Admin Davet Et",
"manager_center.invite_bd": "BD Davet Et",
"manager_center.super_admin": "Süper Admin",
"manager_center.block_users": "Kullanıcıları Engelle",
"manager_center.send_frame": "Çerçeve Gönder",
"manager_center.send_car": "Araç Gönder",
"manager_center.send_badge": "Rozet Gönder",
"manager_center.send_vip": "VIP Gönder",
"manager_center.user_level": "Kullanıcı Seviyesi"
} }

View File

@ -452,6 +452,21 @@
"coinseller_center.loading": "加载中...", "coinseller_center.loading": "加载中...",
"coinseller_center.load_failed": "加载失败,请稍后重试。", "coinseller_center.load_failed": "加载失败,请稍后重试。",
"coinseller_center.transfer_failed": "转账失败。", "coinseller_center.transfer_failed": "转账失败。",
"coinseller_center.sub_sellers": "我的子币商",
"coinseller_center.sub_sellers_desc": "邀请并管理直开子币商",
"coinseller_center.invite_sub_seller": "邀请子币商",
"coinseller_center.invite_sub_seller_hint": "输入用户展示 ID直开为你的子币商。",
"coinseller_center.invite_now": "立即邀请",
"coinseller_center.invite_success": "邀请已提交。",
"coinseller_center.invite_failed": "邀请失败。",
"coinseller_center.no_sub_sellers": "暂无子币商。",
"coinseller_center.sub_seller_wallet": "钱包",
"coinseller_center.transfer": "转账",
"coinseller_center.transfer_now": "立即转账",
"coinseller_center.transfer_success": "转账成功。",
"coinseller_center.transfer_to_sub_seller": "转账给子币商",
"coinseller_center.transfer_to_sub_seller_hint": "将金币转入子币商的币商钱包。",
"coinseller_center.select_sub_seller_first": "请先选择子币商。",
"lalu_landing.message": "送你一份礼物,快来和我一起玩吧!", "lalu_landing.message": "送你一份礼物,快来和我一起玩吧!",
"lalu_landing.frame_duration": "3天", "lalu_landing.frame_duration": "3天",
"lalu_landing.join_party": "加入派对", "lalu_landing.join_party": "加入派对",
@ -649,5 +664,30 @@
"contact_card.required": "请输入 WhatsApp 联系方式。", "contact_card.required": "请输入 WhatsApp 联系方式。",
"contact_card.api_unavailable": "联系方式接口不可用。", "contact_card.api_unavailable": "联系方式接口不可用。",
"contact_card.saved": "已保存。", "contact_card.saved": "已保存。",
"contact_card.save_failed": "保存失败。" "contact_card.save_failed": "保存失败。",
"manager_center.title": "管理中心",
"manager_center.back": "返回",
"manager_center.change_language": "切换语言",
"manager_center.copy_id": "复制经理 ID",
"manager_center.role_manager": "经理",
"manager_center.this_month": "本月",
"manager_center.last_month": "上月",
"manager_center.team_salary": "团队工资",
"manager_center.active_hosts": "活跃主播",
"manager_center.total_active": "累计活跃",
"manager_center.growth": "增长",
"manager_center.vs_last_month": "环比上月",
"manager_center.wallet": "钱包",
"manager_center.bank_cards": "银行卡",
"manager_center.admin": "管理员",
"manager_center.bd_leader": "BD Leader",
"manager_center.invite_admin": "邀请管理员",
"manager_center.invite_bd": "邀请 BD",
"manager_center.super_admin": "超级管理员",
"manager_center.block_users": "封禁用户",
"manager_center.send_frame": "赠送头像框",
"manager_center.send_car": "赠送座驾",
"manager_center.send_badge": "赠送徽章",
"manager_center.send_vip": "赠送 VIP",
"manager_center.user_level": "用户等级"
} }

136
common/skeleton.js Normal file
View File

@ -0,0 +1,136 @@
(function () {
// 页面级骨架屏:加载期整页覆盖一层模拟页面结构的占位层,数据就绪后整层淡出。
// 样式在 common/theme.css 的 .hy-page-skeleton 系列类,流光块复用 .hy-skeleton主题色自动跟随 App。
// 布局通过 section 描述数组配置,默认布局对齐各中心页的“资料卡 + 卡片栅格 + 操作宫格”结构;
// 结构差异大的页面调用 show({ sections: [...] }) 自定义。
var SKELETON_ID = 'hy-page-skeleton';
var LEAVE_MS = 200;
var leaveTimer = 0;
var DEFAULT_SECTIONS = [
// 顶栏:返回按钮 + 标题 + 右侧按钮
{ type: 'header' },
// 资料卡:圆头像 + 两行文本
{ type: 'profile' },
// KPI 卡片 2x2圆图标 + 两条文本
{ type: 'grid', count: 4, columns: 2, lines: 2, height: 128 },
// 资产行 2x1
{ type: 'grid', count: 2, columns: 2, lines: 1, height: 78 },
// 操作宫格 3x3居中圆图标 + 短文本
{ type: 'grid', count: 9, columns: 3, lines: 1, height: 112, center: true },
];
function block(circle) {
var node = document.createElement('div');
node.className = circle ? 'hy-skeleton hy-skeleton--circle' : 'hy-skeleton';
return node;
}
function buildSection(section) {
if (section.type === 'header') {
var header = document.createElement('div');
header.className = 'hy-page-skeleton__header';
header.appendChild(block(true));
header.appendChild(block(false));
header.appendChild(block(true));
return header;
}
if (section.type === 'profile') {
var card = document.createElement('div');
card.className = 'hy-page-skeleton__card hy-page-skeleton__profile';
var avatar = block(true);
avatar.style.width = '76px';
avatar.style.height = '76px';
card.appendChild(avatar);
var lines = document.createElement('div');
lines.className = 'hy-page-skeleton__lines';
lines.appendChild(block(false));
lines.appendChild(block(false));
card.appendChild(lines);
return card;
}
// grid等宽单元格每格圆图标 + N 条文本
var grid = document.createElement('div');
grid.className = 'hy-page-skeleton__grid';
grid.style.gridTemplateColumns =
'repeat(' + (section.columns || 2) + ', minmax(0, 1fr))';
for (var i = 0; i < (section.count || 2); i += 1) {
var cell = document.createElement('div');
cell.className =
'hy-page-skeleton__cell' +
(section.center ? ' hy-page-skeleton__cell--center' : '');
if (section.height) cell.style.minHeight = section.height + 'px';
cell.appendChild(block(true));
for (var line = 0; line < (section.lines || 1); line += 1) {
var bar = block(false);
bar.style.width = line === 0 ? '72%' : '48%';
cell.appendChild(bar);
}
grid.appendChild(cell);
}
return grid;
}
function ensureNode(sections) {
var node = document.getElementById(SKELETON_ID);
if (node) return node;
node = document.createElement('div');
node.id = SKELETON_ID;
node.className = 'hy-page-skeleton';
node.setAttribute('aria-hidden', 'true');
for (var i = 0; i < sections.length; i += 1) {
node.appendChild(buildSection(sections[i]));
}
// 骨架结构之上居中叠加拉鲁猫 loading 图 + 转圈(视觉与 .hy-loading 全屏遮罩同源)。
var mascotWrap = document.createElement('div');
mascotWrap.className = 'hy-page-skeleton__mascot-wrap';
var mascot = document.createElement('div');
mascot.className = 'hy-loading__mascot';
var spinner = document.createElement('div');
spinner.className = 'hy-loading__spinner';
mascotWrap.appendChild(mascot);
mascotWrap.appendChild(spinner);
node.appendChild(mascotWrap);
document.body.appendChild(node);
return node;
}
function show(options) {
if (!document.body) {
document.addEventListener(
'DOMContentLoaded',
function () {
show(options);
},
{ once: true }
);
return;
}
window.clearTimeout(leaveTimer);
var node = ensureNode((options && options.sections) || DEFAULT_SECTIONS);
node.classList.remove('is-leaving');
node.classList.add('is-active');
}
function hide() {
var node = document.getElementById(SKELETON_ID);
if (!node || !node.classList.contains('is-active')) return;
// 淡出后再摘除激活态,避免数据渲染瞬间硬切;重复 hide 幂等。
node.classList.add('is-leaving');
window.clearTimeout(leaveTimer);
leaveTimer = window.setTimeout(function () {
node.classList.remove('is-active', 'is-leaving');
}, LEAVE_MS);
}
function isVisible() {
var node = document.getElementById(SKELETON_ID);
return !!node && node.classList.contains('is-active');
}
window.HyAppSkeleton = {
show: show,
hide: hide,
isVisible: isVisible,
};
})();

View File

@ -170,3 +170,165 @@
animation: none; animation: none;
} }
} }
/* 通用骨架屏加载中给文本/块级占位元素加 .hy-skeleton 变成流光占位条数据到达后移除该类恢复真实内容
底色取主题软色变量lalu/yumi/aslan 三套主题自动跟随圆形占位头像等叠加 .hy-skeleton--circle */
.hy-skeleton {
position: relative;
display: inline-block;
overflow: hidden;
min-width: 3.2em;
min-height: 1em;
border-radius: var(--hy-skeleton-radius, 6px);
background: linear-gradient(
90deg,
var(--hy-theme-primary-soft) 25%,
var(--hy-theme-line) 37%,
var(--hy-theme-primary-soft) 63%
);
background-size: 400% 100%;
color: transparent !important;
animation: hy-skeleton-shimmer 1.4s ease infinite;
pointer-events: none;
user-select: none;
}
.hy-skeleton--circle {
--hy-skeleton-radius: 50%;
}
/* 占位期间隐藏子节点(图标等),避免真实内容在流光条上透出。 */
.hy-skeleton > * {
visibility: hidden;
}
@keyframes hy-skeleton-shimmer {
0% {
background-position: 100% 50%;
}
100% {
background-position: 0 50%;
}
}
@media (prefers-reduced-motion: reduce) {
.hy-skeleton {
animation: none;
}
}
/* 页面级骨架屏common/skeleton.js整页覆盖一层模拟页面结构的占位层
数据就绪后整层淡出移除块级元素复用上面的 .hy-skeleton 流光条 */
.hy-page-skeleton {
position: fixed;
inset: 0;
z-index: 80;
display: none;
overflow: hidden;
padding: calc(14px + env(safe-area-inset-top)) 16px 30px;
background: var(--hy-theme-bg);
opacity: 1;
transition: opacity 0.2s ease;
}
.hy-page-skeleton.is-active {
display: block;
}
.hy-page-skeleton.is-leaving {
opacity: 0;
pointer-events: none;
}
/* 骨架层中央叠加拉鲁猫 loading 图 + 转圈,视觉复用 .hy-loading__mascot / .hy-loading__spinner。 */
.hy-page-skeleton__mascot-wrap {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 10px;
pointer-events: none;
}
.hy-page-skeleton__header {
display: grid;
grid-template-columns: 36px minmax(0, 1fr) 36px;
align-items: center;
justify-items: center;
height: 58px;
margin-bottom: 10px;
}
.hy-page-skeleton__header .hy-skeleton:nth-child(2) {
width: 42%;
height: 20px;
}
.hy-page-skeleton__card {
margin-bottom: 12px;
padding: 14px;
border: 1px solid var(--hy-theme-line);
border-radius: var(--hy-theme-radius-card);
background: var(--hy-theme-surface);
}
.hy-page-skeleton__profile {
display: grid;
grid-template-columns: 76px minmax(0, 1fr);
gap: 12px;
align-items: center;
min-height: 112px;
}
.hy-page-skeleton__lines {
display: grid;
gap: 10px;
}
.hy-page-skeleton__lines .hy-skeleton:first-child {
width: 56%;
height: 18px;
}
.hy-page-skeleton__lines .hy-skeleton:last-child {
width: 38%;
height: 13px;
}
.hy-page-skeleton__grid {
display: grid;
gap: 12px;
margin-bottom: 12px;
}
.hy-page-skeleton__cell {
display: grid;
justify-items: start;
gap: 9px;
padding: 16px 13px;
border: 1px solid var(--hy-theme-line);
border-radius: var(--hy-theme-radius-card);
background: var(--hy-theme-surface);
}
.hy-page-skeleton__cell--center {
justify-items: center;
}
.hy-page-skeleton__cell .hy-skeleton {
height: 13px;
}
.hy-page-skeleton__cell .hy-skeleton--circle {
width: 46px;
height: 46px;
min-width: 46px;
}
@media (prefers-reduced-motion: reduce) {
.hy-page-skeleton {
transition: none;
}
}

View File

@ -112,6 +112,38 @@
</div> </div>
</section> </section>
<section
class="card sub-seller-card"
id="subSellerSection"
hidden
>
<div class="section-head">
<div>
<h2 data-i18n="coinseller_center.sub_sellers">
My sub coin sellers
</h2>
<p data-i18n="coinseller_center.sub_sellers_desc">
Invite and manage direct sub coin sellers
</p>
</div>
<button
class="round-action small"
id="inviteSubSellerButton"
type="button"
aria-label="Invite sub coin seller"
data-i18n-aria="coinseller_center.invite_sub_seller"
hidden
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M12 5v14" />
<path d="M5 12h14" />
</svg>
</button>
</div>
<div class="form-status" id="subSellerStatus" hidden></div>
<div class="sub-seller-list" id="subSellerList"></div>
</section>
<section class="card search-card"> <section class="card search-card">
<div class="section-head"> <div class="section-head">
<div> <div>
@ -131,7 +163,9 @@
> >
<svg viewBox="0 0 24 24" aria-hidden="true"> <svg viewBox="0 0 24 24" aria-hidden="true">
<circle cx="12" cy="12" r="9" /> <circle cx="12" cy="12" r="9" />
<path d="M9.5 9a2.7 2.7 0 0 1 5.2.9c0 1.9-2.7 2.2-2.7 4.1" /> <path
d="M9.5 9a2.7 2.7 0 0 1 5.2.9c0 1.9-2.7 2.2-2.7 4.1"
/>
<path d="M12 17h.01" /> <path d="M12 17h.01" />
</svg> </svg>
</button> </button>
@ -152,34 +186,34 @@
> >
Search Search
</button> </button>
</form> </form>
<div class="form-status" id="searchStatus" hidden></div> <div class="form-status" id="searchStatus" hidden></div>
<div class="target-card" id="targetCard" hidden> <div class="target-card" id="targetCard" hidden>
<div class="target-avatar" id="targetAvatar"></div> <div class="target-avatar" id="targetAvatar"></div>
<div class="target-main"> <div class="target-main">
<div <div
class="role-pill" class="role-pill"
data-i18n="coinseller_center.target_user" data-i18n="coinseller_center.target_user"
> >
Target user Target user
</div> </div>
<div class="target-name" id="targetName">-</div> <div class="target-name" id="targetName">-</div>
<div class="target-meta" id="targetUID">UID: -</div> <div class="target-meta" id="targetUID">UID: -</div>
</div> </div>
<button <button
class="target-coin-action" class="target-coin-action"
id="openRechargeButton" id="openRechargeButton"
type="button" type="button"
aria-label="Recharge coins" aria-label="Recharge coins"
data-i18n-aria="coinseller_center.recharge_coins" data-i18n-aria="coinseller_center.recharge_coins"
> >
<span class="coin-icon" aria-hidden="true">$</span> <span class="coin-icon" aria-hidden="true">$</span>
</button> </button>
</div> </div>
</section> </section>
<section class="card recent-card"> <section class="card recent-card">
<div class="section-head"> <div class="section-head">
<div> <div>
<h2 data-i18n="coinseller_center.recent_records"> <h2 data-i18n="coinseller_center.recent_records">
@ -241,7 +275,10 @@
</button> </button>
</div> </div>
<div class="history-body"> <div class="history-body">
<form class="history-search-form" id="historySearchForm"> <form
class="history-search-form"
id="historySearchForm"
>
<input <input
id="historySearchInput" id="historySearchInput"
type="text" type="text"
@ -290,8 +327,15 @@
Expenditure Expenditure
</button> </button>
</div> </div>
<div class="form-status" id="historyStatus" hidden></div> <div
<div class="record-list history-records" id="historyList"></div> class="form-status"
id="historyStatus"
hidden
></div>
<div
class="record-list history-records"
id="historyList"
></div>
<button <button
class="load-more" class="load-more"
id="loadMoreButton" id="loadMoreButton"
@ -382,10 +426,151 @@
<div <div
class="sheet-modal" class="sheet-modal"
id="helpModal" id="inviteSubSellerModal"
hidden hidden
aria-hidden="true" aria-hidden="true"
> >
<button
class="modal-backdrop"
type="button"
data-close-modal="inviteSubSellerModal"
aria-label="Close"
data-i18n-aria="coinseller_center.close"
></button>
<section
class="bottom-sheet invite-sub-seller-sheet"
role="dialog"
aria-modal="true"
aria-labelledby="inviteSubSellerTitle"
>
<div class="sheet-head">
<h2
id="inviteSubSellerTitle"
data-i18n="coinseller_center.invite_sub_seller"
>
Invite sub coin seller
</h2>
<button
class="sheet-close"
type="button"
data-close-modal="inviteSubSellerModal"
aria-label="Close"
data-i18n-aria="coinseller_center.close"
>
&times;
</button>
</div>
<form class="sub-seller-form" id="inviteSubSellerForm">
<p data-i18n="coinseller_center.invite_sub_seller_hint">
Enter the user's display ID to open a direct sub
coin seller.
</p>
<input
id="inviteSubSellerInput"
type="text"
inputmode="text"
autocomplete="off"
placeholder="Enter user ID"
data-i18n-placeholder="coinseller_center.enter_user_id"
/>
<button
id="submitInviteSubSellerButton"
type="submit"
data-i18n="coinseller_center.invite_now"
>
Invite now
</button>
<div
class="form-status"
id="inviteSubSellerStatus"
hidden
></div>
</form>
</section>
</div>
<div
class="sheet-modal"
id="subSellerTransferModal"
hidden
aria-hidden="true"
>
<button
class="modal-backdrop"
type="button"
data-close-modal="subSellerTransferModal"
aria-label="Close"
data-i18n-aria="coinseller_center.close"
></button>
<section
class="bottom-sheet recharge-sheet"
role="dialog"
aria-modal="true"
aria-labelledby="subSellerTransferTitle"
>
<div class="sheet-head">
<h2
id="subSellerTransferTitle"
data-i18n="coinseller_center.transfer_to_sub_seller"
>
Transfer to sub seller
</h2>
<button
class="sheet-close"
type="button"
data-close-modal="subSellerTransferModal"
aria-label="Close"
data-i18n-aria="coinseller_center.close"
>
&times;
</button>
</div>
<div class="recharge-sheet-body">
<div
class="recharge-target"
id="subSellerTransferTargetName"
>
-
</div>
<p
data-i18n="coinseller_center.transfer_to_sub_seller_hint"
>
Transfer coins into the sub seller coin-seller
wallet.
</p>
<form class="recharge-form" id="subSellerTransferForm">
<label class="coin-input-shell">
<span class="input-coin" aria-hidden="true"
>$</span
>
<input
id="subSellerTransferAmountInput"
type="text"
inputmode="numeric"
autocomplete="off"
placeholder="Enter coins amount"
data-i18n-placeholder="coinseller_center.coin_amount_placeholder"
/>
</label>
<button
id="submitSubSellerTransferButton"
type="submit"
disabled
data-i18n="coinseller_center.transfer_now"
>
Transfer now
</button>
</form>
<div
class="form-status"
id="subSellerTransferStatus"
hidden
></div>
</div>
</section>
</div>
<div class="sheet-modal" id="helpModal" hidden aria-hidden="true">
<button <button
class="modal-backdrop" class="modal-backdrop"
type="button" type="button"
@ -419,13 +604,15 @@
How to recharge? How to recharge?
</p> </p>
<span data-i18n="coinseller_center.faq_answer_1"> <span data-i18n="coinseller_center.faq_answer_1">
Search a user, enter the coin amount, then confirm recharge. Search a user, enter the coin amount, then confirm
recharge.
</span> </span>
<p data-i18n="coinseller_center.faq_question_2"> <p data-i18n="coinseller_center.faq_question_2">
How to check records? How to check records?
</p> </p>
<span data-i18n="coinseller_center.faq_answer_2"> <span data-i18n="coinseller_center.faq_answer_2">
Open history and switch between income and expenditure. Open history and switch between income and
expenditure.
</span> </span>
</div> </div>
</section> </section>
@ -434,9 +621,9 @@
<script src="../../common/toast.js"></script> <script src="../../common/toast.js"></script>
<script src="../../common/jsbridge.js"></script> <script src="../../common/jsbridge.js"></script>
<script src="../../common/api.js?v=20260604-coinseller-records"></script> <script src="../../common/api.js?v=20260709-coinseller-sub-sellers"></script>
<script src="../../common/i18n.js?v=20260604-coinseller"></script> <script src="../../common/i18n.js?v=20260604-coinseller"></script>
<script src="../common/contact-card.js?v=20260629-user-contact"></script> <script src="../common/contact-card.js?v=20260629-user-contact"></script>
<script src="./script.js?v=20260604-record-user-profile"></script> <script src="./script.js?v=20260709-sub-sellers"></script>
</body> </body>
</html> </html>

View File

@ -79,6 +79,29 @@
time: '2026-06-03 18:42', time: '2026-06-03 18:42',
}, },
], ],
subSellerPermission: {
visible: true,
canInvite: true,
canTransfer: true,
},
subSellers: [
{
uid: '7001001',
userId: '107001001',
name: 'Sub Seller Sara',
avatar: avatarData('S', '#efe4ff', '#7d57c7'),
status: 'active',
balance: 320000,
},
{
uid: '7001002',
userId: '107001002',
name: 'Sub Seller Omar',
avatar: avatarData('O', '#e7f7f3', '#31a88f'),
status: 'active',
balance: 180000,
},
],
}; };
var data = { var data = {
@ -87,6 +110,8 @@
users: [], users: [],
quickAmounts: mock.quickAmounts.slice(), quickAmounts: mock.quickAmounts.slice(),
records: [], records: [],
subSellerPermission: emptySubSellerPermission(),
subSellers: [],
}; };
var state = { var state = {
@ -99,6 +124,11 @@
loading: query.get('mock') !== '1', loading: query.get('mock') !== '1',
searching: false, searching: false,
submitting: false, submitting: false,
subSellerLoading: false,
invitingSubSeller: false,
subSellerTransferTarget: null,
subSellerTransferAmount: '',
subSellerTransferSubmitting: false,
}; };
function $(id) { function $(id) {
@ -140,6 +170,14 @@
}; };
} }
function emptySubSellerPermission() {
return {
visible: false,
canInvite: false,
canTransfer: false,
};
}
function initial(text) { function initial(text) {
return String(text || '') return String(text || '')
.trim() .trim()
@ -163,6 +201,15 @@
return number > 0 ? number : 0; return number > 0 ? number : 0;
} }
function booleanValue(value) {
if (value === true || value === 1) return true;
if (value === false || value === 0) return false;
var text = String(value || '')
.trim()
.toLowerCase();
return text === 'true' || text === '1' || text === 'yes';
}
function firstValue(source, keys) { function firstValue(source, keys) {
if (!source) return ''; if (!source) return '';
for (var i = 0; i < keys.length; i += 1) { for (var i = 0; i < keys.length; i += 1) {
@ -219,6 +266,14 @@
); );
} }
function hasSubSellerAPI() {
return (
hasAPI() &&
window.HyAppAPI.wallet.getCoinSellerSubSellerPermission &&
window.HyAppAPI.wallet.listCoinSellerSubSellers
);
}
function setImage(image, fallback, src, name) { function setImage(image, fallback, src, name) {
if (!image || !fallback) return; if (!image || !fallback) return;
fallback.textContent = initial(name) || 'C'; fallback.textContent = initial(name) || 'C';
@ -536,12 +591,129 @@
}); });
} }
function normalizeSubSellerPermission(payload) {
var source = unwrapData(payload) || {};
var canInvite = booleanValue(
firstValue(source, [
'can_invite_sub_seller',
'canInviteSubSeller',
'can_open_sub_seller',
'canOpenSubSeller',
'can_invite',
'canInvite',
])
);
var canTransfer = booleanValue(
firstValue(source, [
'can_transfer_sub_seller',
'canTransferSubSeller',
'can_transfer',
'canTransfer',
])
);
var canList = booleanValue(
firstValue(source, [
'can_list_sub_sellers',
'canListSubSellers',
'can_list',
'canList',
])
);
var explicitVisible = firstValue(source, [
'visible',
'enabled',
'allowed',
'has_permission',
'hasPermission',
]);
return {
visible:
explicitVisible === '' || explicitVisible === undefined
? canInvite || canTransfer || canList
: booleanValue(explicitVisible),
canInvite: canInvite,
canTransfer: canTransfer,
};
}
function normalizeSubSeller(item, index) {
var source = item || {};
var displayID =
firstValue(source, [
'display_user_id',
'displayUserID',
'displayUserId',
'display_id',
'displayId',
]) || '';
var userID = firstValue(source, ['user_id', 'userId', 'id']) || '';
var name =
firstValue(source, ['username', 'nickname', 'name']) ||
t('coinseller_center.user_name_template', 'User {id}').replace(
'{id}',
displayID || userID || index + 1
);
return {
uid: displayID || userID,
userId: userID,
name: name,
avatar:
firstValue(source, [
'avatar',
'avatar_url',
'avatarUrl',
'profile_picture_url',
'profilePictureUrl',
]) || '',
status: firstValue(source, ['status']) || '',
balance: toNumber(
firstValue(source, [
'coin_seller_balance',
'coinSellerBalance',
'available_amount',
'availableAmount',
'balance',
'amount',
])
),
region:
firstValue(source, [
'country_display_name',
'countryDisplayName',
'region_name',
'regionName',
'country_name',
'countryName',
]) || '',
};
}
function normalizeSubSellers(payload) {
var source = unwrapData(payload) || {};
var items = Array.isArray(source.items)
? source.items
: Array.isArray(source.sub_sellers)
? source.sub_sellers
: Array.isArray(source.subSellers)
? source.subSellers
: Array.isArray(source.children)
? source.children
: Array.isArray(source)
? source
: [];
return items.map(function (item, index) {
return normalizeSubSeller(item, index);
});
}
function applyMockData() { function applyMockData() {
data.profile = Object.assign({}, mock.profile); data.profile = Object.assign({}, mock.profile);
data.balance = Object.assign({}, mock.balance); data.balance = Object.assign({}, mock.balance);
data.users = mock.users.slice(); data.users = mock.users.slice();
data.quickAmounts = mock.quickAmounts.slice(); data.quickAmounts = mock.quickAmounts.slice();
data.records = mock.records.slice(); data.records = mock.records.slice();
data.subSellerPermission = Object.assign({}, mock.subSellerPermission);
data.subSellers = mock.subSellers.slice();
} }
function applyEmptyData() { function applyEmptyData() {
@ -550,6 +722,51 @@
data.users = []; data.users = [];
data.quickAmounts = mock.quickAmounts.slice(); data.quickAmounts = mock.quickAmounts.slice();
data.records = []; data.records = [];
data.subSellerPermission = emptySubSellerPermission();
data.subSellers = [];
}
function loadSubSellerCapability() {
if (!hasSubSellerAPI()) {
data.subSellerPermission = emptySubSellerPermission();
data.subSellers = [];
return Promise.resolve();
}
state.subSellerLoading = true;
renderSubSellers();
return window.HyAppAPI.wallet
.getCoinSellerSubSellerPermission()
.then(function (response) {
data.subSellerPermission =
normalizeSubSellerPermission(response);
// 子币商列表属于权限增强区;只有服务端明确给到能力时才拉取,避免普通币商看到空功能入口。
if (!data.subSellerPermission.visible) {
data.subSellers = [];
return null;
}
return window.HyAppAPI.wallet
.listCoinSellerSubSellers({
page: 1,
page_size: 50,
})
.then(function (listResponse) {
data.subSellers = normalizeSubSellers(listResponse);
});
})
.catch(function (error) {
if (window.console && window.console.warn) {
window.console.warn(
'[coinseller-center] sub seller api failed',
error
);
}
data.subSellerPermission = emptySubSellerPermission();
data.subSellers = [];
})
.finally(function () {
state.subSellerLoading = false;
renderSubSellers();
});
} }
function loadRealData() { function loadRealData() {
@ -584,6 +801,7 @@
return null; return null;
}) })
: Promise.resolve(null), : Promise.resolve(null),
loadSubSellerCapability(),
]) ])
.then(function (results) { .then(function (results) {
data.profile = normalizeProfile(results[0]); data.profile = normalizeProfile(results[0]);
@ -647,6 +865,87 @@
} }
} }
function createSubSellerRow(item) {
var row = document.createElement('article');
var avatar = document.createElement('div');
var main = document.createElement('div');
var title = document.createElement('div');
var meta = document.createElement('div');
var balance = document.createElement('div');
var action = document.createElement('button');
row.className = 'sub-seller-row';
avatar.className = 'record-avatar';
main.className = 'sub-seller-main';
title.className = 'record-title';
meta.className = 'record-meta';
balance.className = 'sub-seller-balance';
action.className = 'sub-seller-transfer';
action.type = 'button';
action.disabled =
!data.subSellerPermission.canTransfer ||
state.subSellerTransferSubmitting;
action.textContent = t('coinseller_center.transfer', 'Transfer');
renderAvatar(avatar, item);
title.textContent = item.name || '-';
meta.textContent = t(
'coinseller_center.uid_prefix',
'UID: {id}'
).replace('{id}', item.uid || '-');
balance.textContent =
t('coinseller_center.sub_seller_wallet', 'Wallet') +
': ' +
count(item.balance) +
' ' +
t('coinseller_center.coins_lower', 'coins');
action.addEventListener('click', function () {
openSubSellerTransfer(item);
});
main.appendChild(title);
main.appendChild(meta);
if (item.balance > 0) main.appendChild(balance);
row.appendChild(avatar);
row.appendChild(main);
if (data.subSellerPermission.canTransfer) row.appendChild(action);
return row;
}
function renderSubSellers() {
var section = $('subSellerSection');
if (!section) return;
var permission = data.subSellerPermission || emptySubSellerPermission();
section.hidden = !permission.visible;
$('inviteSubSellerButton').hidden = !permission.canInvite;
if (!permission.visible) return;
if (state.subSellerLoading) {
setStatus(
$('subSellerStatus'),
t('coinseller_center.loading', 'Loading...'),
''
);
} else {
setStatus(
$('subSellerStatus'),
data.subSellers.length
? ''
: t(
'coinseller_center.no_sub_sellers',
'No sub coin sellers yet.'
),
data.subSellers.length ? '' : 'warning'
);
}
replaceChildren(
$('subSellerList'),
data.subSellers.map(function (item) {
return createSubSellerRow(item);
})
);
}
function renderQuickAmounts() { function renderQuickAmounts() {
replaceChildren( replaceChildren(
$('quickAmounts'), $('quickAmounts'),
@ -917,6 +1216,15 @@
].join('-'); ].join('-');
} }
function subSellerCommandID(action) {
return [
'coin-seller-sub-seller',
action || 'action',
Date.now(),
Math.random().toString(36).slice(2, 8),
].join('-');
}
function buildTransferPayload(amount) { function buildTransferPayload(amount) {
return { return {
command_id: commandID(), command_id: commandID(),
@ -947,6 +1255,304 @@
}); });
} }
function appendSubSellerTransferRecord(amount, response) {
var target = state.subSellerTransferTarget;
if (!target) return;
var payload = unwrapData(response) || {};
var coins = toNumber(
firstValue(payload, ['amount', 'coin_amount', 'coinAmount'])
);
data.records.unshift({
id:
firstValue(payload, ['transaction_id', 'transactionId']) ||
'sub-seller-tx-' + Date.now(),
type: 'expenditure',
name: target.name,
uid: target.uid,
avatar: target.avatar,
coins: coins || amount,
amount: 0,
currency: '',
time: currentTimeLabel(),
});
}
function refreshSubSellerFromResponse(target, payload, amount) {
if (!target) return;
var balanceAfter = firstValue(payload, [
'sub_seller_balance_after',
'subSellerBalanceAfter',
'child_balance_after',
'childBalanceAfter',
'target_balance_after',
'targetBalanceAfter',
]);
data.subSellers = data.subSellers.map(function (item) {
if (String(item.uid) !== String(target.uid)) return item;
return Object.assign({}, item, {
balance:
balanceAfter !== '' && balanceAfter !== undefined
? toNumber(balanceAfter)
: item.balance + amount,
});
});
}
function inviteSubSeller() {
var input = $('inviteSubSellerInput');
var displayID = String(input.value || '').trim();
if (!displayID) {
setStatus(
$('inviteSubSellerStatus'),
t('coinseller_center.enter_user_id', 'Enter user ID'),
'warning'
);
return;
}
if (state.isMock) {
data.subSellers.unshift({
uid: displayID,
userId: displayID,
name: t(
'coinseller_center.user_name_template',
'User {id}'
).replace('{id}', displayID),
avatar: avatarData(displayID, '#efe4ff', '#7d57c7'),
status: 'active',
balance: 0,
});
input.value = '';
renderSubSellers();
closeModal('inviteSubSellerModal');
toast(
t('coinseller_center.invite_success', 'Invitation submitted.')
);
return;
}
if (!hasAPI() || !window.HyAppAPI.wallet.inviteCoinSellerSubSeller) {
setStatus(
$('inviteSubSellerStatus'),
t('coinseller_center.invite_failed', 'Invite failed.'),
'warning'
);
return;
}
state.invitingSubSeller = true;
$('submitInviteSubSellerButton').disabled = true;
setStatus(
$('inviteSubSellerStatus'),
t('coinseller_center.loading', 'Loading...'),
''
);
// 直开子币商是有状态写操作,必须使用 command_id 让移动端重试或重复点击时由服务端保持幂等。
window.HyAppAPI.wallet
.inviteCoinSellerSubSeller({
command_id: subSellerCommandID('invite'),
target_display_user_id: displayID,
})
.then(function (response) {
var payload = unwrapData(response) || {};
var seller = normalizeSubSeller(
payload.child ||
payload.sub_seller ||
payload.subSeller ||
payload,
data.subSellers.length
);
if (seller.uid) {
data.subSellers = data.subSellers.filter(function (item) {
return String(item.uid) !== String(seller.uid);
});
data.subSellers.unshift(seller);
}
input.value = '';
setStatus(
$('inviteSubSellerStatus'),
t(
'coinseller_center.invite_success',
'Invitation submitted.'
),
'success'
);
renderSubSellers();
toast(
t(
'coinseller_center.invite_success',
'Invitation submitted.'
)
);
closeModal('inviteSubSellerModal');
})
.catch(function (error) {
if (window.console && window.console.warn) {
window.console.warn(
'[coinseller-center] invite sub seller failed',
error
);
}
setStatus(
$('inviteSubSellerStatus'),
error && error.message
? error.message
: t(
'coinseller_center.invite_failed',
'Invite failed.'
),
'warning'
);
})
.finally(function () {
state.invitingSubSeller = false;
$('submitInviteSubSellerButton').disabled = false;
});
}
function openSubSellerTransfer(item) {
state.subSellerTransferTarget = item;
state.subSellerTransferAmount = '';
$('subSellerTransferAmountInput').value = '';
$('subSellerTransferTargetName').textContent =
t(
'coinseller_center.transfer_to_sub_seller',
'Transfer to sub seller'
) +
': ' +
item.name;
setStatus($('subSellerTransferStatus'), '', '');
renderSubSellerTransferState();
openModal('subSellerTransferModal');
window.setTimeout(function () {
$('subSellerTransferAmountInput').focus();
}, 80);
}
function parsedSubSellerTransferAmount() {
var input = $('subSellerTransferAmountInput');
if (input) {
var sanitized = String(input.value || '').replace(/[^\d]/g, '');
if (input.value !== sanitized) input.value = sanitized;
state.subSellerTransferAmount = sanitized;
}
return Number(state.subSellerTransferAmount || 0);
}
function renderSubSellerTransferState() {
var amount = parsedSubSellerTransferAmount();
$('submitSubSellerTransferButton').disabled =
state.loading ||
state.subSellerTransferSubmitting ||
!state.subSellerTransferTarget ||
amount <= 0;
}
function transferToSubSeller() {
var amount = parsedSubSellerTransferAmount();
var target = state.subSellerTransferTarget;
if (!target) {
setStatus(
$('subSellerTransferStatus'),
t(
'coinseller_center.select_sub_seller_first',
'Select a sub coin seller first.'
),
'warning'
);
return;
}
if (amount <= 0) {
setStatus(
$('subSellerTransferStatus'),
t(
'coinseller_center.valid_amount_required',
'Please enter a valid amount.'
),
'warning'
);
return;
}
if (amount > data.balance.coins) {
setStatus(
$('subSellerTransferStatus'),
t(
'coinseller_center.amount_exceeds_stock',
'Amount exceeds stock.'
),
'warning'
);
return;
}
if (state.isMock) {
data.balance.coins -= amount;
refreshSubSellerFromResponse(target, {}, amount);
appendSubSellerTransferRecord(amount, {});
finishSubSellerTransfer();
return;
}
if (
!hasAPI() ||
!window.HyAppAPI.wallet.transferCoinSellerToSubSeller
) {
setStatus($('subSellerTransferStatus'), '', '');
toast(t('coinseller_center.transfer_failed', 'Transfer failed.'));
return;
}
state.subSellerTransferSubmitting = true;
setStatus(
$('subSellerTransferStatus'),
t('coinseller_center.loading', 'Loading...'),
''
);
renderSubSellerTransferState();
// 子币商转账目标是对方的币商专用钱包不是玩家普通金币payload 单独命名,避免误走老的玩家充值接口。
window.HyAppAPI.wallet
.transferCoinSellerToSubSeller({
command_id: subSellerCommandID('transfer'),
target_display_user_id: String(target.uid || '').trim(),
amount: amount,
reason: 'sub coin seller stock transfer',
})
.then(function (response) {
var payload = unwrapData(response) || {};
var balanceAfter = firstValue(payload, [
'parent_balance_after',
'parentBalanceAfter',
'seller_balance_after',
'sellerBalanceAfter',
]);
data.balance.coins =
balanceAfter !== '' && balanceAfter !== undefined
? toNumber(balanceAfter)
: data.balance.coins - amount;
refreshSubSellerFromResponse(target, payload, amount);
appendSubSellerTransferRecord(amount, response);
finishSubSellerTransfer();
})
.catch(function (error) {
if (window.console && window.console.warn) {
window.console.warn(
'[coinseller-center] sub seller transfer failed',
error
);
}
setStatus($('subSellerTransferStatus'), '', '');
toast(
error && error.message
? error.message
: t(
'coinseller_center.transfer_failed',
'Transfer failed.'
)
);
})
.finally(function () {
state.subSellerTransferSubmitting = false;
renderSubSellerTransferState();
renderSubSellers();
});
}
function rechargeNow() { function rechargeNow() {
var amount = parsedCoinAmount(); var amount = parsedCoinAmount();
if (!state.target) { if (!state.target) {
@ -1058,6 +1664,23 @@
toast(t('coinseller_center.recharge_success', 'Recharge success.')); toast(t('coinseller_center.recharge_success', 'Recharge success.'));
} }
function finishSubSellerTransfer() {
state.subSellerTransferAmount = '';
$('subSellerTransferAmountInput').value = '';
setStatus(
$('subSellerTransferStatus'),
t('coinseller_center.transfer_success', 'Transfer success.'),
'success'
);
renderBalance();
renderSubSellers();
renderRecent();
renderSubSellerTransferState();
if (!$('historyModal').hidden) renderHistory();
closeModal('subSellerTransferModal');
toast(t('coinseller_center.transfer_success', 'Transfer success.'));
}
function currentTimeLabel() { function currentTimeLabel() {
var now = new Date(); var now = new Date();
return ( return (
@ -1112,6 +1735,14 @@
$('helpButton').addEventListener('click', function () { $('helpButton').addEventListener('click', function () {
openModal('helpModal'); openModal('helpModal');
}); });
$('inviteSubSellerButton').addEventListener('click', function () {
$('inviteSubSellerInput').value = '';
setStatus($('inviteSubSellerStatus'), '', '');
openModal('inviteSubSellerModal');
window.setTimeout(function () {
$('inviteSubSellerInput').focus();
}, 80);
});
document document
.querySelectorAll('[data-close-modal]') .querySelectorAll('[data-close-modal]')
.forEach(function (node) { .forEach(function (node) {
@ -1153,6 +1784,26 @@
event.preventDefault(); event.preventDefault();
rechargeNow(); rechargeNow();
}); });
$('inviteSubSellerForm').addEventListener('submit', function (event) {
event.preventDefault();
inviteSubSeller();
});
$('subSellerTransferAmountInput').addEventListener(
'input',
function () {
state.subSellerTransferAmount = $(
'subSellerTransferAmountInput'
).value.replace(/[^\d]/g, '');
$('subSellerTransferAmountInput').value =
state.subSellerTransferAmount;
setStatus($('subSellerTransferStatus'), '', '');
renderSubSellerTransferState();
}
);
$('subSellerTransferForm').addEventListener('submit', function (event) {
event.preventDefault();
transferToSubSeller();
});
document.querySelectorAll('[data-history-tab]').forEach(function (tab) { document.querySelectorAll('[data-history-tab]').forEach(function (tab) {
tab.addEventListener('click', function () { tab.addEventListener('click', function () {
state.historyTab = tab.getAttribute('data-history-tab'); state.historyTab = tab.getAttribute('data-history-tab');
@ -1180,6 +1831,8 @@
if (event.key !== 'Escape') return; if (event.key !== 'Escape') return;
closeModal('historyModal'); closeModal('historyModal');
closeModal('helpModal'); closeModal('helpModal');
closeModal('inviteSubSellerModal');
closeModal('subSellerTransferModal');
}); });
} }
@ -1189,6 +1842,8 @@
renderQuickAmounts(); renderQuickAmounts();
renderTarget(); renderTarget();
renderRechargeState(); renderRechargeState();
renderSubSellers();
renderSubSellerTransferState();
renderRecent(); renderRecent();
} }

View File

@ -15,6 +15,7 @@
} }
.balance-card, .balance-card,
.sub-seller-card,
.search-card, .search-card,
.recent-card { .recent-card {
padding: 16px; padding: 16px;
@ -118,6 +119,7 @@
} }
.search-card, .search-card,
.sub-seller-card,
.recent-card { .recent-card {
display: grid; display: grid;
gap: 14px; gap: 14px;
@ -205,6 +207,10 @@
display: none; display: none;
} }
.sub-seller-card[hidden] {
display: none;
}
.target-avatar, .target-avatar,
.record-avatar { .record-avatar {
display: flex; display: flex;
@ -373,6 +379,54 @@
gap: 10px; gap: 10px;
} }
.sub-seller-list {
display: grid;
gap: 10px;
}
.sub-seller-row {
display: grid;
grid-template-columns: 42px minmax(0, 1fr) auto;
gap: 10px;
align-items: center;
min-width: 0;
padding: 12px;
border: 1px solid var(--line);
border-radius: 8px;
background: #fbfcfc;
}
.sub-seller-main {
min-width: 0;
}
.sub-seller-balance {
margin-top: 5px;
overflow: hidden;
color: var(--primary-deep);
font-size: 12px;
font-weight: 900;
line-height: 1.2;
text-overflow: ellipsis;
white-space: nowrap;
}
.sub-seller-transfer {
min-height: 38px;
padding: 0 12px;
border-radius: 8px;
background: var(--button);
color: #fff;
font-size: 13px;
font-weight: 900;
white-space: nowrap;
}
.sub-seller-transfer:disabled {
background: var(--button-disabled);
color: var(--muted);
}
.record-row { .record-row {
display: grid; display: grid;
grid-template-columns: 42px minmax(0, 1fr) auto; grid-template-columns: 42px minmax(0, 1fr) auto;
@ -515,6 +569,52 @@
padding: 16px 18px 22px; padding: 16px 18px 22px;
} }
.sub-seller-form {
display: grid;
gap: 12px;
padding: 16px 18px calc(18px + env(safe-area-inset-bottom));
}
.sub-seller-form p {
margin: 0;
color: var(--muted);
font-size: 13px;
font-weight: 850;
line-height: 1.35;
}
.sub-seller-form input {
width: 100%;
min-width: 0;
height: 52px;
padding: 0 14px;
border: 1px solid var(--line);
border-radius: 8px;
background: rgba(251, 249, 255, 0.9);
color: var(--text);
font-size: 15px;
font-weight: 800;
}
.sub-seller-form input::placeholder {
color: #b5b0bd;
}
.sub-seller-form button {
width: 100%;
min-height: 56px;
border-radius: 28px;
background: var(--button);
color: #fff;
font-size: 15px;
font-weight: 900;
}
.sub-seller-form button:disabled {
background: var(--button-disabled);
color: var(--muted);
}
.help-content h3, .help-content h3,
.help-content p { .help-content p {
margin: 0; margin: 0;
@ -565,4 +665,13 @@ body.modal-open .coinseller-center {
.history-search-form { .history-search-form {
grid-template-columns: minmax(0, 1fr); grid-template-columns: minmax(0, 1fr);
} }
.sub-seller-row {
grid-template-columns: 42px minmax(0, 1fr);
}
.sub-seller-transfer {
grid-column: 2;
justify-self: start;
}
} }

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

View File

@ -6,17 +6,17 @@
name="viewport" name="viewport"
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover"
/> />
<title>Control Panel</title> <title>Manage Center</title>
<link rel="stylesheet" href="../../common/theme.css?v=20260608" />
<link rel="stylesheet" href="../common/center.css?v=20260608" />
<link <link
rel="stylesheet" rel="stylesheet"
href="./style.css?v=20260618-manager-permissions" href="../../common/theme.css?v=20260709-skeleton-mascot"
/> />
<link rel="stylesheet" href="../common/center.css?v=20260608" />
<link rel="stylesheet" href="./style.css?v=20260708-manager-actions" />
</head> </head>
<body> <body>
<div class="manager-center" aria-label="Control Panel"> <div class="manager-center" aria-label="Manage Center">
<div class="hero-bg" aria-hidden="true"></div> <div class="hero-bg" aria-hidden="true"></div>
<nav class="title-bar"> <nav class="title-bar">
@ -28,10 +28,12 @@
data-i18n-aria="manager_center.back" data-i18n-aria="manager_center.back"
> >
<svg viewBox="0 0 32 32" aria-hidden="true"> <svg viewBox="0 0 32 32" aria-hidden="true">
<path d="M20 8 12 16l8 8" /> <path
d="M20.3 6.8a2 2 0 0 1 0 2.8L14 15.9c-.1.1-.1.2 0 .3l6.3 6.3a2 2 0 1 1-2.8 2.8L9.8 17.6a2.3 2.3 0 0 1 0-3.2l7.7-7.7a2 2 0 0 1 2.8.1Z"
/>
</svg> </svg>
</button> </button>
<h1 data-i18n="manager_center.title">Control Panel</h1> <h1 data-i18n="manager_center.title">Manage Center</h1>
<div class="language-switcher"> <div class="language-switcher">
<button <button
class="language-button" class="language-button"
@ -39,10 +41,16 @@
aria-label="Change language" aria-label="Change language"
aria-expanded="false" aria-expanded="false"
data-language-toggle data-language-toggle
data-current-lang
data-i18n-aria="manager_center.change_language" data-i18n-aria="manager_center.change_language"
> >
EN <svg viewBox="0 0 32 32" aria-hidden="true">
<path
d="M16 11.2a4.8 4.8 0 1 0 0 9.6 4.8 4.8 0 0 0 0-9.6Z"
/>
<path
d="M26.2 17.7v-3.4l-3.2-.7a8 8 0 0 0-.8-1.9l1.8-2.8-2.4-2.4-2.8 1.8c-.6-.3-1.2-.6-1.9-.8L16.3 4h-3.4l-.7 3.5c-.7.2-1.3.5-1.9.8L7.5 6.5 5.1 8.9l1.8 2.8c-.3.6-.6 1.2-.8 1.9l-3.3.7v3.4l3.3.7c.2.7.5 1.3.8 1.9l-1.8 2.8 2.4 2.4 2.8-1.8c.6.3 1.2.6 1.9.8l.7 3.5h3.4l.7-3.5c.7-.2 1.3-.5 1.9-.8l2.8 1.8 2.4-2.4-1.8-2.8c.3-.6.6-1.2.8-1.9l3.2-.7Z"
/>
</svg>
</button> </button>
<div class="language-menu" data-language-menu hidden> <div class="language-menu" data-language-menu hidden>
<button type="button" data-lang-option="en">EN</button> <button type="button" data-lang-option="en">EN</button>
@ -56,262 +64,447 @@
</nav> </nav>
<main class="content"> <main class="content">
<section class="card manager-panel"> <section class="card manager-profile-card">
<div class="manager-grid" aria-label="Manager tools"> <div class="manager-avatar-shell">
<img
class="manager-avatar-image"
id="managerAvatar"
src=""
alt=""
hidden
/>
<div
class="manager-avatar-fallback"
id="managerAvatarFallback"
aria-hidden="true"
></div>
</div>
<div class="manager-profile-copy">
<strong id="managerName"></strong>
<span class="manager-role-badge">
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="m5 9 4 4 3-7 3 7 4-4-2 9H7Z" />
</svg>
<span data-i18n="manager_center.role_manager"
>Manager</span
>
</span>
<span class="manager-id-line">
<span>ID:</span>
<span id="managerDisplayId"></span>
<button
class="manager-copy-button"
type="button"
aria-label="Copy manager ID"
data-i18n-aria="manager_center.copy_id"
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path
d="M8.5 7.2h9.1c1.2 0 2.2 1 2.2 2.2v9.1c0 1.2-1 2.2-2.2 2.2H8.5c-1.2 0-2.2-1-2.2-2.2V9.4c0-1.2 1-2.2 2.2-2.2Z"
/>
<path
d="M5.2 3.3h8.7c1 0 1.8.8 1.8 1.8v.8H7.8c-1.6 0-2.9 1.3-2.9 2.9v7.9H4c-1 0-1.8-.8-1.8-1.8V6.3c0-1.7 1.3-3 3-3Z"
/>
</svg>
</button>
</span>
</div>
</section>
<section class="manager-kpi-grid" aria-label="Manager summary">
<article class="card manager-kpi-card kpi-wallet">
<span class="manager-kpi-icon" aria-hidden="true">
<svg viewBox="0 0 36 36">
<path
d="M8 13.5h18.2a4.2 4.2 0 0 1 4.2 4.2v10.6H8Z"
/>
<path d="M8.2 12.8 23.8 7l2.2 6.5Z" />
<path
class="icon-cutout"
d="M24 19.5h6.4v5.4H24a2.7 2.7 0 0 1 0-5.4Z"
/>
<circle
class="icon-dot"
cx="24.3"
cy="22.2"
r="1.1"
/>
</svg>
</span>
<div class="manager-kpi-copy">
<span data-i18n="manager_center.this_month"
>This Month</span
>
<strong id="managerThisMonthSalary"></strong>
<em data-i18n="manager_center.team_salary"
>Team Salary</em
>
</div>
<i class="manager-bars" aria-hidden="true"></i>
</article>
<article class="card manager-kpi-card kpi-report">
<span class="manager-kpi-icon" aria-hidden="true">
<svg viewBox="0 0 36 36">
<path d="M9.5 5.5h15.4l4.6 4.6v20.4h-20Z" />
<path class="icon-cutout" d="M24 6.5v5h5Z" />
<path
class="icon-cutout"
d="M15 15h9v2.2h-9ZM15 20h6.8v2.2H15Z"
/>
<path
class="icon-cutout"
d="m15.3 27.3 3.4-3.4 2.9 2.8 5-7 1.9 1.4-6.6 9.1-3.1-3-2.1 2.1Z"
/>
</svg>
</span>
<div class="manager-kpi-copy">
<span data-i18n="manager_center.last_month"
>Last Month</span
>
<strong id="managerLastMonthSalary"></strong>
<em data-i18n="manager_center.team_salary"
>Team Salary</em
>
</div>
<i class="manager-bars" aria-hidden="true"></i>
</article>
<article class="card manager-kpi-card kpi-hosts">
<span class="manager-kpi-icon" aria-hidden="true">
<svg viewBox="0 0 36 36">
<circle cx="13.4" cy="12.3" r="5.5" />
<path
d="M4.8 30.5c1.3-6.4 4.2-9.6 8.6-9.6s7.3 3.2 8.6 9.6Z"
/>
<circle cx="24.7" cy="12.7" r="4.8" />
<path
d="M21.4 30.5c1.1-5.4 3.5-8.1 7.2-8.1 3 0 5.1 2 6.4 6.1v2Z"
/>
</svg>
</span>
<div class="manager-kpi-copy">
<span data-i18n="manager_center.active_hosts"
>Active Hosts</span
>
<strong id="managerActiveHosts"></strong>
<em data-i18n="manager_center.total_active"
>Total Active</em
>
</div>
<i class="manager-bars" aria-hidden="true"></i>
</article>
<article class="card manager-kpi-card kpi-growth">
<span class="manager-kpi-icon" aria-hidden="true">
<svg viewBox="0 0 36 36">
<path
d="M7.5 25.5 15.3 17.7l4.4 4.3 8.1-11h3.4v3.4L20.2 29 15.4 24.2 10 29.5Z"
/>
<path d="M23.2 9.6h8v8h-3v-5h-5Z" />
</svg>
</span>
<div class="manager-kpi-copy">
<span data-i18n="manager_center.growth"
>Growth</span
>
<strong id="managerGrowth"></strong>
<em data-i18n="manager_center.vs_last_month"
>vs Last Month</em
>
</div>
<i class="manager-bars" aria-hidden="true"></i>
</article>
</section>
<!-- <section class="card manager-assets-card">
<button class="manager-asset-item" type="button">
<span
class="manager-asset-icon wallet"
aria-hidden="true"
>
<svg viewBox="0 0 36 36">
<path d="M7 12h18.5a4 4 0 0 1 4 4v12H7Z" />
<path d="M7.4 11.4 24.5 7l1.9 5Z" />
<path
class="icon-cutout"
d="M23.4 18.3h6.1v5.6h-6.1a2.8 2.8 0 0 1 0-5.6Z"
/>
<circle
class="icon-dot"
cx="23.8"
cy="21.1"
r="1"
/>
</svg>
</span>
<span>
<strong data-i18n="manager_center.wallet"
>Wallet</strong
>
<em id="managerWallet"></em>
</span>
<i aria-hidden="true"></i>
</button>
<button class="manager-asset-item" type="button">
<span
class="manager-asset-icon bank"
aria-hidden="true"
>
<svg viewBox="0 0 36 36">
<path d="M6 11h24v16H6Z" />
<path
class="icon-cutout"
d="M6 15h24v3H6ZM10 22h7v2.4h-7Z"
/>
</svg>
</span>
<span>
<strong data-i18n="manager_center.bank_cards"
>Bank Cards</strong
>
<em id="managerBankCards"></em>
</span>
<i aria-hidden="true"></i>
</button>
</section> -->
<section class="card manager-actions-card">
<!-- 操作入口按 overview.permissions 显隐renderActionPermissions 按 data-action 对应的权限位隐藏未授权入口 -->
<div class="manager-action-grid" aria-label="Manager tools">
<button <button
class="manager-tile" class="manager-action-item action-send-frame"
type="button" type="button"
data-action="send-frame" data-action="send-frame"
> >
<span <span
class="tile-icon frame-icon" class="manager-action-icon"
aria-hidden="true" aria-hidden="true"
> >
<svg viewBox="0 0 48 48"> <svg viewBox="0 0 36 36">
<path d="M10 10h28v28H10Z" />
<path d="M16 16h16v16H16Z" />
<path <path
d="M10 18h6M32 18h6M10 30h6M32 30h6" d="M9 4.5h18A4.5 4.5 0 0 1 31.5 9v18a4.5 4.5 0 0 1-4.5 4.5H9A4.5 4.5 0 0 1 4.5 27V9A4.5 4.5 0 0 1 9 4.5Z"
/>
<circle
class="icon-cutout"
cx="18"
cy="14.6"
r="4.8"
/>
<path
class="icon-cutout"
d="M10.4 28c1.2-5.1 3.8-7.6 7.6-7.6s6.4 2.5 7.6 7.6Z"
/> />
</svg> </svg>
</span> </span>
<span <span data-i18n="manager_center.send_frame"
class="tile-title"
data-i18n="manager_center.send_frame"
>Send Frame</span >Send Frame</span
> >
</button> </button>
<button <button
class="manager-tile" class="manager-action-item action-send-car"
type="button" type="button"
data-action="send-car" data-action="send-car"
> >
<span class="tile-icon car-icon" aria-hidden="true"> <span
<svg viewBox="0 0 48 48"> class="manager-action-icon"
<path d="M9 28h30l-4-10H13Z" /> aria-hidden="true"
<path d="M13 28v7M35 28v7" /> >
<svg viewBox="0 0 36 36">
<path <path
d="M15 35a3 3 0 1 0 0 6 3 3 0 0 0 0-6Z" d="m6.1 16 2.2-6a3.2 3.2 0 0 1 3-2.1h13.4a3.2 3.2 0 0 1 3 2.1l2.2 6c1.6.5 2.7 1.8 2.7 3.5v6.1a1.8 1.8 0 0 1-1.8 1.8h-1.6a1.8 1.8 0 0 1-1.8-1.8v-1.1H8.6v1.1a1.8 1.8 0 0 1-1.8 1.8H5.2a1.8 1.8 0 0 1-1.8-1.8v-6.1c0-1.7 1.1-3 2.7-3.5Z"
/> />
<path <path
d="M33 35a3 3 0 1 0 0 6 3 3 0 0 0 0-6Z" class="icon-cutout"
d="m9.4 15.3 1.5-4.3c.2-.6.8-1 1.4-1h11.4c.6 0 1.2.4 1.4 1l1.5 4.3Z"
/>
<circle
class="icon-cutout"
cx="9.9"
cy="20.3"
r="1.7"
/>
<circle
class="icon-cutout"
cx="26.1"
cy="20.3"
r="1.7"
/> />
<path d="M18 18h12" />
</svg> </svg>
</span> </span>
<span <span data-i18n="manager_center.send_car"
class="tile-title"
data-i18n="manager_center.send_car"
>Send Car</span >Send Car</span
> >
</button> </button>
<button <button
class="manager-tile" class="manager-action-item action-send-badge"
type="button" type="button"
data-action="send-badge" data-action="send-badge"
> >
<span <span
class="tile-icon badge-icon" class="manager-action-icon"
aria-hidden="true" aria-hidden="true"
> >
<svg viewBox="0 0 48 48"> <svg viewBox="0 0 36 36">
<path <path
d="M24 7 37 13v10c0 9-5.2 15-13 18-7.8-3-13-9-13-18V13Z" d="m12.6 20.9-2.8 10 5.3-2.3 2.9 3.9 2.9-3.9 5.3 2.3-2.8-10Z"
/> />
<circle cx="18" cy="13.4" r="9.4" />
<path <path
d="m24 16 2.5 5.1 5.6.8-4.1 4 .9 5.7-4.9-2.7-4.9 2.7.9-5.7-4.1-4 5.6-.8Z" class="icon-cutout"
d="m18 8.4 1.6 3.1 3.4.5-2.5 2.4.6 3.4-3.1-1.6-3.1 1.6.6-3.4-2.5-2.4 3.4-.5Z"
/> />
</svg> </svg>
</span> </span>
<span <span data-i18n="manager_center.send_badge"
class="tile-title"
data-i18n="manager_center.send_badge"
>Send Badge</span >Send Badge</span
> >
</button> </button>
<button <button
class="manager-tile" class="manager-action-item action-send-vip"
type="button" type="button"
data-action="send-vip" data-action="send-vip"
> >
<span class="tile-icon vip-icon" aria-hidden="true"> <span
<svg viewBox="0 0 48 48"> class="manager-action-icon"
aria-hidden="true"
>
<svg viewBox="0 0 36 36">
<path <path
d="m9 18 8 8 7-14 7 14 8-8-4 20H13Z" d="m4.8 11.4 6.4 4.9L18 7.6l6.8 8.7 6.4-4.9-2.6 14.3a2.6 2.6 0 0 1-2.6 2.1H10a2.6 2.6 0 0 1-2.6-2.1Z"
/>
<circle
class="icon-cutout"
cx="18"
cy="21.4"
r="2"
/> />
<path d="M13 38h22" />
<path d="M20 29h8" />
</svg> </svg>
</span> </span>
<span <span data-i18n="manager_center.send_vip"
class="tile-title"
data-i18n="manager_center.send_vip"
>Send VIP</span >Send VIP</span
> >
</button> </button>
<button <button
class="manager-tile" class="manager-action-item action-update-level"
type="button" type="button"
data-action="update-user-level" data-action="update-user-level"
> >
<span <span
class="tile-icon level-icon" class="manager-action-icon"
aria-hidden="true" aria-hidden="true"
> >
<svg viewBox="0 0 48 48"> <svg viewBox="0 0 36 36">
<path d="M5.5 29.5V20h5.4v9.5Z" />
<path d="M15.3 29.5V13.8h5.4v15.7Z" />
<path d="M25.1 29.5V7.5h5.4v22Z" />
<path <path
d="M24 7a10 10 0 1 0 0 20 10 10 0 0 0 0-20Z" d="M6.2 13.2 12.9 6.5H8.5V3.7h9v9h-2.8V8.5l-6.6 6.6Z"
/> />
<path
d="M8 42c2.4-8 8.2-12 16-12s13.6 4 16 12"
/>
<path d="M35 10v7M31.5 13.5h7" />
</svg> </svg>
</span> </span>
<span <span data-i18n="manager_center.user_level"
class="tile-title" >User Level</span
data-i18n="manager_center.update_user_level"
>Update User Level</span
> >
</button> </button>
<button <button
class="manager-tile" class="manager-action-item action-bd"
type="button" type="button"
data-action="add-bd-leader" data-action="add-bd-leader"
> >
<span <span
class="tile-icon bd-leader-icon" class="manager-action-icon"
aria-hidden="true" aria-hidden="true"
> >
<svg viewBox="0 0 48 48"> <svg viewBox="0 0 36 36">
<circle cx="15" cy="11.5" r="6" />
<path <path
d="M24 7a9 9 0 1 0 0 18 9 9 0 0 0 0-18Z" d="M5.5 30.5c1.4-7.1 4.6-10.6 9.5-10.6 4.2 0 7.1 2.5 8.8 7.5v3.1Z"
/> />
<circle cx="27" cy="24.8" r="5.8" />
<path <path
d="M9 42c2.3-8 7.4-12 15-12s12.7 4 15 12" class="icon-cutout"
d="M26 20.8h2v3h3v2h-3v3h-2v-3h-3v-2h3Z"
/> />
<path d="M36 8v10M31 13h10" />
</svg> </svg>
</span> </span>
<span <span data-i18n="manager_center.bd_leader"
class="tile-title" >BD Leader</span
data-i18n="manager_center.add_bd_leader"
>Add BD Leader</span
> >
</button> </button>
<button <button
class="manager-tile" class="manager-action-item action-invite-admin"
type="button" type="button"
data-action="add-admin" data-action="add-admin"
> >
<span <span
class="tile-icon admin-icon" class="manager-action-icon"
aria-hidden="true" aria-hidden="true"
> >
<svg viewBox="0 0 48 48"> <svg viewBox="0 0 36 36">
<circle cx="15" cy="11.5" r="6" />
<path <path
d="M24 7a9 9 0 1 0 0 18 9 9 0 0 0 0-18Z" d="M5.5 30.5c1.4-7.1 4.6-10.6 9.5-10.6s8.1 3.5 9.5 10.6Z"
/> />
<circle cx="26.5" cy="22.8" r="5.4" />
<path <path
d="M9 42c2.3-8 7.4-12 15-12s12.7 4 15 12" class="icon-cutout"
/> d="m26.5 19.3 1 2 2.2.3-1.6 1.5.4 2.2-2-1-2 1 .4-2.2-1.6-1.5 2.2-.3Z"
<path
d="m35 9 2.2 4.4 4.8.7-3.5 3.4.8 4.8-4.3-2.3-4.3 2.3.8-4.8-3.5-3.4 4.8-.7Z"
/> />
</svg> </svg>
</span> </span>
<span <span data-i18n="manager_center.admin">Admin</span>
class="tile-title"
data-i18n="manager_center.add_admin"
>Add Admin</span
>
</button> </button>
<button <button
class="manager-tile" class="manager-action-item action-super"
type="button" type="button"
data-action="add-superadmin" data-action="add-superadmin"
> >
<span <span
class="tile-icon superadmin-icon" class="manager-action-icon"
aria-hidden="true" aria-hidden="true"
> >
<svg viewBox="0 0 48 48"> <svg viewBox="0 0 36 36">
<path <path
d="M24 7a9 9 0 1 0 0 18 9 9 0 0 0 0-18Z" d="M18 5 29 10v8.2c0 7-4.3 10.8-11 13.3-6.7-2.5-11-6.3-11-13.3V10Z"
/> />
<path <path
d="M9 42c2.3-8 7.4-12 15-12s12.7 4 15 12" class="icon-cutout"
d="m18 11 1.8 3.6 4 .6-2.9 2.8.7 4-3.6-1.9-3.6 1.9.7-4-2.9-2.8 4-.6Z"
/> />
<path
d="m34 8 2.5 5 5.5.8-4 3.9.9 5.5-4.9-2.6-4.9 2.6.9-5.5-4-3.9 5.5-.8Z"
/>
<path d="M34 12v8M30 16h8" />
</svg> </svg>
</span> </span>
<span <span data-i18n="manager_center.super_admin"
class="tile-title" >Super Admin</span
data-i18n="manager_center.add_superadmin"
>Add Superadmin</span
> >
</button> </button>
<button <button
class="manager-tile manager-tile-wide" class="manager-action-item action-block-users"
type="button"
data-action="transfer-user-country"
>
<span
class="tile-icon country-icon"
aria-hidden="true"
>
<svg viewBox="0 0 48 48">
<path
d="M24 42c8-8 13-15 13-23A13 13 0 1 0 11 19c0 8 5 15 13 23Z"
/>
<path d="M18 19h12" />
<path d="M24 13v12" />
</svg>
</span>
<span
class="tile-title"
data-i18n="manager_center.transfer_user_country"
>Transfer User Country</span
>
</button>
<button
class="manager-tile manager-tile-wide"
type="button" type="button"
data-action="block-user" data-action="block-user"
> >
<span <span
class="tile-icon block-icon" class="manager-action-icon"
aria-hidden="true" aria-hidden="true"
> >
<svg viewBox="0 0 48 48"> <svg viewBox="0 0 36 36">
<circle cx="16" cy="11.5" r="5.8" />
<path <path
d="M18 22a8 8 0 1 0 0-16 8 8 0 0 0 0 16Z" d="M7.2 28.8c1.3-6.9 4.2-10.3 8.8-10.3s7.5 3.4 8.8 10.3Z"
/>
<circle
class="icon-cutout"
cx="26"
cy="23.5"
r="7.4"
/> />
<path <path
d="M5 42c1.8-9.2 6.2-13.8 13-13.8 4.3 0 7.6 1.8 10 5.3" d="M21.4 18.9a7.4 7.4 0 0 1 9.2 9.2Z"
/> />
<path
d="M35 23a9 9 0 1 0 0 18 9 9 0 0 0 0-18Z"
/>
<path d="m29 29 12 12" />
</svg> </svg>
</span> </span>
<span <span data-i18n="manager_center.block_users"
class="tile-title" >Block Users</span
data-i18n="manager_center.block_user"
>Block User</span
> >
</button> </button>
</div> </div>
@ -519,10 +712,12 @@
</div> </div>
<script src="../../common/toast.js"></script> <script src="../../common/toast.js"></script>
<script src="../../common/loading.js?v=20260709-lalu-loading"></script>
<script src="../../common/skeleton.js?v=20260709-skeleton-mascot"></script>
<script src="../../common/api.js?v=20260624-manager-badge-country"></script> <script src="../../common/api.js?v=20260624-manager-badge-country"></script>
<script src="../../common/params.js?v=20260608-manager-real"></script> <script src="../../common/params.js?v=20260608-manager-real"></script>
<script src="../../common/jsbridge.js"></script> <script src="../../common/jsbridge.js"></script>
<script src="../../common/i18n.js?v=20260608"></script> <script src="../../common/i18n.js?v=20260608"></script>
<script src="./script.js?v=20260705-manager-vip-permission"></script> <script src="./script.js?v=20260709-page-skeleton"></script>
</body> </body>
</html> </html>

View File

@ -70,7 +70,7 @@
resourceType: '', resourceType: '',
title: '', title: '',
searchedUser: null, searchedUser: null,
selectedResourceId: '', selectedResourceIds: [],
selectedResourceDurationMs: DEFAULT_RESOURCE_GRANT_DURATION_MS, selectedResourceDurationMs: DEFAULT_RESOURCE_GRANT_DURATION_MS,
selectedVipLevel: 0, selectedVipLevel: 0,
submitting: false, submitting: false,
@ -148,6 +148,46 @@
return user.avatar || user.target_avatar || ''; return user.avatar || user.target_avatar || '';
} }
function resourceImageUrl(resource) {
return (
resource.preview_url ||
resource.previewUrl ||
resource.asset_url ||
resource.assetUrl ||
resource.icon_url ||
resource.iconUrl ||
''
);
}
function resourceKindLabel() {
if (grantState.action === 'send-frame') return 'Frame';
if (grantState.action === 'send-car') return 'Car';
if (grantState.action === 'send-badge') return 'Badge';
return 'Resource';
}
function currentVip(user) {
return user.current_vip || user.currentVip || user.vip || null;
}
function currentVipLevel(user) {
var vip = currentVip(user);
if (!vip || !vip.active) return 0;
return Number(vip.level) || 0;
}
function currentVipStatusText(user) {
var vip = currentVip(user);
if (!vip || !vip.active || !vip.level) return 'Current VIP: None';
var label = vip.name || 'VIP ' + vip.level;
var expiresAt = Number(vip.expires_at_ms || vip.expiresAtMs || 0);
if (expiresAt > 0) {
return label + ' active · Expires ' + formatDateTime(expiresAt);
}
return label + ' active';
}
function initial(text) { function initial(text) {
return String(text || 'M') return String(text || 'M')
.trim() .trim()
@ -175,6 +215,246 @@
node.textContent = initial(userName(user) || userDisplayId(user)); node.textContent = initial(userName(user) || userDisplayId(user));
} }
function textValue(value, fallback) {
if (value === undefined || value === null || value === '') {
return fallback;
}
return String(value);
}
function isLocalPreviewError(error) {
var host = window.location && window.location.hostname;
var isLocalHost =
host === 'localhost' ||
host === '127.0.0.1' ||
host === '::1' ||
host === '';
var message = (error && error.message) || '';
return isLocalHost && /failed to fetch|network/i.test(message);
}
function firstValue(source, keys) {
source = source || {};
for (var i = 0; i < keys.length; i += 1) {
var key = keys[i];
if (
Object.prototype.hasOwnProperty.call(source, key) &&
source[key] !== undefined &&
source[key] !== null &&
source[key] !== ''
) {
return source[key];
}
}
return undefined;
}
function firstNestedValue(sources, keys) {
for (var i = 0; i < sources.length; i += 1) {
var value = firstValue(sources[i], keys);
if (value !== undefined) return value;
}
return undefined;
}
function formatMoney(value, fallback) {
if (value === undefined || value === null || value === '') {
return fallback;
}
if (typeof value === 'string' && value.indexOf('$') >= 0) {
return value;
}
var number = Number(String(value).replace(/,/g, ''));
if (!Number.isFinite(number)) return String(value);
return (
'$' +
number.toLocaleString('en-US', {
maximumFractionDigits: number % 1 === 0 ? 0 : 2,
minimumFractionDigits: number % 1 === 0 ? 0 : 2,
})
);
}
function formatPercent(value, fallback) {
if (value === undefined || value === null || value === '') {
return fallback;
}
if (typeof value === 'string' && value.indexOf('%') >= 0) {
// 服务端已带符号下发;只对正数补 +,零和负数原样透传,避免出现 "+0%"。
var parsed = parseFloat(value);
if (!Number.isFinite(parsed) || parsed <= 0) {
return value;
}
return '+' + value.replace(/^\+/, '');
}
var number = Number(value);
if (!Number.isFinite(number)) return String(value);
if (Math.abs(number) > 0 && Math.abs(number) <= 1) {
number *= 100;
}
return (number > 0 ? '+' : '') + Math.round(number) + '%';
}
function setText(id, value) {
var node = $(id);
if (node) node.textContent = value;
}
function updateManagerAvatar(profile) {
var image = $('managerAvatar');
var fallback = $('managerAvatarFallback');
if (!image || !fallback) return;
// 没有头像时展示首字母占位,不再使用演示头像假数据。
var url = avatarUrl(profile);
if (url) {
image.src = url;
image.hidden = false;
fallback.hidden = true;
return;
}
image.hidden = true;
fallback.hidden = false;
fallback.textContent = initial(
userName(profile) || userDisplayId(profile)
);
}
function dashboardSources() {
var overview = pageState.overview || {};
return [
overview.dashboard,
overview.summary,
overview.metrics,
overview.statistics,
overview.wallet,
overview,
];
}
function renderDashboard() {
var overview = pageState.overview || {};
var profile =
overview.user ||
overview.profile ||
overview.manager ||
overview.current_user ||
overview.currentUser ||
overview;
updateManagerAvatar(profile || {});
setText(
'managerName',
textValue(
firstValue(profile || {}, [
'username',
'name',
'nickname',
'target_username',
]),
'--'
)
);
setText(
'managerDisplayId',
textValue(
userDisplayId(profile || {}) || userId(profile || {}),
'--'
)
);
var sources = dashboardSources();
// 页面不再内置任何设计假数据:加载中由骨架屏占位,渲染时字段缺失(聚合降级/请求失败/无 API
// 一律显示中性占位符,避免把演示数值误当真实团队工资。
var fallback = {
thisMonth: '--',
lastMonth: '--',
hosts: '--',
growth: '--',
wallet: '--',
bankCards: '--',
};
setText(
'managerThisMonthSalary',
formatMoney(
firstNestedValue(sources, [
'this_month_salary',
'thisMonthSalary',
'current_month_salary',
'currentMonthSalary',
'team_salary_this_month',
'teamSalaryThisMonth',
]),
fallback.thisMonth
)
);
setText(
'managerLastMonthSalary',
formatMoney(
firstNestedValue(sources, [
'last_month_salary',
'lastMonthSalary',
'previous_month_salary',
'previousMonthSalary',
'team_salary_last_month',
'teamSalaryLastMonth',
]),
fallback.lastMonth
)
);
setText(
'managerActiveHosts',
textValue(
firstNestedValue(sources, [
'active_hosts',
'activeHosts',
'total_active_hosts',
'totalActiveHosts',
'host_count',
'hostCount',
]),
fallback.hosts
)
);
setText(
'managerGrowth',
formatPercent(
firstNestedValue(sources, [
'growth',
'growth_rate',
'growthRate',
'salary_growth',
'salaryGrowth',
'month_growth',
'monthGrowth',
]),
fallback.growth
)
);
setText(
'managerWallet',
formatMoney(
firstNestedValue(sources, [
'wallet',
'wallet_balance',
'walletBalance',
'balance',
]),
fallback.wallet
)
);
setText(
'managerBankCards',
textValue(
firstNestedValue(sources, [
'bank_cards',
'bankCards',
'bank_card_count',
'bankCardCount',
]),
fallback.bankCards
)
);
}
function formatDateTime(ms) { function formatDateTime(ms) {
if (!ms) return 'Never'; if (!ms) return 'Never';
var date = new Date(Number(ms)); var date = new Date(Number(ms));
@ -187,10 +467,17 @@
} }
function setPageLoading(loading) { function setPageLoading(loading) {
// 首屏加载态用页面级骨架屏整层覆盖common/skeleton.js数据就绪后整层淡出
// is-loading 状态类保留给页面内其他降级样式使用。
pageState.loading = loading; pageState.loading = loading;
var container = root(); var container = root();
if (!container) return; if (container) container.classList.toggle('is-loading', loading);
container.classList.toggle('is-loading', loading); if (!window.HyAppSkeleton) return;
if (loading) {
window.HyAppSkeleton.show();
} else {
window.HyAppSkeleton.hide();
}
} }
function setDenied(denied) { function setDenied(denied) {
@ -201,6 +488,7 @@
} }
function normalizeUser(item) { function normalizeUser(item) {
var vip = item.current_vip || item.currentVip || item.vip || null;
return { return {
user_id: item.user_id || item.userId || item.target_user_id || '', user_id: item.user_id || item.userId || item.target_user_id || '',
display_user_id: display_user_id:
@ -212,6 +500,16 @@
avatar: item.avatar || item.target_avatar || '', avatar: item.avatar || item.target_avatar || '',
country: item.country || '', country: item.country || '',
levels: item.levels || {}, levels: item.levels || {},
current_vip: vip
? {
level: Number(vip.level) || 0,
name: vip.name || '',
active: Boolean(vip.active),
started_at_ms: vip.started_at_ms || vip.startedAtMs || 0,
expires_at_ms: vip.expires_at_ms || vip.expiresAtMs || 0,
updated_at_ms: vip.updated_at_ms || vip.updatedAtMs || 0,
}
: null,
}; };
} }
@ -322,16 +620,21 @@
if (!api || !api.searchUsers) { if (!api || !api.searchUsers) {
return Promise.reject(new Error('API not available.')); return Promise.reject(new Error('API not available.'));
} }
return api.searchUsers(keyword, 20, scope, action).then(function (data) { return api
var items = (data && data.items) || []; .searchUsers(keyword, 20, scope, action)
return items.map(normalizeUser); .then(function (data) {
}); var items = (data && data.items) || [];
return items.map(normalizeUser);
});
} }
function loadOverview() { function loadOverview() {
if (!api || !api.overview) { if (!api || !api.overview) {
setDenied(true); // 无 API 注入(本地纯静态预览等)时立即渲染一次,把骨架屏收敛成中性占位符。
toast('Permission denied.'); pageState.permissions = normalizePermissions({});
renderDashboard();
renderActionPermissions();
setDenied(false);
return Promise.resolve(); return Promise.resolve();
} }
setPageLoading(true); setPageLoading(true);
@ -342,6 +645,7 @@
pageState.permissions = normalizePermissions( pageState.permissions = normalizePermissions(
pageState.overview.permissions || pageState.overview pageState.overview.permissions || pageState.overview
); );
renderDashboard();
renderActionPermissions(); renderActionPermissions();
setDenied(false); setDenied(false);
return canUseAction('block-user') return canUseAction('block-user')
@ -349,7 +653,16 @@
: Promise.resolve(); : Promise.resolve();
}) })
.catch(function (error) { .catch(function (error) {
if (isLocalPreviewError(error)) {
pageState.permissions = normalizePermissions({});
renderDashboard();
renderActionPermissions();
setDenied(false);
return;
}
// 接口失败也要渲染一次:把骨架屏收敛成中性占位符,页面不残留加载态。
setDenied(true); setDenied(true);
renderDashboard();
showError(error); showError(error);
}) })
.finally(function () { .finally(function () {
@ -389,6 +702,11 @@
copy.appendChild( copy.appendChild(
createNode('div', 'searched-meta', 'UID: ' + userDisplayId(user)) createNode('div', 'searched-meta', 'UID: ' + userDisplayId(user))
); );
if (currentVip(user)) {
copy.appendChild(
createNode('div', 'searched-vip', currentVipStatusText(user))
);
}
var button = createNode('button', 'user-block-button', actionText); var button = createNode('button', 'user-block-button', actionText);
button.type = 'button'; button.type = 'button';
button.addEventListener('click', onAction); button.addEventListener('click', onAction);
@ -681,7 +999,8 @@
toast('Enter user ID.'); toast('Enter user ID.');
return; return;
} }
apiSearchUser(value, '', grantState.action) // 封禁搜索只校验 Block User 权限;不能借用赠送弹窗状态,否则会按资源/VIP 赠送权限误判。
apiSearchUser(value, '', 'block-user')
.then(function (items) { .then(function (items) {
if (!items.length) { if (!items.length) {
blockState.searchedUser = null; blockState.searchedUser = null;
@ -733,7 +1052,8 @@
toast('Enter user ID.'); toast('Enter user ID.');
return; return;
} }
apiSearchUser(value) // 等级调整搜索按自己的能力开关校验,避免资源赠送关闭时误挡等级操作。
apiSearchUser(value, '', 'update-user-level')
.then(function (items) { .then(function (items) {
if (!items.length) { if (!items.length) {
levelState.searchedUser = null; levelState.searchedUser = null;
@ -815,7 +1135,7 @@
grantState.resourceType = RESOURCE_TYPES[action] || ''; grantState.resourceType = RESOURCE_TYPES[action] || '';
grantState.title = ACTION_LABELS[action] || 'Send'; grantState.title = ACTION_LABELS[action] || 'Send';
grantState.searchedUser = null; grantState.searchedUser = null;
grantState.selectedResourceId = ''; grantState.selectedResourceIds = [];
grantState.selectedResourceDurationMs = grantState.selectedResourceDurationMs =
DEFAULT_RESOURCE_GRANT_DURATION_MS; DEFAULT_RESOURCE_GRANT_DURATION_MS;
grantState.selectedVipLevel = 0; grantState.selectedVipLevel = 0;
@ -860,7 +1180,8 @@
toast('Enter user ID.'); toast('Enter user ID.');
return; return;
} }
apiSearchUser(value) // 搜索入口也必须携带当前赠送动作Send VIP 只校验 VIP 赠送权限,不能落回资源赠送权限导致误拒。
apiSearchUser(value, '', grantState.action)
.then(function (items) { .then(function (items) {
if (!items.length) { if (!items.length) {
grantState.searchedUser = null; grantState.searchedUser = null;
@ -890,6 +1211,10 @@
var grid = $('grantOptionGrid'); var grid = $('grantOptionGrid');
if (!grid) return; if (!grid) return;
grid.innerHTML = ''; grid.innerHTML = '';
grid.classList.toggle(
'is-resource-grid',
grantState.action !== 'send-vip'
);
var durationTitle = $('grantDurationTitle'); var durationTitle = $('grantDurationTitle');
var durationGrid = $('grantDurationGrid'); var durationGrid = $('grantDurationGrid');
if (durationTitle) { if (durationTitle) {
@ -905,24 +1230,55 @@
} }
if (grantState.action === 'send-vip') { if (grantState.action === 'send-vip') {
$('grantOptionTitle').textContent = 'Select VIP level'; $('grantOptionTitle').textContent = 'Select VIP level';
var activeVipLevel = currentVipLevel(grantState.searchedUser || {});
if (
grantState.selectedVipLevel &&
activeVipLevel > grantState.selectedVipLevel
) {
grantState.selectedVipLevel = 0;
}
// VIP grants must stay on the explicit product allow-list so the UI never sends a level the backend has not opened. // VIP grants must stay on the explicit product allow-list so the UI never sends a level the backend has not opened.
VIP_GRANT_LEVELS.forEach(function (level) { VIP_GRANT_LEVELS.forEach(function (level) {
var button = createNode('button', '', 'VIP ' + level); var button = createNode('button', '', 'VIP ' + level);
var blockedByCurrentVip = activeVipLevel > level;
button.type = 'button'; button.type = 'button';
button.disabled = blockedByCurrentVip;
if (blockedByCurrentVip) {
button.title = 'Current VIP is higher';
}
button.classList.toggle( button.classList.toggle(
'is-selected', 'is-selected',
grantState.selectedVipLevel === level grantState.selectedVipLevel === level
); );
button.addEventListener('click', function () { button.addEventListener('click', function () {
if (blockedByCurrentVip) return;
grantState.selectedVipLevel = level; grantState.selectedVipLevel = level;
renderGrantOptions(); renderGrantOptions();
}); });
grid.appendChild(button); grid.appendChild(button);
}); });
if (activeVipLevel > 1) {
grid.appendChild(
createNode(
'div',
'vip-grant-note',
activeVipLevel > Math.max.apply(null, VIP_GRANT_LEVELS)
? 'Current VIP is higher than manager-center gift levels.'
: 'Lower VIP levels are disabled because the user already has a higher VIP.'
)
);
}
} else { } else {
$('grantOptionTitle').textContent = 'Select resource'; $('grantOptionTitle').textContent = 'Select ' + resourceKindLabel();
var resources = var resources =
pageState.resourcesByType[grantState.resourceType] || []; pageState.resourcesByType[grantState.resourceType] || [];
var selectedResourceSet = grantState.selectedResourceIds.reduce(
function (result, resourceId) {
result[String(resourceId)] = true;
return result;
},
{}
);
if (!resources.length) { if (!resources.length) {
grid.appendChild( grid.appendChild(
createNode('div', 'blocked-empty', 'No resource available.') createNode('div', 'blocked-empty', 'No resource available.')
@ -930,20 +1286,56 @@
} }
resources.forEach(function (resource) { resources.forEach(function (resource) {
var button = createNode('button', 'grant-resource-option'); var button = createNode('button', 'grant-resource-option');
var imageURL = resourceImageUrl(resource);
var resourceID = resource.resource_id;
var resourceKey = String(resourceID);
button.type = 'button'; button.type = 'button';
button.setAttribute(
'aria-label',
'Select ' + resourceKindLabel()
);
button.classList.toggle( button.classList.toggle(
'is-selected', 'is-selected',
String(grantState.selectedResourceId) === !!selectedResourceSet[resourceKey]
String(resource.resource_id)
); );
button.textContent = if (imageURL) {
resource.name || 'Resource #' + resource.resource_id; var media = createNode('span', 'grant-resource-media');
var img = document.createElement('img');
img.src = imageURL;
img.alt = '';
media.appendChild(img);
button.appendChild(media);
} else {
button.appendChild(
createNode('span', 'grant-resource-fallback')
);
}
button.addEventListener('click', function () { button.addEventListener('click', function () {
grantState.selectedResourceId = resource.resource_id; // 资源赠送是素材发放,不是互斥装备选择;同一弹窗允许一次选择多个素材,由后端拆成独立 grant 命令保证可重试。
if (selectedResourceSet[resourceKey]) {
grantState.selectedResourceIds =
grantState.selectedResourceIds.filter(
function (selectedID) {
return String(selectedID) !== resourceKey;
}
);
} else {
grantState.selectedResourceIds =
grantState.selectedResourceIds.concat(resourceID);
}
renderGrantOptions(); renderGrantOptions();
}); });
grid.appendChild(button); grid.appendChild(button);
}); });
if (grantState.selectedResourceIds.length) {
grid.appendChild(
createNode(
'div',
'resource-selected-note',
grantState.selectedResourceIds.length + ' selected'
)
);
}
if (durationGrid) { if (durationGrid) {
RESOURCE_GRANT_DURATIONS.forEach(function (item) { RESOURCE_GRANT_DURATIONS.forEach(function (item) {
var durationButton = createNode('button', '', item.label); var durationButton = createNode('button', '', item.label);
@ -966,8 +1358,10 @@
!grantState.searchedUser || !grantState.searchedUser ||
grantState.submitting || grantState.submitting ||
(grantState.action === 'send-vip' (grantState.action === 'send-vip'
? !grantState.selectedVipLevel ? !grantState.selectedVipLevel ||
: !grantState.selectedResourceId || currentVipLevel(grantState.searchedUser) >
grantState.selectedVipLevel
: !grantState.selectedResourceIds.length ||
!grantState.selectedResourceDurationMs); !grantState.selectedResourceDurationMs);
} }
} }
@ -986,7 +1380,7 @@
payload.level = grantState.selectedVipLevel; payload.level = grantState.selectedVipLevel;
request = api.grantVip(payload); request = api.grantVip(payload);
} else { } else {
payload.resource_id = grantState.selectedResourceId; payload.resource_ids = grantState.selectedResourceIds.slice();
payload.duration_ms = grantState.selectedResourceDurationMs; payload.duration_ms = grantState.selectedResourceDurationMs;
request = api.grantResource(payload); request = api.grantResource(payload);
} }
@ -1065,7 +1459,8 @@
toast('Enter user ID.'); toast('Enter user ID.');
return; return;
} }
apiSearchUser(value) // BD/Admin/Superadmin 共用弹窗,搜索阶段先按当前按钮的能力开关过滤;写入口仍会二次校验真实落库能力。
apiSearchUser(value, '', bdLeaderState.action)
.then(function (items) { .then(function (items) {
if (!items.length) { if (!items.length) {
bdLeaderState.searchedUser = null; bdLeaderState.searchedUser = null;
@ -1257,7 +1652,8 @@
toast('Enter user ID.'); toast('Enter user ID.');
return; return;
} }
apiSearchUser(value, 'all') // 转国家是唯一允许跨国家搜索的入口,必须同时携带 scope=all 和动作,避免额外依赖资源赠送权限。
apiSearchUser(value, 'all', 'transfer-user-country')
.then(function (items) { .then(function (items) {
if (!items.length) { if (!items.length) {
countryState.searchedUser = null; countryState.searchedUser = null;
@ -1362,9 +1758,30 @@
}); });
} }
function copyManagerId() {
var value = $('managerDisplayId')
? $('managerDisplayId').textContent.trim()
: '';
if (!value) return;
if (window.navigator && window.navigator.clipboard) {
window.navigator.clipboard
.writeText(value)
.then(function () {
toast('Copied.');
})
.catch(function () {
toast(value);
});
return;
}
toast(value);
}
function bindEvents() { function bindEvents() {
var backButton = $('backButton'); var backButton = $('backButton');
if (backButton) backButton.addEventListener('click', goBack); if (backButton) backButton.addEventListener('click', goBack);
var copyButton = document.querySelector('.manager-copy-button');
if (copyButton) copyButton.addEventListener('click', copyManagerId);
document.querySelectorAll('[data-action]').forEach(function (button) { document.querySelectorAll('[data-action]').forEach(function (button) {
button.addEventListener('click', function () { button.addEventListener('click', function () {
var action = button.getAttribute('data-action'); var action = button.getAttribute('data-action');
@ -1453,6 +1870,7 @@
function init() { function init() {
bindEvents(); bindEvents();
renderBlockedList(); renderBlockedList();
// 首屏不预渲染仪表盘:骨架屏保持到 loadOverview 的任一分支调用 renderDashboard 收敛。
loadOverview(); loadOverview();
if (window.HyAppBridge && window.HyAppBridge.ready) { if (window.HyAppBridge && window.HyAppBridge.ready) {
window.HyAppBridge.ready({ page: 'manager-center' }); window.HyAppBridge.ready({ page: 'manager-center' });

View File

@ -1,3 +1,544 @@
.manager-center {
background:
radial-gradient(
circle at 15% 0,
rgba(222, 206, 255, 0.58),
rgba(251, 248, 255, 0) 28%
),
radial-gradient(
circle at 92% 18%,
rgba(229, 238, 255, 0.62),
rgba(251, 248, 255, 0) 31%
),
var(--page-bg);
}
.manager-center .hero-bg {
display: none;
}
.manager-center .home-indicator {
display: none;
}
.manager-center .title-bar {
grid-template-columns: 52px minmax(0, 1fr) 52px;
height: 72px;
padding: 13px 18px 0;
}
.manager-center .title-bar h1 {
color: #11162e;
font-size: 22px;
font-weight: 820;
line-height: 1.1;
}
.manager-center .back-button,
.manager-center .language-button {
width: 44px;
height: 44px;
}
.manager-center .language-button {
border: 0;
border-radius: 50%;
background: transparent;
box-shadow: none;
color: #11162e;
}
.manager-center .language-button svg {
width: 32px;
height: 32px;
}
.manager-center .back-button path,
.manager-center .language-button path,
.manager-center .manager-copy-button path {
fill: currentColor;
stroke: none;
}
.manager-center .content {
gap: 12px;
padding: 10px 16px 30px;
}
.manager-center .card {
border: 1px solid rgba(125, 87, 199, 0.12);
border-radius: 16px;
background:
linear-gradient(
145deg,
rgba(255, 255, 255, 0.98),
rgba(255, 255, 255, 0.89)
),
var(--card-bg);
box-shadow:
0 18px 42px rgba(88, 66, 142, 0.075),
inset 0 1px 0 rgba(255, 255, 255, 0.96);
}
.manager-profile-card {
display: grid;
grid-template-columns: 82px minmax(0, 1fr) auto;
gap: 11px;
align-items: center;
min-height: 112px;
padding: 13px 12px;
}
.manager-avatar-shell {
position: relative;
width: 76px;
height: 76px;
border: 3px solid transparent;
border-radius: 50%;
background:
linear-gradient(#fff, #fff) padding-box,
linear-gradient(145deg, #f4eaff, #b894ff) border-box;
box-shadow: 0 8px 22px rgba(125, 87, 199, 0.2);
}
.manager-avatar-image,
.manager-avatar-fallback {
width: 100%;
height: 100%;
border-radius: 50%;
}
.manager-avatar-image {
display: block;
object-fit: cover;
}
.manager-avatar-fallback {
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(145deg, #f7f0ff, #e3d5ff);
color: var(--primary-deep);
font-size: 32px;
font-weight: 950;
}
.manager-profile-copy {
display: grid;
min-width: 0;
gap: 7px;
}
.manager-profile-copy strong {
overflow: hidden;
color: #151936;
font-size: 24px;
font-weight: 820;
line-height: 1;
text-overflow: ellipsis;
white-space: nowrap;
}
.manager-role-badge {
display: inline-flex;
width: max-content;
max-width: 100%;
height: 24px;
align-items: center;
gap: 6px;
padding: 0 10px;
border-radius: 7px;
background: linear-gradient(135deg, #a678ff, #8b5ce8);
color: #fff;
font-size: 14px;
font-weight: 780;
line-height: 24px;
box-shadow: 0 8px 18px rgba(139, 92, 232, 0.22);
}
.manager-role-badge svg {
width: 14px;
height: 14px;
}
.manager-role-badge path {
fill: currentColor;
}
.manager-id-line {
display: flex;
min-width: 0;
align-items: center;
gap: 5px;
color: #837ba3;
font-size: 14px;
font-weight: 650;
line-height: 1.1;
}
.manager-id-line > span:nth-child(2) {
overflow: hidden;
flex: 0 0 auto;
text-overflow: ellipsis;
white-space: nowrap;
}
.manager-copy-button {
display: flex;
flex: 0 0 22px;
width: 22px;
height: 22px;
align-items: center;
justify-content: center;
color: #837ba3;
}
.manager-copy-button svg {
width: 18px;
height: 18px;
}
.manager-kpi-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.manager-kpi-card {
position: relative;
display: grid;
grid-template-columns: minmax(0, 1fr);
min-height: 128px;
overflow: hidden;
padding: 18px 13px 16px;
}
.manager-kpi-icon {
position: absolute;
top: 18px;
left: 13px;
z-index: 1;
display: flex;
width: 48px;
height: 48px;
align-items: center;
justify-content: center;
border-radius: 50%;
pointer-events: none;
}
.manager-kpi-icon svg {
width: 30px;
height: 30px;
}
.manager-kpi-icon path,
.manager-kpi-icon circle,
.manager-asset-icon path,
.manager-asset-icon circle {
fill: currentColor;
stroke: none;
}
.manager-kpi-icon .icon-cutout,
.manager-asset-icon .icon-cutout {
fill: #fff;
stroke: none;
}
.manager-kpi-icon .icon-dot {
fill: currentColor;
stroke: none;
}
.manager-asset-icon.wallet .icon-cutout,
.manager-asset-icon.wallet .icon-dot {
fill: #ff6c08;
}
.manager-asset-icon.bank .icon-cutout {
fill: #2f61ff;
}
.manager-kpi-copy {
position: relative;
z-index: 2;
display: grid;
align-content: center;
min-width: 0;
gap: 6px;
padding-left: 58px;
}
.manager-kpi-copy span,
.manager-kpi-copy em {
color: #827f99;
font-size: 14px;
font-style: normal;
font-weight: 540;
line-height: 1.12;
}
.manager-kpi-copy strong {
color: #ff660a;
font-size: 25px;
font-weight: 840;
line-height: 1;
letter-spacing: 0;
white-space: nowrap;
}
.kpi-hosts .manager-kpi-copy strong {
color: #171b35;
}
.kpi-growth .manager-kpi-copy strong {
color: #ff424d;
}
.kpi-wallet .manager-kpi-icon {
background: #f2e9ff;
color: #854fe8;
}
.kpi-report .manager-kpi-icon {
background: #eef4ff;
color: #3478f6;
}
.kpi-hosts .manager-kpi-icon {
background: #e8fbf8;
color: #10bda6;
}
.kpi-growth .manager-kpi-icon {
background: #fff0f4;
color: #ff3d52;
}
.manager-bars {
position: absolute;
right: 13px;
bottom: 16px;
width: 39px;
height: 34px;
opacity: 0.38;
background:
linear-gradient(currentColor, currentColor) 0 21px / 7px 13px no-repeat,
linear-gradient(currentColor, currentColor) 11px 14px / 7px 20px
no-repeat,
linear-gradient(currentColor, currentColor) 22px 7px / 7px 27px
no-repeat,
linear-gradient(currentColor, currentColor) 33px 0 / 7px 34px no-repeat;
}
.kpi-wallet .manager-bars {
color: #eadcff;
}
.kpi-report .manager-bars {
color: #d9e9ff;
}
.kpi-hosts .manager-bars {
color: #d9fbf5;
}
.kpi-growth .manager-bars {
color: #ffe0e7;
}
.manager-assets-card {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
min-height: 88px;
margin-top: 12px;
overflow: hidden;
padding: 0;
}
.manager-asset-item {
display: grid;
grid-template-columns: 48px minmax(0, 1fr) 14px;
gap: 10px;
align-items: center;
min-width: 0;
min-height: 88px;
padding: 16px 13px;
text-align: left;
}
.manager-asset-item + .manager-asset-item {
border-left: 1px solid rgba(125, 87, 199, 0.1);
}
.manager-asset-icon,
.manager-action-icon {
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
}
.manager-asset-icon {
width: 48px;
height: 48px;
color: #fff;
}
.manager-asset-icon.wallet {
background: linear-gradient(145deg, #ff8a16, #ff5a00);
}
.manager-asset-icon.bank {
background: linear-gradient(145deg, #4d73ff, #215dff);
}
.manager-asset-icon svg {
width: 29px;
height: 29px;
}
.manager-asset-item strong,
.manager-action-item > span:last-child {
overflow-wrap: anywhere;
color: #1d2140;
font-size: 14px;
font-weight: 560;
line-height: 1.15;
}
.manager-asset-item strong {
white-space: nowrap;
}
.manager-action-item > span:last-child {
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.manager-asset-item em {
display: block;
margin-top: 7px;
color: #ff660a;
font-size: 14px;
font-style: normal;
font-weight: 700;
line-height: 1;
}
.manager-asset-item:nth-child(2) em {
color: #858296;
font-weight: 540;
}
.manager-asset-item i {
color: #a8a4b7;
font-size: 30px;
font-style: normal;
font-weight: 300;
line-height: 1;
}
.manager-actions-card {
overflow: hidden;
margin-top: 8px;
padding: 0;
}
.manager-action-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.manager-action-item {
display: grid;
min-height: 114px;
align-content: center;
justify-items: center;
gap: 8px;
padding: 11px 7px;
text-align: center;
}
.manager-action-item:nth-child(n + 4) {
border-top: 1px solid rgba(125, 87, 199, 0.055);
}
.manager-action-item:not(:nth-child(3n + 1)) {
border-left: 1px solid rgba(125, 87, 199, 0.055);
}
.manager-action-icon {
width: 56px;
height: 56px;
}
.manager-action-icon svg {
width: 33px;
height: 33px;
}
.manager-action-icon path,
.manager-action-icon circle {
fill: currentColor;
stroke: none;
}
.manager-action-icon .icon-cutout {
fill: #fff;
stroke: none;
}
.action-admin .manager-action-icon,
.action-send-frame .manager-action-icon {
background: #f0e6ff;
color: #844dec;
}
.action-block-users .manager-action-icon {
background: #fff1f4;
color: #d95d72;
}
.action-bd .manager-action-icon,
.action-send-car .manager-action-icon {
background: #eef4ff;
color: #2f6df1;
}
.action-invite-admin .manager-action-icon,
.action-send-vip .manager-action-icon {
background: #ffe9ef;
color: #ff304a;
}
.action-invite-bd .manager-action-icon,
.action-update-level .manager-action-icon {
background: #e5fbf7;
color: #08bca6;
}
.action-super .manager-action-icon,
.action-send-badge .manager-action-icon {
background: #fff4db;
color: #ff9700;
}
.manager-center.is-loading .manager-profile-card,
.manager-center.is-loading .manager-kpi-card,
.manager-center.is-loading .manager-assets-card,
.manager-center.is-loading .manager-actions-card {
opacity: 0.72;
}
.manager-center.is-denied .manager-action-grid {
opacity: 0.46;
pointer-events: none;
}
.manager-panel { .manager-panel {
position: relative; position: relative;
display: grid; display: grid;
@ -392,6 +933,7 @@
} }
.searched-meta, .searched-meta,
.searched-vip,
.blocked-meta, .blocked-meta,
.blocked-time { .blocked-time {
margin-top: 3px; margin-top: 3px;
@ -404,6 +946,10 @@
white-space: nowrap; white-space: nowrap;
} }
.searched-vip {
color: #7d57c7;
}
.user-block-button { .user-block-button {
min-width: 70px; min-width: 70px;
height: 40px; height: 40px;
@ -486,14 +1032,114 @@
color: #fff; color: #fff;
} }
.duration-grid button:disabled,
.level-grid button:disabled,
.track-grid button:disabled,
.grant-option-grid button:disabled {
cursor: not-allowed;
border-color: rgba(125, 87, 199, 0.12);
background: #f3eef8;
color: #b5a9c5;
}
.grant-option-grid { .grant-option-grid {
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
} }
.grant-option-grid.is-resource-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.vip-grant-note {
grid-column: 1 / -1;
color: #7d6e90;
font-size: 12px;
font-weight: 800;
line-height: 1.3;
}
.resource-selected-note {
grid-column: 1 / -1;
color: #7d6e90;
font-size: 12px;
font-weight: 900;
line-height: 1.3;
}
.grant-resource-option { .grant-resource-option {
position: relative;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; display: grid;
white-space: nowrap; min-height: 96px;
place-items: center;
padding: 10px;
border-color: rgba(125, 87, 199, 0.14);
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.96), #fff),
radial-gradient(
circle at 50% 24%,
rgba(125, 87, 199, 0.12),
transparent 58%
);
}
.grant-resource-option::after {
position: absolute;
top: 7px;
right: 7px;
width: 18px;
height: 18px;
content: '';
border: 2px solid rgba(125, 87, 199, 0.2);
border-radius: 999px;
background: #fff;
}
.grant-resource-option.is-selected {
border-color: var(--primary-deep);
background:
linear-gradient(180deg, rgba(248, 243, 255, 0.98), #fff),
radial-gradient(
circle at 50% 24%,
rgba(125, 87, 199, 0.2),
transparent 58%
);
color: var(--text);
box-shadow: inset 0 0 0 2px rgba(125, 87, 199, 0.22);
}
.grant-resource-option.is-selected::after {
border-color: var(--primary-deep);
background: var(--primary-deep);
box-shadow: inset 0 0 0 4px #fff;
}
.grant-resource-media {
display: flex;
width: 68px;
height: 68px;
align-items: center;
justify-content: center;
}
.grant-resource-media img {
display: block;
max-width: 100%;
max-height: 100%;
object-fit: contain;
}
.grant-resource-fallback {
width: 58px;
height: 58px;
border-radius: 50%;
background:
linear-gradient(
135deg,
rgba(125, 87, 199, 0.28),
rgba(78, 120, 215, 0.18)
),
#f6f0ff;
} }
.country-option { .country-option {

View File

@ -3,6 +3,8 @@
"version": "1.0.0", "version": "1.0.0",
"description": "H5 project", "description": "H5 project",
"scripts": { "scripts": {
"dev:h5": "python3 scripts/h5_dev_server.py --host 127.0.0.1 --port 8788 --directory .",
"dev:manager-center": "python3 scripts/h5_dev_server.py --host 127.0.0.1 --port 8788 --directory .",
"format": "prettier --write \"**/*.{js,html,css,json,md}\"", "format": "prettier --write \"**/*.{js,html,css,json,md}\"",
"format:check": "prettier --check \"**/*.{js,html,css,json,md}\"" "format:check": "prettier --check \"**/*.{js,html,css,json,md}\""
}, },

View File

@ -12,6 +12,7 @@ import urllib.request
PROXIES = { PROXIES = {
"/__api_prod__/": "https://api.global-interaction.com/",
"/__api_test__/": "https://api-test.global-interaction.com/", "/__api_test__/": "https://api-test.global-interaction.com/",
"/__api_local__/": "http://127.0.0.1:13000/", "/__api_local__/": "http://127.0.0.1:13000/",
} }
@ -105,6 +106,8 @@ class H5DevHandler(http.server.SimpleHTTPRequestHandler):
length = int(self.headers.get("Content-Length", "0") or "0") length = int(self.headers.get("Content-Length", "0") or "0")
body = self.rfile.read(length) if length > 0 else b"" body = self.rfile.read(length) if length > 0 else b""
# 代理只转发业务相关 headerHost、Connection 等 hop-by-hop header 必须由 urllib 按目标域重建,
# 否则生产网关会看到本地 Host 或错误的连接语义,排查时会像业务接口异常。
headers = { headers = {
key: value key: value
for key, value in self.headers.items() for key, value in self.headers.items()
@ -179,7 +182,7 @@ class ReusableTCPServer(socketserver.ThreadingTCPServer):
def main() -> None: def main() -> None:
parser = argparse.ArgumentParser() parser = argparse.ArgumentParser()
parser.add_argument("--host", default="127.0.0.1") parser.add_argument("--host", default="127.0.0.1")
parser.add_argument("--port", type=int, default=30000) parser.add_argument("--port", type=int, default=8788)
parser.add_argument("--directory", default=os.getcwd()) parser.add_argument("--directory", default=os.getcwd())
args = parser.parse_args() args = parser.parse_args()
@ -190,6 +193,8 @@ def main() -> None:
) )
with ReusableTCPServer((args.host, args.port), handler) as server: with ReusableTCPServer((args.host, args.port), handler) as server:
print(f"Serving H5 at http://{args.host}:{args.port}/") print(f"Serving H5 at http://{args.host}:{args.port}/")
print(f"Manager center: http://{args.host}:{args.port}/gonghui/manager-center/")
print("API proxy: /__api_prod__/ -> https://api.global-interaction.com/")
print("API proxy: /__api_test__/ -> https://api-test.global-interaction.com/") print("API proxy: /__api_test__/ -> https://api-test.global-interaction.com/")
print("API proxy: /__api_local__/ -> http://127.0.0.1:13000/") print("API proxy: /__api_local__/ -> http://127.0.0.1:13000/")
server.serve_forever() server.serve_forever()

641
yumi_commmon/yumi-common.js Normal file
View File

@ -0,0 +1,641 @@
(function () {
'use strict';
var PROD_API_BASE = 'https://jvapi.haiyihy.com';
var TEST_API_BASE = 'https://jvapi-test.haiyihy.com';
var LOCAL_API_BASE = 'http://127.0.0.1:13000';
var APP_CODE = 'yumi';
var RELATION_TYPE_BY_TAB = {
cp: 'CP',
brother: 'BROTHER',
sister: 'SISTERS',
};
var headers = {
authorization: '',
'req-app-intel':
'version=1.0.0;build=1;model=H5;sysVersion=web;channel=Web',
'req-client': 'H5',
'req-imei': 'H5-STATIC',
'req-lang': 'en',
'req-sys-origin': 'origin=YUMI;originChild=YUMI',
'req-version': 'V2',
'req-zone': 'Asia/Shanghai',
};
var readyPromise = null;
function currentParams() {
var params = new URLSearchParams(window.location.search || '');
var hash = window.location.hash || '';
if (hash.indexOf('?') >= 0) {
new URLSearchParams(hash.split('?').slice(1).join('?')).forEach(
function (value, key) {
if (!params.has(key)) params.set(key, value);
}
);
}
return params;
}
function query(name) {
return currentParams().get(name);
}
function normalizeBaseURL(value) {
return String(value || '').replace(/\/+$/, '');
}
function resolveEnv() {
var env = String(query('env') || '')
.trim()
.toLowerCase();
return env === 'test' || env === 'local' || env === 'dev' ? env : '';
}
function baseURL() {
var config = window.YumiCommonConfig || {};
var override =
query('apiBase') ||
query('api_base') ||
query('yumi_api_base') ||
window.YUMI_API_BASE_URL ||
config.apiBase;
if (override) return normalizeBaseURL(override);
var env = resolveEnv();
if (env === 'local')
return normalizeBaseURL(config.localApi || LOCAL_API_BASE);
if (env === 'test' || env === 'dev')
return normalizeBaseURL(config.testApi || TEST_API_BASE);
return normalizeBaseURL(config.prodApi || PROD_API_BASE);
}
function buildURL(path, queryData) {
var url = new URL(
String(path || '').replace(/^\/+/, ''),
baseURL() + '/'
);
Object.keys(queryData || {}).forEach(function (key) {
var value = queryData[key];
if (value === undefined || value === null || value === '') return;
url.searchParams.set(key, value);
});
return url.toString();
}
function normalizeAuthorization(value) {
var text = String(value || '').trim();
if (!text) return '';
return /^Bearer\s+/i.test(text) ? text : 'Bearer ' + text;
}
function tokenForHyAppAPI(value) {
return String(value || '').replace(/^Bearer\s+/i, '');
}
function splitHeaderPairs(value) {
var result = {};
String(value || '')
.split(';')
.forEach(function (item) {
var index = item.indexOf('=');
if (index > -1)
result[item.slice(0, index)] = item.slice(index + 1);
});
return result;
}
function putHeader(target, key, value) {
if (
value === undefined ||
value === null ||
value === '' ||
target[key]
)
return;
target[key] = String(value);
}
function normalizeHeaderCandidate(value, depth) {
depth = depth || 0;
if (depth > 4 || value === null || value === undefined) return {};
if (typeof value === 'string') {
var trimmed = value.trim();
if (!trimmed) return {};
if (trimmed.charAt(0) === '{' || trimmed.charAt(0) === '[') {
try {
return normalizeHeaderCandidate(
JSON.parse(trimmed),
depth + 1
);
} catch (_) {
return {};
}
}
return trimmed ? { authorization: trimmed } : {};
}
if (Array.isArray(value)) {
return value.reduce(function (result, item) {
return Object.assign(
result,
normalizeHeaderCandidate(item, depth + 1)
);
}, {});
}
if (typeof value !== 'object') return {};
var appIntel = splitHeaderPairs(
value['Req-App-Intel'] || value['req-app-intel']
);
var sysOrigin = splitHeaderPairs(
value['Req-Sys-Origin'] || value['req-sys-origin']
);
var normalized = {};
putHeader(
normalized,
'authorization',
value.Authorization ||
value.authorization ||
value.token ||
value.accessToken
);
putHeader(
normalized,
'reqLang',
value['Req-Lang'] ||
value['req-lang'] ||
value.reqLang ||
value.lang
);
putHeader(
normalized,
'appVersion',
appIntel.version || value.appVersion || value['App-Version']
);
putHeader(
normalized,
'buildVersion',
appIntel.build || value.buildVersion
);
putHeader(normalized, 'model', appIntel.model || value.model);
putHeader(normalized, 'channel', appIntel.channel || value.channel);
putHeader(
normalized,
'reqImei',
value['Req-Imei'] || value['req-imei'] || value.reqImei
);
putHeader(
normalized,
'origin',
sysOrigin.origin || value.sysOrigin || value.origin
);
putHeader(
normalized,
'child',
sysOrigin.originChild ||
sysOrigin.child ||
value.sysOriginChild ||
value.child
);
['headerInfo', 'headers', 'auth', 'data', 'state'].forEach(
function (key) {
Object.assign(
normalized,
normalizeHeaderCandidate(value[key], depth + 1)
);
}
);
return normalized;
}
function setHeadersFromApp(rawOrObject) {
var data = normalizeHeaderCandidate(rawOrObject || {});
var next = {};
if (data.authorization)
next.authorization = normalizeAuthorization(data.authorization);
if (data.reqLang) next['req-lang'] = data.reqLang;
if (
data.appVersion ||
data.buildVersion ||
data.model ||
data.channel
) {
next['req-app-intel'] = [
data.appVersion ? 'version=' + data.appVersion : '',
data.buildVersion ? 'build=' + data.buildVersion : '',
data.model ? 'model=' + data.model : '',
data.channel ? 'channel=' + data.channel : '',
]
.filter(Boolean)
.join(';');
}
if (data.reqImei) next['req-imei'] = data.reqImei;
if (data.origin) {
next['req-sys-origin'] = [
'origin=' + data.origin,
'originChild=' + (data.child || data.origin),
].join(';');
}
headers = Object.assign(headers, next);
if (window.HyAppAPI) {
if (next.authorization)
window.HyAppAPI.setAccessToken(
tokenForHyAppAPI(next.authorization)
);
window.HyAppAPI.setAppCode(APP_CODE);
}
return headers;
}
function discoverQueryHeaders() {
return {
authorization:
query('authorization') ||
query('Authorization') ||
query('token') ||
query('accessToken') ||
query('access_token'),
reqLang: query('lang') || query('language') || query('locale'),
origin: query('sysOrigin') || query('origin') || 'YUMI',
child: query('sysOriginChild') || query('originChild') || 'YUMI',
channel: query('channel') || query('appChannel'),
};
}
function readStorageHeaders(storage) {
var collected = {};
if (!storage) return collected;
[
'authorization',
'Authorization',
'token',
'accessToken',
'headerInfo',
'headersFromApp',
'appHeaders',
'user',
'userInfo',
].forEach(function (key) {
try {
Object.assign(
collected,
normalizeHeaderCandidate(storage.getItem(key))
);
} catch (_) {}
});
return collected;
}
function applyDiscoveredHeaders() {
setHeadersFromApp(discoverQueryHeaders());
setHeadersFromApp(window.__APP_HEADERS__);
setHeadersFromApp(window.__HEADER_INFO__);
setHeadersFromApp(window.headerInfo);
try {
setHeadersFromApp(readStorageHeaders(window.localStorage));
setHeadersFromApp(readStorageHeaders(window.sessionStorage));
} catch (_) {}
}
function ensureYumiAppCode() {
if (window.HyAppAPI) window.HyAppAPI.setAppCode(APP_CODE);
var params = currentParams();
if (
params.get('app_code') ||
params.get('app') ||
params.get('appCode')
)
return;
if (!window.history || !window.history.replaceState) return;
var url = new URL(window.location.href);
url.searchParams.set('app_code', APP_CODE);
// 目标仓 common/api.js 没有 app_code 会默认走 laluYumi 页入口必须显式落到 Yumi 租户。
window.history.replaceState(null, '', url.toString());
}
function isAppEnvironment() {
return Boolean(
window.app ||
window.FlutterApp ||
window.FlutterPageControl ||
window.webkit
);
}
function requestAccessOrigin() {
return new Promise(function (resolve) {
var settled = false;
var timeoutId = null;
function finish(raw) {
if (raw) setHeadersFromApp(raw);
if (settled) return;
settled = true;
if (timeoutId) window.clearTimeout(timeoutId);
resolve(raw || null);
}
window.renderData = finish;
window.getIosAccessOriginParam = finish;
if (!isAppEnvironment()) {
finish(null);
return;
}
// Android 普通 WebView 会注入 window.app.getAccessOriginiOS/旧页可能只通过回调异步回填。
if (
window.app &&
typeof window.app.getAccessOrigin === 'function'
) {
try {
finish(window.app.getAccessOrigin());
return;
} catch (_) {}
}
if (
window.FlutterApp &&
typeof window.FlutterApp.postMessage === 'function'
) {
try {
window.FlutterApp.postMessage('requestAccessOrigin');
} catch (_) {}
}
var attempt = 0;
var poll = function () {
attempt += 1;
if (
window.app &&
typeof window.app.getAccessOrigin === 'function'
) {
try {
finish(window.app.getAccessOrigin());
return;
} catch (_) {}
}
if (!settled && attempt < 30) window.setTimeout(poll, 60);
};
poll();
timeoutId = window.setTimeout(function () {
finish(null);
}, 1800);
});
}
function connectToApp() {
if (readyPromise) return readyPromise;
ensureYumiAppCode();
applyDiscoveredHeaders();
readyPromise = requestAccessOrigin().then(function () {
return {
baseURL: baseURL(),
headers: Object.assign({}, headers),
authenticated: Boolean(headers.authorization),
};
});
return readyPromise;
}
function parseResponse(response) {
return response.text().then(function (text) {
var payload = text ? JSON.parse(text) : {};
if (!response.ok) {
var httpError = new Error(
payload.message || response.statusText || 'request_failed'
);
httpError.status = response.status;
httpError.payload = payload;
throw httpError;
}
if (payload && payload.status === false) {
var apiError = new Error(
payload.message || payload.errorMsg || 'api_request_failed'
);
apiError.payload = payload;
throw apiError;
}
if (
payload &&
Object.prototype.hasOwnProperty.call(payload, 'body')
)
return payload.body;
if (
payload &&
Object.prototype.hasOwnProperty.call(payload, 'data')
)
return payload.data;
return payload;
});
}
function request(path, options) {
var opts = options || {};
var body = opts.body;
if (body && typeof body === 'object' && !(body instanceof FormData)) {
body = JSON.stringify(body);
}
return connectToApp().then(function () {
var requestHeaders = Object.assign({}, headers, opts.headers || {});
if (!requestHeaders.authorization)
delete requestHeaders.authorization;
if (body)
requestHeaders['content-type'] =
requestHeaders['content-type'] || 'application/json';
return window
.fetch(buildURL(path, opts.query), {
method: opts.method || 'GET',
headers: requestHeaders,
body: body,
credentials: 'omit',
cache: 'no-store',
})
.then(parseResponse);
});
}
function readNumber(value, fallback) {
var number = Number(value);
return Number.isFinite(number) ? number : fallback || 0;
}
function relationType(tabOrType) {
var key = String(tabOrType || 'cp')
.trim()
.toLowerCase();
return (
RELATION_TYPE_BY_TAB[key] || String(tabOrType || 'CP').toUpperCase()
);
}
function userAvatar(profile) {
profile = profile || {};
return (
profile.userAvatar ||
profile.avatar ||
profile.headImg ||
profile.headImage ||
''
);
}
function userId(profile) {
profile = profile || {};
return profile.id || profile.userId || profile.uid || '';
}
function choosePair(list, type) {
var rows = Array.isArray(list) ? list : list ? [list] : [];
for (var i = 0; i < rows.length; i += 1) {
if (relationType(rows[i].relationType) === type) return rows[i];
}
return rows[0] || null;
}
function sortedLevels(levels) {
return (Array.isArray(levels) ? levels : [])
.slice()
.sort(function (a, b) {
return readNumber(a.level, 0) - readNumber(b.level, 0);
});
}
function levelProgress(rights) {
var intimacy = readNumber(rights && rights.intimacyValue, 0);
var levels = sortedLevels(rights && rights.levels);
var current = levels[0] || { level: 0, requiredIntimacyValue: 0 };
var next = levels[1] || current;
levels.forEach(function (level, index) {
if (readNumber(level.requiredIntimacyValue, 0) <= intimacy) {
current = level;
next = levels[index + 1] || level;
}
});
var currentThreshold = readNumber(current.requiredIntimacyValue, 0);
var nextThreshold = Math.max(
readNumber(next.requiredIntimacyValue, currentThreshold),
currentThreshold
);
var currentExp = Math.max(0, intimacy - currentThreshold);
var nextExp = Math.max(0, nextThreshold - currentThreshold);
var progressPercent = nextExp ? (currentExp / nextExp) * 100 : 100;
return {
currentLevel: readNumber(current.level, 0),
nextLevel: readNumber(next.level, current.level),
currentExp: currentExp,
nextExp: nextExp || intimacy,
neededExp: Math.max(0, nextThreshold - intimacy),
nextExpLevel: readNumber(next.level, current.level),
progressPercent: Math.max(0, Math.min(100, progressPercent)),
};
}
function normalizeRelation(pair, rights) {
pair = pair || {};
rights = rights || {};
var progress = levelProgress(rights);
var me = pair.meUserProfile || pair.me || {};
var partner = pair.cpUserProfile || pair.partner || {};
return Object.assign(progress, {
relationType: relationType(
pair.relationType || rights.relationType
),
status: rights.relationshipStatus || pair.status || 'NONE',
cpUserId: rights.cpUserId || userId(partner),
days: readNumber(pair.days, 0),
togetherDays: readNumber(pair.days, 0),
me: { avatar: userAvatar(me), id: userId(me) },
partner: { avatar: userAvatar(partner), id: userId(partner) },
raw: { pair: pair, rights: rights },
});
}
function pair(options) {
var opts = options || {};
return request('/user/cp-relationship/pair', {
query: {
relationType: relationType(opts.relationType || opts.tab),
userId: opts.userId,
},
});
}
function rights(options) {
var opts = options || {};
return request('/user/cp/rights/config', {
query: {
relationType: relationType(opts.relationType || opts.tab),
cpUserId: opts.cpUserId,
},
});
}
function loadRelation(options) {
var opts = options || {};
var type = relationType(opts.relationType || opts.tab);
var selectedPair = null;
var firstError = null;
return pair({ relationType: type, userId: opts.userId })
.then(function (pairList) {
selectedPair = choosePair(pairList, type);
var cpUserId =
opts.cpUserId ||
(selectedPair && userId(selectedPair.cpUserProfile)) ||
'';
return rights({ relationType: type, cpUserId: cpUserId });
})
.catch(function (error) {
firstError = error;
return rights({ relationType: type, cpUserId: opts.cpUserId });
})
.then(function (rightsData) {
return normalizeRelation(selectedPair, rightsData);
})
.catch(function (error) {
throw firstError || error;
});
}
function back() {
if (
window.FlutterPageControl &&
window.FlutterPageControl.postMessage
) {
window.FlutterPageControl.postMessage('close_page');
return;
}
if (window.HyAppBridge && window.HyAppBridge.back) {
window.HyAppBridge.back();
return;
}
window.history.back();
}
window.YumiCommon = {
baseURL: baseURL,
buildURL: buildURL,
headers: function () {
return Object.assign({}, headers);
},
connectToApp: connectToApp,
setHeadersFromApp: setHeadersFromApp,
request: request,
get: function (path, queryData) {
return request(path, { method: 'GET', query: queryData });
},
post: function (path, body, queryData) {
return request(path, {
method: 'POST',
body: body,
query: queryData,
});
},
back: back,
cpSpace: {
relationType: relationType,
pair: pair,
rights: rights,
loadRelation: loadRelation,
normalizeRelation: normalizeRelation,
},
};
})();