diff --git a/.claude/launch.json b/.claude/launch.json new file mode 100644 index 0000000..0c4a2ed --- /dev/null +++ b/.claude/launch.json @@ -0,0 +1,11 @@ +{ + "version": "0.0.1", + "configurations": [ + { + "name": "h5-static", + "runtimeExecutable": "npx", + "runtimeArgs": ["--yes", "http-server", "-p", "18080", "-c-1"], + "port": 18080 + } + ] +} diff --git a/activity/cp-space/yumi.css b/activity/cp-space/yumi.css index 216ab3d..b7557de 100644 --- a/activity/cp-space/yumi.css +++ b/activity/cp-space/yumi.css @@ -34,6 +34,10 @@ button { } .yumi-cp-stage { + --stage-height: 812px; + --design-width: 375px; + --design-height: 812px; + --design-scale: 1; --accent: #ff0979; --accent-soft: #ff5762; --tab-bg: rgba(94, 13, 35, 0.8); @@ -47,14 +51,22 @@ button { --progress-to: #ffcf67; --exp-pill-bg: rgba(255, 102, 169, 0.74); position: relative; - width: min(100vw, 375px); - height: min(812px, 216.533333vw); - max-height: 812px; + width: 100%; + height: var(--stage-height); overflow: hidden; background: #440b23; 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 { --accent: #5709ff; --accent-soft: #5709ff; @@ -108,7 +120,6 @@ button { object-fit: fill; } -.yumi-status, .yumi-nav, .yumi-tabs, .yumi-card { @@ -116,146 +127,9 @@ button { 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 { left: 0; - top: 44px; + top: 0; width: 375px; height: 44px; } @@ -318,7 +192,7 @@ button { .yumi-tabs { left: 16px; - top: 109px; + top: 65px; display: grid; grid-template-columns: repeat(3, 1fr); width: 343px; @@ -362,7 +236,7 @@ button { .yumi-card { left: 14px; - top: 169px; + top: 125px; width: 347px; height: 199px; color: var(--accent); @@ -636,13 +510,3 @@ button { [dir='rtl'] .yumi-tabs { direction: ltr; } - -@media (max-width: 374.98px) { - .yumi-status, - .yumi-nav, - .yumi-tabs, - .yumi-card { - transform: scale(calc(100vw / 375)); - transform-origin: left top; - } -} diff --git a/activity/cp-space/yumi.html b/activity/cp-space/yumi.html index d08f68d..cb43cdf 100644 --- a/activity/cp-space/yumi.html +++ b/activity/cp-space/yumi.html @@ -12,7 +12,7 @@ rel="stylesheet" href="../../common/theme.css?v=20260708-yumi-layered" /> - +
- - - - - - - - -
- -
- - -
- -
- - -
- -
- - 999 - Days -
- -
- 888 Exp away from Lv. 2 -
- -
-
+ +

+ Best Friend +

+ + + + + +
+ + +
+ - Lv1 -
-
+ + +
- 1000/200000 +
+ +
+ + 999 + Days
-
- - Lv1 + +
+ 888 Exp away from Lv. 2
-

- Time spent together: 4 days -

-
-
+ +
+
+ + Lv1 +
+
+ + 1000/200000 +
+
+ + Lv1 +
+

+ Time spent together: 4 days +

+
+ +
@@ -201,6 +204,7 @@ - + + diff --git a/activity/cp-space/yumi.js b/activity/cp-space/yumi.js index a0a5bee..145ee56 100644 --- a/activity/cp-space/yumi.js +++ b/activity/cp-space/yumi.js @@ -25,20 +25,21 @@ }, }; var DEFAULT_RELATION = { - days: 999, - togetherDays: 4, - currentLevel: 1, - nextLevel: 1, - currentExp: 1000, - nextExp: 200000, - neededExp: 888, - nextExpLevel: 2, - progressPercent: 22.3, + days: 0, + togetherDays: 0, + currentLevel: 0, + nextLevel: 0, + currentExp: 0, + nextExp: 0, + neededExp: 0, + nextExpLevel: 0, + progressPercent: 0, me: { avatar: '' }, partner: { avatar: '' }, }; var page = document.getElementById('yumiCpPage'); + var stage = document.querySelector('.yumi-cp-stage'); var backButton = document.getElementById('yumiBackButton'); var helpButton = document.getElementById('yumiHelpButton'); var tabButtons = Array.prototype.slice.call( @@ -63,7 +64,10 @@ var state = { activeTab: normalizeTab(readParam('tab')) || 'cp', data: clone(DEFAULT_RELATION), + tabData: {}, + requestSeq: 0, }; + var stageResizeObserver = null; function clone(value) { return JSON.parse(JSON.stringify(value)); @@ -86,6 +90,15 @@ 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) { if (window.HyAppI18n && window.HyAppI18n.t) { return window.HyAppI18n.t(key, fallback); @@ -102,6 +115,28 @@ 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) { if (!window.history || !window.history.replaceState) return; var url = new URL(window.location.href); @@ -132,28 +167,31 @@ return { days: readNumber(data.days, DEFAULT_RELATION.days), togetherDays: readNumber( - data.togetherDays || data.together_days, + firstValue(data.togetherDays, data.together_days), DEFAULT_RELATION.togetherDays ), currentLevel: readNumber( - data.currentLevel || data.current_level || data.level, - 1 + firstValue(data.currentLevel, data.current_level, data.level), + DEFAULT_RELATION.currentLevel + ), + nextLevel: readNumber( + firstValue(data.nextLevel, data.next_level), + DEFAULT_RELATION.nextLevel ), - nextLevel: readNumber(data.nextLevel || data.next_level, 1), currentExp: readNumber( - data.currentExp || data.current_exp, + firstValue(data.currentExp, data.current_exp), DEFAULT_RELATION.currentExp ), nextExp: readNumber( - data.nextExp || data.next_exp, + firstValue(data.nextExp, data.next_exp), DEFAULT_RELATION.nextExp ), neededExp: readNumber( - data.neededExp || data.needed_exp, + firstValue(data.neededExp, data.needed_exp), DEFAULT_RELATION.neededExp ), nextExpLevel: readNumber( - data.nextExpLevel || data.next_exp_level, + firstValue(data.nextExpLevel, data.next_exp_level), DEFAULT_RELATION.nextExpLevel ), progressPercent: Math.max( @@ -161,7 +199,7 @@ Math.min( 100, readNumber( - data.progressPercent || data.progress_percent, + firstValue(data.progressPercent, data.progress_percent), DEFAULT_RELATION.progressPercent ) ) @@ -237,18 +275,59 @@ var data = payload || {}; var tabData = data[state.activeTab] || data.relation || data; state.data = normalizedRelation(tabData); + state.tabData[state.activeTab] = state.data; 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() { tabButtons.forEach(function (button) { 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) { backButton.addEventListener('click', function () { + if (window.YumiCommon && window.YumiCommon.back) { + window.YumiCommon.back(); + return; + } if (window.HyAppBridge && window.HyAppBridge.back) { window.HyAppBridge.back(); return; @@ -274,13 +353,16 @@ }); } + bindStageScale(); bindEvents(); if (window.HyAppCPSpaceYumiData) applyExternalData(window.HyAppCPSpaceYumiData); setActiveTab(state.activeTab, { syncURL: false }); + showTabData(state.activeTab); + loadTabData(state.activeTab); if (window.HyAppBridge && window.HyAppBridge.ready) { - // ready 只声明页面能力;真实关系数据由客户端事件或网关注入,避免 H5 自行判断关系状态。 + // ready 只声明页面能力;真实关系数据由 YumiCommon 接口层拉取,客户端仍可通过事件覆盖。 window.HyAppBridge.ready({ page: 'yumi-cp-space' }); } })(); diff --git a/common/api.js b/common/api.js index 95a1379..a601201 100644 --- a/common/api.js +++ b/common/api.js @@ -153,6 +153,14 @@ var default_api = 'https://api.global-interaction.com/'; 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() { var env = readQuery('env'); if (env === 'test' || env === 'local') { @@ -169,10 +177,9 @@ var default_api = 'https://api.global-interaction.com/'; var env = resolveEnv(); var configuredAPI = activeAppAPIBaseURL(env); if (configuredAPI) return configuredAPI; - if (env === 'test') - return isLocalDevOrigin() - ? devProxyBase('/__api_test__/') - : normalizeBaseURL(test_api); + var devProxy = localDevProxyBase(env); + if (devProxy) return devProxy; + if (env === 'test') return normalizeBaseURL(test_api); if (env === 'local') return normalizeBaseURL(local_api); return normalizeBaseURL(default_api); } @@ -597,6 +604,32 @@ var default_api = 'https://api.global-interaction.com/'; 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 统一处理。 diff --git a/common/loading.js b/common/loading.js new file mode 100644 index 0000000..a7d7d8b --- /dev/null +++ b/common/loading.js @@ -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); + }); +})(); diff --git a/common/locales/ar.json b/common/locales/ar.json index e5e44b4..f7df6af 100644 --- a/common/locales/ar.json +++ b/common/locales/ar.json @@ -479,6 +479,21 @@ "coinseller_center.loading": "جار التحميل...", "coinseller_center.load_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.frame_duration": "3 أيام", "lalu_landing.join_party": "انضم إلى الحفلة", @@ -715,5 +730,30 @@ "contact_card.required": "أدخل جهة اتصال WhatsApp.", "contact_card.api_unavailable": "واجهة جهة الاتصال غير متاحة.", "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": "مستوى المستخدم" } diff --git a/common/locales/en.json b/common/locales/en.json index 403ad1f..fdbc218 100644 --- a/common/locales/en.json +++ b/common/locales/en.json @@ -485,6 +485,21 @@ "coinseller_center.loading": "Loading...", "coinseller_center.load_failed": "Load failed. Try again later.", "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.frame_duration": "3 days", "lalu_landing.join_party": "Join party", @@ -721,5 +736,30 @@ "contact_card.required": "Enter WhatsApp contact.", "contact_card.api_unavailable": "Contact API unavailable.", "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" } diff --git a/common/locales/es.json b/common/locales/es.json index 695f8b8..deaadf3 100644 --- a/common/locales/es.json +++ b/common/locales/es.json @@ -479,6 +479,21 @@ "coinseller_center.loading": "Cargando...", "coinseller_center.load_failed": "Error al cargar. Inténtalo de nuevo más tarde.", "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.frame_duration": "3 días", "lalu_landing.join_party": "Unirse a la fiesta", @@ -715,5 +730,30 @@ "contact_card.required": "Ingresa el contacto de WhatsApp.", "contact_card.api_unavailable": "API de contacto no disponible.", "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" } diff --git a/common/locales/id.json b/common/locales/id.json index fc9451a..f6becb4 100644 --- a/common/locales/id.json +++ b/common/locales/id.json @@ -446,6 +446,21 @@ "coinseller_center.loading": "Memuat...", "coinseller_center.load_failed": "Gagal memuat. Coba lagi nanti.", "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.frame_duration": "3 hari", "lalu_landing.join_party": "Gabung party", @@ -643,5 +658,30 @@ "contact_card.required": "Masukkan kontak WhatsApp.", "contact_card.api_unavailable": "API kontak tidak tersedia.", "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" } diff --git a/common/locales/tr.json b/common/locales/tr.json index 933ce4b..fa4692c 100644 --- a/common/locales/tr.json +++ b/common/locales/tr.json @@ -446,6 +446,21 @@ "coinseller_center.loading": "Yükleniyor...", "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.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.frame_duration": "3 gün", "lalu_landing.join_party": "Partiye katıl", @@ -682,5 +697,30 @@ "contact_card.required": "WhatsApp iletişimini gir.", "contact_card.api_unavailable": "İletişim API kullanılamıyor.", "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" } diff --git a/common/locales/zh.json b/common/locales/zh.json index 97425fd..47095fc 100644 --- a/common/locales/zh.json +++ b/common/locales/zh.json @@ -452,6 +452,21 @@ "coinseller_center.loading": "加载中...", "coinseller_center.load_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.frame_duration": "3天", "lalu_landing.join_party": "加入派对", @@ -649,5 +664,30 @@ "contact_card.required": "请输入 WhatsApp 联系方式。", "contact_card.api_unavailable": "联系方式接口不可用。", "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": "用户等级" } diff --git a/common/skeleton.js b/common/skeleton.js new file mode 100644 index 0000000..9f3d15c --- /dev/null +++ b/common/skeleton.js @@ -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, + }; +})(); diff --git a/common/theme.css b/common/theme.css index 04b8e3a..6c2d668 100644 --- a/common/theme.css +++ b/common/theme.css @@ -170,3 +170,165 @@ 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; + } +} diff --git a/gonghui/coinseller-center/index.html b/gonghui/coinseller-center/index.html index e6060f9..561c038 100644 --- a/gonghui/coinseller-center/index.html +++ b/gonghui/coinseller-center/index.html @@ -112,6 +112,38 @@ + +
@@ -131,7 +163,9 @@ > @@ -152,34 +186,34 @@ > Search - - + + - -
+ + -
+

@@ -241,7 +275,10 @@

-
+
- -
+ +
+ +
+ + + +
@@ -434,9 +621,9 @@ - + - + diff --git a/gonghui/coinseller-center/script.js b/gonghui/coinseller-center/script.js index e7abbf7..c9be237 100644 --- a/gonghui/coinseller-center/script.js +++ b/gonghui/coinseller-center/script.js @@ -79,6 +79,29 @@ 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 = { @@ -87,6 +110,8 @@ users: [], quickAmounts: mock.quickAmounts.slice(), records: [], + subSellerPermission: emptySubSellerPermission(), + subSellers: [], }; var state = { @@ -99,6 +124,11 @@ loading: query.get('mock') !== '1', searching: false, submitting: false, + subSellerLoading: false, + invitingSubSeller: false, + subSellerTransferTarget: null, + subSellerTransferAmount: '', + subSellerTransferSubmitting: false, }; function $(id) { @@ -140,6 +170,14 @@ }; } + function emptySubSellerPermission() { + return { + visible: false, + canInvite: false, + canTransfer: false, + }; + } + function initial(text) { return String(text || '') .trim() @@ -163,6 +201,15 @@ 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) { if (!source) return ''; 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) { if (!image || !fallback) return; 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() { data.profile = Object.assign({}, mock.profile); data.balance = Object.assign({}, mock.balance); data.users = mock.users.slice(); data.quickAmounts = mock.quickAmounts.slice(); data.records = mock.records.slice(); + data.subSellerPermission = Object.assign({}, mock.subSellerPermission); + data.subSellers = mock.subSellers.slice(); } function applyEmptyData() { @@ -550,6 +722,51 @@ data.users = []; data.quickAmounts = mock.quickAmounts.slice(); 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() { @@ -584,6 +801,7 @@ return null; }) : Promise.resolve(null), + loadSubSellerCapability(), ]) .then(function (results) { 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() { replaceChildren( $('quickAmounts'), @@ -917,6 +1216,15 @@ ].join('-'); } + function subSellerCommandID(action) { + return [ + 'coin-seller-sub-seller', + action || 'action', + Date.now(), + Math.random().toString(36).slice(2, 8), + ].join('-'); + } + function buildTransferPayload(amount) { return { 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() { var amount = parsedCoinAmount(); if (!state.target) { @@ -1058,6 +1664,23 @@ 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() { var now = new Date(); return ( @@ -1112,6 +1735,14 @@ $('helpButton').addEventListener('click', function () { openModal('helpModal'); }); + $('inviteSubSellerButton').addEventListener('click', function () { + $('inviteSubSellerInput').value = ''; + setStatus($('inviteSubSellerStatus'), '', ''); + openModal('inviteSubSellerModal'); + window.setTimeout(function () { + $('inviteSubSellerInput').focus(); + }, 80); + }); document .querySelectorAll('[data-close-modal]') .forEach(function (node) { @@ -1153,6 +1784,26 @@ event.preventDefault(); 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) { tab.addEventListener('click', function () { state.historyTab = tab.getAttribute('data-history-tab'); @@ -1180,6 +1831,8 @@ if (event.key !== 'Escape') return; closeModal('historyModal'); closeModal('helpModal'); + closeModal('inviteSubSellerModal'); + closeModal('subSellerTransferModal'); }); } @@ -1189,6 +1842,8 @@ renderQuickAmounts(); renderTarget(); renderRechargeState(); + renderSubSellers(); + renderSubSellerTransferState(); renderRecent(); } diff --git a/gonghui/coinseller-center/style.css b/gonghui/coinseller-center/style.css index 32af4a9..d585a9d 100644 --- a/gonghui/coinseller-center/style.css +++ b/gonghui/coinseller-center/style.css @@ -15,6 +15,7 @@ } .balance-card, +.sub-seller-card, .search-card, .recent-card { padding: 16px; @@ -118,6 +119,7 @@ } .search-card, +.sub-seller-card, .recent-card { display: grid; gap: 14px; @@ -205,6 +207,10 @@ display: none; } +.sub-seller-card[hidden] { + display: none; +} + .target-avatar, .record-avatar { display: flex; @@ -373,6 +379,54 @@ 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 { display: grid; grid-template-columns: 42px minmax(0, 1fr) auto; @@ -515,6 +569,52 @@ 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 p { margin: 0; @@ -565,4 +665,13 @@ body.modal-open .coinseller-center { .history-search-form { 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; + } } diff --git a/gonghui/manager-center/assets/demo-avatar.png b/gonghui/manager-center/assets/demo-avatar.png new file mode 100644 index 0000000..fb4f034 Binary files /dev/null and b/gonghui/manager-center/assets/demo-avatar.png differ diff --git a/gonghui/manager-center/index.html b/gonghui/manager-center/index.html index 7efbb74..79ca033 100644 --- a/gonghui/manager-center/index.html +++ b/gonghui/manager-center/index.html @@ -6,17 +6,17 @@ name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" /> - Control Panel - - + Manage Center + + -
+
-
-
+
+
+ + +
+
+ + + + Manager + + + ID: + + + +
+
+ +
+
+ +
+ This Month + + Team Salary +
+ +
+ +
+ +
+ Last Month + + Team Salary +
+ +
+ +
+ +
+ Active Hosts + + Total Active +
+ +
+ +
+ +
+ Growth + + vs Last Month +
+ +
+
+ + + +
+ +
- - - - - - - - - -
@@ -519,10 +712,12 @@
+ + - + diff --git a/gonghui/manager-center/script.js b/gonghui/manager-center/script.js index f3168f7..b70642c 100644 --- a/gonghui/manager-center/script.js +++ b/gonghui/manager-center/script.js @@ -70,7 +70,7 @@ resourceType: '', title: '', searchedUser: null, - selectedResourceId: '', + selectedResourceIds: [], selectedResourceDurationMs: DEFAULT_RESOURCE_GRANT_DURATION_MS, selectedVipLevel: 0, submitting: false, @@ -148,6 +148,46 @@ 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) { return String(text || 'M') .trim() @@ -175,6 +215,246 @@ 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) { if (!ms) return 'Never'; var date = new Date(Number(ms)); @@ -187,10 +467,17 @@ } function setPageLoading(loading) { + // 首屏加载态用页面级骨架屏整层覆盖(common/skeleton.js),数据就绪后整层淡出; + // is-loading 状态类保留给页面内其他降级样式使用。 pageState.loading = loading; var container = root(); - if (!container) return; - container.classList.toggle('is-loading', loading); + if (container) container.classList.toggle('is-loading', loading); + if (!window.HyAppSkeleton) return; + if (loading) { + window.HyAppSkeleton.show(); + } else { + window.HyAppSkeleton.hide(); + } } function setDenied(denied) { @@ -201,6 +488,7 @@ } function normalizeUser(item) { + var vip = item.current_vip || item.currentVip || item.vip || null; return { user_id: item.user_id || item.userId || item.target_user_id || '', display_user_id: @@ -212,6 +500,16 @@ avatar: item.avatar || item.target_avatar || '', country: item.country || '', 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) { return Promise.reject(new Error('API not available.')); } - return api.searchUsers(keyword, 20, scope, action).then(function (data) { - var items = (data && data.items) || []; - return items.map(normalizeUser); - }); + return api + .searchUsers(keyword, 20, scope, action) + .then(function (data) { + var items = (data && data.items) || []; + return items.map(normalizeUser); + }); } function loadOverview() { if (!api || !api.overview) { - setDenied(true); - toast('Permission denied.'); + // 无 API 注入(本地纯静态预览等)时立即渲染一次,把骨架屏收敛成中性占位符。 + pageState.permissions = normalizePermissions({}); + renderDashboard(); + renderActionPermissions(); + setDenied(false); return Promise.resolve(); } setPageLoading(true); @@ -342,6 +645,7 @@ pageState.permissions = normalizePermissions( pageState.overview.permissions || pageState.overview ); + renderDashboard(); renderActionPermissions(); setDenied(false); return canUseAction('block-user') @@ -349,7 +653,16 @@ : Promise.resolve(); }) .catch(function (error) { + if (isLocalPreviewError(error)) { + pageState.permissions = normalizePermissions({}); + renderDashboard(); + renderActionPermissions(); + setDenied(false); + return; + } + // 接口失败也要渲染一次:把骨架屏收敛成中性占位符,页面不残留加载态。 setDenied(true); + renderDashboard(); showError(error); }) .finally(function () { @@ -389,6 +702,11 @@ copy.appendChild( 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); button.type = 'button'; button.addEventListener('click', onAction); @@ -681,7 +999,8 @@ toast('Enter user ID.'); return; } - apiSearchUser(value, '', grantState.action) + // 封禁搜索只校验 Block User 权限;不能借用赠送弹窗状态,否则会按资源/VIP 赠送权限误判。 + apiSearchUser(value, '', 'block-user') .then(function (items) { if (!items.length) { blockState.searchedUser = null; @@ -733,7 +1052,8 @@ toast('Enter user ID.'); return; } - apiSearchUser(value) + // 等级调整搜索按自己的能力开关校验,避免资源赠送关闭时误挡等级操作。 + apiSearchUser(value, '', 'update-user-level') .then(function (items) { if (!items.length) { levelState.searchedUser = null; @@ -815,7 +1135,7 @@ grantState.resourceType = RESOURCE_TYPES[action] || ''; grantState.title = ACTION_LABELS[action] || 'Send'; grantState.searchedUser = null; - grantState.selectedResourceId = ''; + grantState.selectedResourceIds = []; grantState.selectedResourceDurationMs = DEFAULT_RESOURCE_GRANT_DURATION_MS; grantState.selectedVipLevel = 0; @@ -860,7 +1180,8 @@ toast('Enter user ID.'); return; } - apiSearchUser(value) + // 搜索入口也必须携带当前赠送动作;Send VIP 只校验 VIP 赠送权限,不能落回资源赠送权限导致误拒。 + apiSearchUser(value, '', grantState.action) .then(function (items) { if (!items.length) { grantState.searchedUser = null; @@ -890,6 +1211,10 @@ var grid = $('grantOptionGrid'); if (!grid) return; grid.innerHTML = ''; + grid.classList.toggle( + 'is-resource-grid', + grantState.action !== 'send-vip' + ); var durationTitle = $('grantDurationTitle'); var durationGrid = $('grantDurationGrid'); if (durationTitle) { @@ -905,24 +1230,55 @@ } if (grantState.action === 'send-vip') { $('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_GRANT_LEVELS.forEach(function (level) { var button = createNode('button', '', 'VIP ' + level); + var blockedByCurrentVip = activeVipLevel > level; button.type = 'button'; + button.disabled = blockedByCurrentVip; + if (blockedByCurrentVip) { + button.title = 'Current VIP is higher'; + } button.classList.toggle( 'is-selected', grantState.selectedVipLevel === level ); button.addEventListener('click', function () { + if (blockedByCurrentVip) return; grantState.selectedVipLevel = level; renderGrantOptions(); }); 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 { - $('grantOptionTitle').textContent = 'Select resource'; + $('grantOptionTitle').textContent = 'Select ' + resourceKindLabel(); var resources = pageState.resourcesByType[grantState.resourceType] || []; + var selectedResourceSet = grantState.selectedResourceIds.reduce( + function (result, resourceId) { + result[String(resourceId)] = true; + return result; + }, + {} + ); if (!resources.length) { grid.appendChild( createNode('div', 'blocked-empty', 'No resource available.') @@ -930,20 +1286,56 @@ } resources.forEach(function (resource) { var button = createNode('button', 'grant-resource-option'); + var imageURL = resourceImageUrl(resource); + var resourceID = resource.resource_id; + var resourceKey = String(resourceID); button.type = 'button'; + button.setAttribute( + 'aria-label', + 'Select ' + resourceKindLabel() + ); button.classList.toggle( 'is-selected', - String(grantState.selectedResourceId) === - String(resource.resource_id) + !!selectedResourceSet[resourceKey] ); - button.textContent = - resource.name || 'Resource #' + resource.resource_id; + if (imageURL) { + 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 () { - 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(); }); grid.appendChild(button); }); + if (grantState.selectedResourceIds.length) { + grid.appendChild( + createNode( + 'div', + 'resource-selected-note', + grantState.selectedResourceIds.length + ' selected' + ) + ); + } if (durationGrid) { RESOURCE_GRANT_DURATIONS.forEach(function (item) { var durationButton = createNode('button', '', item.label); @@ -966,8 +1358,10 @@ !grantState.searchedUser || grantState.submitting || (grantState.action === 'send-vip' - ? !grantState.selectedVipLevel - : !grantState.selectedResourceId || + ? !grantState.selectedVipLevel || + currentVipLevel(grantState.searchedUser) > + grantState.selectedVipLevel + : !grantState.selectedResourceIds.length || !grantState.selectedResourceDurationMs); } } @@ -986,7 +1380,7 @@ payload.level = grantState.selectedVipLevel; request = api.grantVip(payload); } else { - payload.resource_id = grantState.selectedResourceId; + payload.resource_ids = grantState.selectedResourceIds.slice(); payload.duration_ms = grantState.selectedResourceDurationMs; request = api.grantResource(payload); } @@ -1065,7 +1459,8 @@ toast('Enter user ID.'); return; } - apiSearchUser(value) + // BD/Admin/Superadmin 共用弹窗,搜索阶段先按当前按钮的能力开关过滤;写入口仍会二次校验真实落库能力。 + apiSearchUser(value, '', bdLeaderState.action) .then(function (items) { if (!items.length) { bdLeaderState.searchedUser = null; @@ -1257,7 +1652,8 @@ toast('Enter user ID.'); return; } - apiSearchUser(value, 'all') + // 转国家是唯一允许跨国家搜索的入口,必须同时携带 scope=all 和动作,避免额外依赖资源赠送权限。 + apiSearchUser(value, 'all', 'transfer-user-country') .then(function (items) { if (!items.length) { 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() { var backButton = $('backButton'); 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) { button.addEventListener('click', function () { var action = button.getAttribute('data-action'); @@ -1453,6 +1870,7 @@ function init() { bindEvents(); renderBlockedList(); + // 首屏不预渲染仪表盘:骨架屏保持到 loadOverview 的任一分支调用 renderDashboard 收敛。 loadOverview(); if (window.HyAppBridge && window.HyAppBridge.ready) { window.HyAppBridge.ready({ page: 'manager-center' }); diff --git a/gonghui/manager-center/style.css b/gonghui/manager-center/style.css index 9dadede..da7c709 100644 --- a/gonghui/manager-center/style.css +++ b/gonghui/manager-center/style.css @@ -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 { position: relative; display: grid; @@ -392,6 +933,7 @@ } .searched-meta, +.searched-vip, .blocked-meta, .blocked-time { margin-top: 3px; @@ -404,6 +946,10 @@ white-space: nowrap; } +.searched-vip { + color: #7d57c7; +} + .user-block-button { min-width: 70px; height: 40px; @@ -486,14 +1032,114 @@ 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 { 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 { + position: relative; overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; + display: grid; + 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 { diff --git a/package.json b/package.json index 814c116..6aa851a 100644 --- a/package.json +++ b/package.json @@ -3,6 +3,8 @@ "version": "1.0.0", "description": "H5 project", "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:check": "prettier --check \"**/*.{js,html,css,json,md}\"" }, diff --git a/scripts/h5_dev_server.py b/scripts/h5_dev_server.py index ddb8d09..6f8dc1e 100644 --- a/scripts/h5_dev_server.py +++ b/scripts/h5_dev_server.py @@ -12,6 +12,7 @@ import urllib.request PROXIES = { + "/__api_prod__/": "https://api.global-interaction.com/", "/__api_test__/": "https://api-test.global-interaction.com/", "/__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") body = self.rfile.read(length) if length > 0 else b"" + # 代理只转发业务相关 header;Host、Connection 等 hop-by-hop header 必须由 urllib 按目标域重建, + # 否则生产网关会看到本地 Host 或错误的连接语义,排查时会像业务接口异常。 headers = { key: value for key, value in self.headers.items() @@ -179,7 +182,7 @@ class ReusableTCPServer(socketserver.ThreadingTCPServer): def main() -> None: parser = argparse.ArgumentParser() 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()) args = parser.parse_args() @@ -190,6 +193,8 @@ def main() -> None: ) with ReusableTCPServer((args.host, args.port), handler) as server: 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_local__/ -> http://127.0.0.1:13000/") server.serve_forever() diff --git a/yumi_commmon/yumi-common.js b/yumi_commmon/yumi-common.js new file mode 100644 index 0000000..e6afeeb --- /dev/null +++ b/yumi_commmon/yumi-common.js @@ -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 会默认走 lalu;Yumi 页入口必须显式落到 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.getAccessOrigin;iOS/旧页可能只通过回调异步回填。 + 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, + }, + }; +})();