diff --git a/.hermes/shared-memory/decision-log.md b/.hermes/shared-memory/decision-log.md index 1135211f..99051d28 100644 --- a/.hermes/shared-memory/decision-log.md +++ b/.hermes/shared-memory/decision-log.md @@ -38,6 +38,7 @@ - 决策:简单提示、确认 / 取消和危险确认统一使用 `src/components/common/UnifiedConfirmDialog.tsx`;剪贴板复制反馈统一使用 `src/components/common/useCopyFeedback.ts`,可点击复制按钮统一使用 `src/components/common/CopyFeedbackButton.tsx` 承载图标、三态文案、可访问名称、纯图标模式和动作按钮外观入口,作品号 / 用户号等短代码 chip 统一使用 `src/components/common/CopyCodeButton.tsx` 承载代码、三态后缀和默认可访问名称,非按钮复制提示统一使用 `src/components/common/CopyFeedbackMessage.tsx`,白底平台状态提示统一使用 `src/components/common/PlatformStatusMessage.tsx`,无操作空态 / 轻量读取态统一使用 `src/components/common/PlatformEmptyState.tsx`,平台动作按钮统一使用 `src/components/common/PlatformActionButton.tsx` 承载 platform / profile 两类样式族、尺寸、圆角、对齐、宽度和禁用态;认证表单的提交、验证码、第三方登录和邀请码提交按钮使用 `size="lg"` 复用 48px 高度,统一创作工作台、统一创作页壳层、玩法创作工作台、结果页返回按钮和反馈页 header 返回使用 `tone="ghost"`,生成 / 提交 / 发布按钮使用主动作,自定义世界实体目录、RPG 首页作品卡删除、创作中心错误重试和素材槽的小动作使用 `size="xs"` 或 `shape="pill"` 收口,推荐回复和列表内动作使用 `align="start"` 承接左对齐,上传控件等需要 label 语义时使用 `PlatformActionButton asChild="label"`,不把文件输入伪装成普通 button。普通平台图标动作按钮和图标上传 label 统一使用 `src/components/common/PlatformIconButton.tsx` 承载 `platform-icon-button` 外观、可访问名称、默认 `type="button"`、`asChild="label"` 和可选 title;历史图片选择弹窗、RPG 发布检查弹窗、RPG 首页搜索结果清空、creative-agent 侧边栏关闭 / 外观 / 设置入口、creation-agent 参考图移除、敲木鱼结果页新增主题标签入口、拼图结果页标签生成 / 标签新增 / 关卡详情关闭 / 发布弹窗关闭 / 删除关卡入口、视觉小说结果页素材选择 / 音频生成 / 保存草稿 / 运行配置入口,以及抓大鹅结果页标签生成 / 标签新增 / 物品素材删除 / 参考图上传入口已先迁移;图标上传控件必须保留 label + file input 语义。平台 / 个人中心弹窗关闭按钮统一使用 `src/components/common/PlatformModalCloseButton.tsx` 承载 profile / profileCompact / floating / floatingPlain / platformIcon 五类圆形关闭按钮、默认图标和可访问名称;认证入口、邀请码弹窗、抓大鹅结果页弹窗关闭等平台头部关闭按钮使用 `variant="platformIcon"`,不在业务 JSX 中手写 `platform-icon-button` + X 图标。RPG / 拼图 / 抓大鹅 / 跳一跳 / 敲木鱼 / 拼消消 / 宝贝识物 / 方洞 / 汪汪声浪结果页,拼消消 / 宝贝识物 / 视觉小说 / 汪汪声浪创作工作台,发布检查、素材生成面板和自定义世界实体目录中的错误 / 成功 / 信息 / 警告 / 中性提示使用 `PlatformStatusMessage surface="platform"` 复用平台 banner token;个人中心弹窗、账号安全弹窗、认证入口、验证码提示、统一创作工作台和通用创作输入区的错误 / 成功 / 信息 / 警告提示使用 `PlatformStatusMessage surface="profile"` 复用 profile token,不再把 `platform-profile-error` / `platform-profile-success` 或 `platform-banner--danger / success / info / warning / neutral` 作为业务 JSX 接口。`UnifiedModal` 继续作为底层模态窗口 Module。已有弹窗栈内的二级确认使用 `UnifiedConfirmDialog portal={false}` 内嵌到当前层级。特殊确认按钮外观通过 `confirmClassName` 适配,不让业务页重新手写 footer;`UnifiedConfirmDialog` 自身的 footer 按钮也复用 `PlatformActionButton`。带复制状态、渠道按钮、媒体预览或复杂网格的弹窗可以保留专用 Module,但普通确认按钮、普通动作按钮、普通图标动作按钮、复制按钮动作外观、复制状态机、copied / failed 按钮 / toast 分支、基础错误 / 成功提示条、无操作空态和普通弹窗关闭按钮不再直接写进业务页面。运行态 HUD、输入 Composer 发送 / 上传按钮、复制三态图标按钮或需要专用交互禁用语义的图标按钮先保留专用布局,等对应场景验证时再迁移。业务代码中的阻断提示、删除确认和公开作品失效恢复不得继续调用浏览器原生 `window.alert` / `window.confirm`,应由页面壳层或编辑器壳层用 `UnifiedConfirmDialog` 承接。简单确认需要像素风时使用 `UnifiedConfirmDialog variant="pixel"`,不再为同类确认单独维护壳层和按钮。 - 2026-06-10 追加:推荐页运行态卡片底部的点赞 / 分享 / 改造入口,以及创作中心公开作品卡右上角分享入口统一迁移到 `PlatformIconButton`;这类和 swipe / drag 手势耦合的图标动作必须继续保留业务局部 class 与 `onPointerDown` / `onClick` 里的 `stopPropagation`,只把按钮语义、可访问名称和默认 `type="button"` 收口到共享组件,避免图标动作误触推荐卡切换、整卡打开或残留左滑状态。 - 2026-06-10 追加:RPG 首页个人中心里的统计卡、统计骨架、常用功能入口、设置行和法律信息入口统一抽到 `src/components/platform-entry/PlatformProfilePrimitives.tsx`;这组纯展示原子以后优先通过 props 接收图片资源、点击回调和展示文案,不再继续塞回 `RpgEntryHomeView` 的账户控制逻辑里。新建 `PlatformProfilePrimitives.test.tsx` 作为组件级护栏,页面级布局与法律入口继续由 `RpgEntryHomeView.recharge.test.tsx` 兜底。 +- 2026-06-10 追加:RPG 首页个人中心的充值 / 钱包 / 每日任务 / 邀请 / 兑换码等商业与账户控制逻辑统一收口到 `src/components/platform-entry/usePlatformProfileCenterController.ts`;controller 负责账户动作分流、商业状态派生与相关面板控制,`RpgEntryHomeView` 只保留展示、昵称头像编辑、扫码入口和页面级交互编排,不在页面组件里继续堆叠账户控制分支。验证命令:`npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx`、`npm run typecheck`。 - 2026-06-09 追加:通用输入 Composer 的上传参考图、发送和移除参考图已迁移到 `PlatformIconButton`;图标上传仍使用 `asChild="label"` 保留 label + file input 语义,公共组件会自动写入隐藏文本,确保内嵌 file input 继承可访问名称。 - 2026-06-10 追加:creation-agent composer 的上传文档 / 上传参考图入口使用 `PlatformIconButton` 默认 `platformIcon`;工作台只保留动态 label、title、busy 状态和 picker 回调,发送按钮继续保留主题色动作布局。验证命令:`npm run test -- src/components/creation-agent/CreationAgentWorkspace.test.tsx src/components/common/PlatformIconButton.test.tsx`。 - 2026-06-10 追加:作品详情顶部返回 / 分享和封面轮播上一张 / 下一张入口使用 `PlatformIconButton variant="platformIcon"`;详情页保留原 `platform-work-detail__*` 局部 class 控制位置和尺寸,点赞、复制三态等专用动作暂不迁移。验证命令:`npm run test -- src/components/platform-entry/PlatformWorkDetailView.test.tsx src/components/common/PlatformIconButton.test.tsx`。 diff --git a/docs/technical/【前端架构】PlatformUiKit弹窗组件收口计划-2026-06-08.md b/docs/technical/【前端架构】PlatformUiKit弹窗组件收口计划-2026-06-08.md index 5e147da8..f16dc978 100644 --- a/docs/technical/【前端架构】PlatformUiKit弹窗组件收口计划-2026-06-08.md +++ b/docs/technical/【前端架构】PlatformUiKit弹窗组件收口计划-2026-06-08.md @@ -234,6 +234,7 @@ 19.3.10. RPG 首页推荐运行态卡片底部的点赞 / 分享 / 改造入口迁移到 `PlatformIconButton`;推荐卡继续保留 `.platform-recommend-work-meta__action*` 局部 class 控制透明圆角按钮尺寸、间距和玩法主题色,同时显式保留 `onPointerDown` / `onClick` 里的 `stopPropagation`,避免图标动作把推荐卡纵向拖拽切换误触发。后续任何耦合 swipe / drag 手势的图标动作都沿用“共享按钮承接语义,本地 class 保留视觉与手势隔离”的策略。验证命令:`npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx -t "logged in recommend runtime preloads adjacent work previews and drag switches like video feed"`。 19.3.11. 创作中心公开作品卡右上角的分享快按钮迁移到 `PlatformIconButton`;作品卡继续保留 `.creation-work-card__quick-action-button` 局部 class 承接卡片角落定位和尺寸,并显式保留 `stopPropagation`、关闭 swipe action、清理 `suppressOpenRef` 与分享回调顺序,避免右上角分享入口误触整卡打开或遗留左滑状态。验证命令:`npm run test -- src/components/custom-world-home/CustomWorldCreationHub.interaction.test.tsx`。 19.3.12. RPG 首页个人中心的统计卡、统计骨架、常用功能入口、设置行与法律信息入口抽离到 `src/components/platform-entry/PlatformProfilePrimitives.tsx`;`RpgEntryHomeView` 只继续保留账户数据、图片资源、点击回调和打开弹层的控制器,不再把这一组纯展示原子和个人中心页面编排混在同一个 7k+ 首页文件里。组件级验证新增 `src/components/platform-entry/PlatformProfilePrimitives.test.tsx`,并继续复用 `RpgEntryHomeView.recharge.test.tsx` 的个人中心集成断言。验证命令:`npm run test -- src/components/platform-entry/PlatformProfilePrimitives.test.tsx src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx -t "mobile profile page matches the reference layout sections|profile stats cards are centered without update timestamp|profile page shows legal entries and hides archive shortcuts"`。 +19.3.13. RPG 首页个人中心的充值 / 钱包 / 每日任务 / 邀请 / 兑换码等商业与账户控制逻辑收口到 `src/components/platform-entry/usePlatformProfileCenterController.ts`;`RpgEntryHomeView` 仅保留个人中心展示、昵称头像编辑、扫码入口和页面级编排 / 交互,不再直接承接账户动作分流、商业状态派生和面板控制。该收口默认保持现有弹层与充值链路语义不变,避免在职责迁移时顺带扩张行为面。验证命令:`npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx`、`npm run typecheck`。 19.3. creative-agent 首页的侧边栏菜单、账号入口、开启新对话、我的创作、首页激励 CTA 和 prompt suggestion 按钮迁移到 `PlatformIconButton` / `PlatformActionButton`;首页继续保留 `creative-agent-home__*` 本地 class 承接透明顶栏、抽屉和品牌化胶囊视觉,不把视觉回收和语义收口绑成一次大改。`Beta` 徽标和历史记录纯文本行暂保留本地实现,等出现更多同构轻量列表行后再评估是否抽新的共享 row primitive。 19.4. 大鱼吃小鱼结果页 hero 的返回入口迁移到 `PlatformIconButton variant="darkMini"`,测试 / 发布动作迁移到 `PlatformActionButton surface="editorDark"`;结果页只保留测试运行、发布提交和文案状态语义,不再手写 hero 顶栏按钮壳。 20. 平台方形上传入口和紧凑虚线新增入口迁移到 `PlatformUploadTile`,上传后的图片预览迁移到 `PlatformUploadPreviewCard`;反馈页上传凭证入口 / 预览、敲木鱼工作台新增功德词条入口、通用创作图片面板的提示词参考图缩略图、抓大鹅封面编辑参考图缩略图、通用输入 Composer 已选参考图条、creation-agent 已选参考图条和拼图结果页关卡引用图横条已先迁移。方形缩略图使用默认 `layout="square"`,横向“已选择参考图 / 文件名 / 素材名 / 移除”条使用 `layout="inline"`;只读引用图条不传 `onRemove`,避免公共组件额外渲染删除入口。后续继续收口结果页素材上传、工作台参考图上传、紧凑虚线新增入口等上传 / 动作块时,业务页只保留文件选择、预览数组、预览回调、删除回调、校验逻辑或新增回调,上传方块外观、主副文案、缩略图壳、预览按钮、标题行、横向已选条、移除按钮和禁用态统一由 Module 承接;工具栏中的小图标上传仍继续使用 `PlatformIconButton asChild="label"`。 diff --git a/src/components/platform-entry/usePlatformProfileCenterController.ts b/src/components/platform-entry/usePlatformProfileCenterController.ts new file mode 100644 index 00000000..78185359 --- /dev/null +++ b/src/components/platform-entry/usePlatformProfileCenterController.ts @@ -0,0 +1,1060 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; + +import type { AuthUser } from '../../services/authService'; +import { refreshStoredAccessToken } from '../../services/apiClient'; +import { + resolveProfileRechargeProductPaymentChannel, + WECHAT_H5_PAYMENT_CHANNEL, + WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_CHANNEL, + WECHAT_NATIVE_PAYMENT_CHANNEL, +} from '../../services/payment/paymentPlatform'; +import { redirectToPaymentUrl } from '../../services/payment/paymentRedirect'; +import { + claimRpgProfileTaskReward, + confirmWechatRpgProfileRechargeOrder, + createRpgProfileRechargeOrder, + getRpgProfileRechargeCenter, + getRpgProfileReferralInviteCenter, + getRpgProfileTasks, + getRpgProfileWalletLedger, + redeemRpgProfileReferralInviteCode, + redeemRpgProfileRewardCode, + watchWechatRpgProfileRechargeOrder, +} from '../../services/rpg-entry/rpgProfileClient'; +import { + type ConfirmWechatProfileRechargeOrderResponse, + type ProfileRechargeCenterResponse, + type ProfileRechargeProduct, + type ProfileReferralInviteCenterResponse, + type ProfileTaskCenterResponse, + type ProfileWalletLedgerResponse, + type RedeemProfileRewardCodeResponse, + type WechatMiniProgramPayParams, + type WechatMiniProgramVirtualPayParams, + type WechatNativePayment, +} from '../../../packages/shared/src/contracts/runtime'; +import { + type CopyFeedbackState, + useCopyFeedback, +} from '../common/useCopyFeedback'; + +const PROFILE_TASK_DAY_MS = 24 * 60 * 60 * 1000; +const PROFILE_TASK_BEIJING_OFFSET_MS = 8 * 60 * 60 * 1000; +const PROFILE_TASK_MIN_RESET_DELAY_MS = 1000; +const PROFILE_INVITE_QUERY_KEYS = ['inviteCode', 'invite_code'] as const; +const WECHAT_JS_SDK_URL = 'https://res.wx.qq.com/open/js/jweixin-1.6.0.js'; +const WECHAT_PAY_CONFIRM_RETRY_DELAYS_MS = [800, 1600, 3000] as const; +const WECHAT_PAY_RESULT_RECHECK_INTERVAL_MS = 250; +const WECHAT_PAY_RESULT_RECHECK_TIMEOUT_MS = 10000; + +export type ProfileReferralPanel = 'invite' | 'redeem' | 'community'; +export type ProfilePopupPanel = ProfileReferralPanel | 'saveArchives'; +export type RechargeTab = 'points' | 'membership'; + +type WechatPayResult = { + requestId: string; + orderId: string | null; + status: 'success' | 'cancel' | 'fail'; + errorMessage: string | null; +}; + +type RechargePaymentResultKind = 'success' | 'pending' | 'cancel' | 'failed'; + +export type RechargePaymentResult = { + kind: RechargePaymentResultKind; + title: string; + message: string; +}; + +export type WechatRechargeOrderConfirmationState = { + orderId: string; +}; + +export type NativeWechatPaymentState = WechatNativePayment & { + orderId: string; + isConfirming: boolean; +}; + +type UsePlatformProfileCenterControllerArgs = { + activeTab: string; + isAuthenticated: boolean; + showRechargeEntry: boolean; + profileTaskRefreshKey?: number; + onRechargeSuccess?: () => void | Promise; + requestLogin: () => void; + currentUser: AuthUser | null | undefined; +}; + +function getDelayUntilNextProfileTaskReset(nowMs = Date.now()) { + const shiftedNow = nowMs + PROFILE_TASK_BEIJING_OFFSET_MS; + const nextDayStart = + Math.floor(shiftedNow / PROFILE_TASK_DAY_MS) * PROFILE_TASK_DAY_MS + + PROFILE_TASK_DAY_MS; + const nextResetAt = nextDayStart - PROFILE_TASK_BEIJING_OFFSET_MS; + return Math.max(PROFILE_TASK_MIN_RESET_DELAY_MS, nextResetAt - nowMs); +} + +function readProfileInviteCodeFromLocationSearch(search: string) { + const params = new URLSearchParams(search); + for (const key of PROFILE_INVITE_QUERY_KEYS) { + const value = (params.get(key) ?? '') + .trim() + .replace(/[^0-9a-z]/giu, '') + .toUpperCase(); + if (value) { + return value; + } + } + return ''; +} + +function clearWechatPayResultHash() { + if (typeof window === 'undefined') { + return; + } + + const rawHash = window.location.hash.replace(/^#/, ''); + if (!rawHash.includes('wx_pay_result=')) { + return; + } + const params = new URLSearchParams(rawHash); + params.delete('wx_pay_result'); + const nextHash = params.toString(); + const nextUrl = `${window.location.pathname}${window.location.search}${nextHash ? `#${nextHash}` : ''}`; + window.history.replaceState(null, '', nextUrl); +} + +function readWechatPayResultFromHash(): WechatPayResult | null { + if (typeof window === 'undefined') { + return null; + } + + const result = new URLSearchParams( + window.location.hash.replace(/^#/, ''), + ).get('wx_pay_result'); + if (!result) { + return null; + } + + const [requestId = '', rawStatus = '', explicitOrderId = '', ...rawErrors] = + result.split(':'); + const inferredOrderId = requestId + .replace(/^wechat_pay_/, '') + .replace(/_\d+$/, '') + .trim(); + const orderId = explicitOrderId.trim() || inferredOrderId; + const status = + rawStatus === 'success' + ? 'success' + : rawStatus === 'cancel' + ? 'cancel' + : 'fail'; + let errorMessage: string | null = null; + const rawError = rawErrors.join(':'); + if (rawError) { + try { + errorMessage = decodeURIComponent(rawError); + } catch (_error) { + errorMessage = rawError; + } + } + + return { + requestId, + orderId: orderId || null, + status, + errorMessage, + }; +} + +function loadWechatJsSdk() { + if (typeof window === 'undefined') { + return Promise.reject(new Error('请在微信小程序内完成支付')); + } + if (window.wx?.miniProgram?.navigateTo) { + return Promise.resolve(window.wx); + } + + return new Promise>((resolve, reject) => { + const existingScript = document.querySelector( + `script[src="${WECHAT_JS_SDK_URL}"]`, + ); + const complete = () => { + if (window.wx?.miniProgram?.navigateTo) { + resolve(window.wx); + } else { + reject(new Error('请在微信小程序内完成支付')); + } + }; + + if (existingScript) { + existingScript.addEventListener('load', complete, { once: true }); + existingScript.addEventListener( + 'error', + () => reject(new Error('请在微信小程序内完成支付')), + { once: true }, + ); + complete(); + return; + } + + const script = document.createElement('script'); + script.src = WECHAT_JS_SDK_URL; + script.async = true; + script.onload = complete; + script.onerror = () => reject(new Error('请在微信小程序内完成支付')); + document.head.appendChild(script); + }); +} + +async function requestWechatMiniProgramPayment( + payload: + | WechatMiniProgramPayParams + | WechatMiniProgramVirtualPayParams + | null + | undefined, + orderId: string, +): Promise { + if (!payload) { + return Promise.reject(new Error('请在微信小程序内完成支付')); + } + const wxBridge = await loadWechatJsSdk(); + const miniProgram = wxBridge.miniProgram; + if (!miniProgram || typeof miniProgram.navigateTo !== 'function') { + return Promise.reject(new Error('请在微信小程序内完成支付')); + } + const navigateTo = miniProgram.navigateTo; + + const requestId = `wechat_pay_${orderId}_${Date.now()}`; + return new Promise((resolve, reject) => { + navigateTo({ + url: `/pages/wechat-pay/index?requestId=${encodeURIComponent(requestId)}&orderId=${encodeURIComponent(orderId)}&payParams=${encodeURIComponent(JSON.stringify(payload))}`, + success() { + resolve(); + }, + fail(error) { + console.error('[wechat-pay] navigateTo failed', error); + reject( + error instanceof Error + ? error + : new Error('请在微信小程序内完成支付'), + ); + }, + }); + }); +} + +function waitWechatPayConfirmDelay(delayMs: number) { + return new Promise((resolve) => { + window.setTimeout(resolve, delayMs); + }); +} + +async function confirmWechatRechargeOrderUntilSettled( + orderId: string, +): Promise { + let latestResponse = await confirmWechatRpgProfileRechargeOrder(orderId); + if (latestResponse.order.status !== 'pending') { + return latestResponse; + } + + for (const delayMs of WECHAT_PAY_CONFIRM_RETRY_DELAYS_MS) { + await waitWechatPayConfirmDelay(delayMs); + + latestResponse = await confirmWechatRpgProfileRechargeOrder(orderId); + if (latestResponse.order.status !== 'pending') { + return latestResponse; + } + } + + try { + const streamedResponse = await watchWechatRpgProfileRechargeOrder(orderId); + return streamedResponse; + } catch { + return latestResponse; + } +} + +export function usePlatformProfileCenterController({ + activeTab, + isAuthenticated, + showRechargeEntry, + profileTaskRefreshKey = 0, + onRechargeSuccess, + requestLogin, + currentUser, +}: UsePlatformProfileCenterControllerArgs) { + // 中文注释:个人中心里的充值、任务、邀请码、账单等账户商业能力统一由这个 controller 托管, + // 页面层只消费状态与回调,不再直接堆叠一大组本地 state / effect / callback。 + const [isRewardCodeOpen, setIsRewardCodeOpen] = useState(false); + const [rewardCodeInput, setRewardCodeInput] = useState(''); + const [isSubmittingRewardCode, setIsSubmittingRewardCode] = useState(false); + const [rewardCodeError, setRewardCodeError] = useState(null); + const [rewardCodeSuccess, setRewardCodeSuccess] = useState( + null, + ); + const [isRechargeOpen, setIsRechargeOpen] = useState(false); + const [rechargeCenter, setRechargeCenter] = + useState(null); + const [isLoadingRechargeCenter, setIsLoadingRechargeCenter] = useState(false); + const [rechargeError, setRechargeError] = useState(null); + const [rechargePaymentResult, setRechargePaymentResult] = + useState(null); + const [ + wechatRechargeOrderConfirmationState, + setWechatRechargeOrderConfirmationState, + ] = useState(null); + const [nativeWechatPayment, setNativeWechatPayment] = + useState(null); + const [activeRechargeTab, setActiveRechargeTab] = + useState('points'); + const [submittingRechargeProductId, setSubmittingRechargeProductId] = + useState(null); + const [isWalletLedgerOpen, setIsWalletLedgerOpen] = useState(false); + const [walletLedger, setWalletLedger] = + useState(null); + const [walletLedgerError, setWalletLedgerError] = useState( + null, + ); + const [isLoadingWalletLedger, setIsLoadingWalletLedger] = useState(false); + const [isTaskCenterOpen, setIsTaskCenterOpen] = useState(false); + const [taskCenter, setTaskCenter] = + useState(null); + const [taskCenterError, setTaskCenterError] = useState(null); + const [isLoadingTaskCenter, setIsLoadingTaskCenter] = useState(false); + const taskCenterRequestIdRef = useRef(0); + const [claimingTaskId, setClaimingTaskId] = useState(null); + const [taskClaimSuccess, setTaskClaimSuccess] = useState(null); + const [profilePopupPanel, setProfilePopupPanel] = + useState(null); + const [referralCenter, setReferralCenter] = + useState(null); + const [isLoadingReferral, setIsLoadingReferral] = useState(false); + const [isReferralCenterInitialized, setIsReferralCenterInitialized] = + useState(false); + const pendingProfileInviteCode = useMemo( + () => + typeof window === 'undefined' + ? '' + : readProfileInviteCodeFromLocationSearch(window.location.search), + [], + ); + const promptedLoginForInviteQueryRef = useRef(false); + const autoOpenedInviteQueryRef = useRef(false); + const [referralRedeemCode, setReferralRedeemCode] = useState( + pendingProfileInviteCode, + ); + const [isSubmittingReferralRedeem, setIsSubmittingReferralRedeem] = + useState(false); + const [referralError, setReferralError] = useState(null); + const [referralSuccess, setReferralSuccess] = useState(null); + const { copyState: inviteCopyState, copyText: copyInviteText } = + useCopyFeedback(); + const pendingWechatRechargeOrderIdRef = useRef(null); + const confirmingWechatRechargeOrderIdRef = useRef(null); + + // 中文注释:支持带邀请码 query 的直达场景,登录成功后自动打开兑换面板并复用同一套输入状态。 + useEffect(() => { + if (!pendingProfileInviteCode || autoOpenedInviteQueryRef.current) { + return; + } + + if (!currentUser) { + if (!promptedLoginForInviteQueryRef.current) { + promptedLoginForInviteQueryRef.current = true; + requestLogin(); + } + return; + } + + autoOpenedInviteQueryRef.current = true; + setReferralRedeemCode(pendingProfileInviteCode); + setReferralError(null); + setReferralSuccess(null); + setProfilePopupPanel('redeem'); + }, [currentUser, pendingProfileInviteCode, requestLogin]); + + const loadWalletLedger = useCallback(() => { + setWalletLedgerError(null); + setIsLoadingWalletLedger(true); + void getRpgProfileWalletLedger() + .then(setWalletLedger) + .catch((error: unknown) => { + setWalletLedger(null); + setWalletLedgerError( + error instanceof Error ? error.message : '读取泥点账单失败', + ); + }) + .finally(() => setIsLoadingWalletLedger(false)); + }, []); + + const openWalletLedgerPanel = useCallback(() => { + setIsWalletLedgerOpen(true); + loadWalletLedger(); + }, [loadWalletLedger]); + + const loadRechargeCenter = useCallback(() => { + setRechargeError(null); + setIsLoadingRechargeCenter(true); + void getRpgProfileRechargeCenter() + .then(setRechargeCenter) + .catch((error: unknown) => { + setRechargeCenter(null); + setRechargeError( + error instanceof Error ? error.message : '读取账户充值失败', + ); + }) + .finally(() => setIsLoadingRechargeCenter(false)); + }, []); + + const refreshRechargeState = useCallback(() => { + loadRechargeCenter(); + setSubmittingRechargeProductId(null); + pendingWechatRechargeOrderIdRef.current = null; + confirmingWechatRechargeOrderIdRef.current = null; + setWechatRechargeOrderConfirmationState(null); + setNativeWechatPayment(null); + }, [loadRechargeCenter]); + + const handleWechatPayResult = useCallback(() => { + const payResult = readWechatPayResultFromHash(); + if (!payResult) { + return false; + } + + if ( + pendingWechatRechargeOrderIdRef.current && + payResult.orderId && + payResult.orderId !== pendingWechatRechargeOrderIdRef.current + ) { + return false; + } + + if (payResult.status === 'success') { + const orderId = + payResult.orderId || pendingWechatRechargeOrderIdRef.current; + if (!orderId) { + clearWechatPayResultHash(); + return true; + } + if (confirmingWechatRechargeOrderIdRef.current === orderId) { + clearWechatPayResultHash(); + return true; + } + confirmingWechatRechargeOrderIdRef.current = orderId; + setWechatRechargeOrderConfirmationState({ orderId }); + setSubmittingRechargeProductId(null); + setRechargePaymentResult(null); + void confirmWechatRechargeOrderUntilSettled(orderId) + .then((response) => { + const isPaid = response.order.status === 'paid'; + setRechargeCenter(response.center); + pendingWechatRechargeOrderIdRef.current = null; + confirmingWechatRechargeOrderIdRef.current = null; + setWechatRechargeOrderConfirmationState(null); + setRechargePaymentResult( + isPaid + ? { + kind: 'success', + title: '支付成功', + message: '已到账,账户状态已刷新。', + } + : { + kind: 'pending', + title: '支付处理中', + message: '正在等待到账状态确认,请稍后查看余额或会员状态。', + }, + ); + if (isPaid) { + void onRechargeSuccess?.(); + } + clearWechatPayResultHash(); + }) + .catch(() => { + confirmingWechatRechargeOrderIdRef.current = null; + setWechatRechargeOrderConfirmationState(null); + setRechargePaymentResult({ + kind: 'pending', + title: '支付处理中', + message: '暂时没能确认到账状态,请稍后查看余额或会员状态。', + }); + clearWechatPayResultHash(); + }); + } else if (payResult.status === 'cancel') { + setRechargePaymentResult({ + kind: 'cancel', + title: '支付已取消', + message: '本次没有扣款,账户状态未发生变化。', + }); + setWechatRechargeOrderConfirmationState(null); + refreshRechargeState(); + } else { + const detail = payResult.errorMessage + ? `微信返回:${payResult.errorMessage}` + : '微信支付没有完成,本次不会入账。'; + setRechargePaymentResult({ + kind: 'failed', + title: '支付未完成', + message: detail, + }); + setWechatRechargeOrderConfirmationState(null); + refreshRechargeState(); + } + + clearWechatPayResultHash(); + return true; + }, [onRechargeSuccess, refreshRechargeState]); + + const pollWechatPayResultFromHash = useCallback( + () => handleWechatPayResult(), + [handleWechatPayResult], + ); + + const confirmPendingWechatRechargeOrder = useCallback(() => { + const orderId = pendingWechatRechargeOrderIdRef.current; + if (!orderId || confirmingWechatRechargeOrderIdRef.current === orderId) { + return false; + } + + confirmingWechatRechargeOrderIdRef.current = orderId; + setWechatRechargeOrderConfirmationState({ orderId }); + setRechargePaymentResult(null); + void confirmWechatRechargeOrderUntilSettled(orderId) + .then((response) => { + const isPaid = response.order.status === 'paid'; + setRechargeCenter(response.center); + pendingWechatRechargeOrderIdRef.current = null; + confirmingWechatRechargeOrderIdRef.current = null; + setWechatRechargeOrderConfirmationState(null); + setSubmittingRechargeProductId(null); + setRechargePaymentResult( + isPaid + ? { + kind: 'success', + title: '支付成功', + message: '已到账,账户状态已刷新。', + } + : { + kind: 'pending', + title: '支付处理中', + message: '正在等待到账状态确认,请稍后查看余额或会员状态。', + }, + ); + if (isPaid) { + void onRechargeSuccess?.(); + } + }) + .catch(() => { + confirmingWechatRechargeOrderIdRef.current = null; + setWechatRechargeOrderConfirmationState(null); + setRechargePaymentResult({ + kind: 'pending', + title: '支付处理中', + message: '暂时没能确认到账状态,请稍后查看余额或会员状态。', + }); + }); + return true; + }, [onRechargeSuccess]); + + const openRechargeModal = useCallback(() => { + if (!currentUser) { + requestLogin(); + return; + } + + setIsRechargeOpen(true); + loadRechargeCenter(); + }, [currentUser, loadRechargeCenter, requestLogin]); + + const openRewardCodeModal = useCallback(() => { + setIsRewardCodeOpen(true); + setRewardCodeError(null); + setRewardCodeSuccess(null); + }, []); + + const openRechargeOrRewardCodeModal = useCallback(() => { + if (showRechargeEntry) { + openRechargeModal(); + return; + } + + openRewardCodeModal(); + }, [openRechargeModal, openRewardCodeModal, showRechargeEntry]); + + const buyRechargeProduct = useCallback( + (product: ProfileRechargeProduct) => { + if (submittingRechargeProductId) { + return; + } + + const paymentChannel = resolveProfileRechargeProductPaymentChannel( + { kind: product.kind }, + {}, + ); + setSubmittingRechargeProductId(product.productId); + setRechargeError(null); + setRechargePaymentResult(null); + setWechatRechargeOrderConfirmationState(null); + setNativeWechatPayment(null); + void createRpgProfileRechargeOrder(product.productId, paymentChannel) + .then(async (response) => { + if (paymentChannel === WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_CHANNEL) { + pendingWechatRechargeOrderIdRef.current = response.order.orderId; + setRechargeCenter(response.center); + await requestWechatMiniProgramPayment( + response.wechatMiniProgramPayParams, + response.order.orderId, + ); + return; + } + if (paymentChannel === WECHAT_H5_PAYMENT_CHANNEL) { + const h5Url = response.wechatH5Payment?.h5Url?.trim(); + if (!h5Url) { + throw new Error('微信 H5 支付链接生成失败'); + } + pendingWechatRechargeOrderIdRef.current = response.order.orderId; + setRechargeCenter(response.center); + setRechargePaymentResult({ + kind: 'pending', + title: '正在打开微信支付', + message: '完成支付后返回页面确认到账状态。', + }); + redirectToPaymentUrl(h5Url); + return; + } + if (paymentChannel === WECHAT_NATIVE_PAYMENT_CHANNEL) { + const codeUrl = response.wechatNativePayment?.codeUrl?.trim(); + if (!codeUrl) { + throw new Error('微信 Native 支付二维码生成失败'); + } + pendingWechatRechargeOrderIdRef.current = response.order.orderId; + setRechargeCenter(response.center); + setNativeWechatPayment({ + orderId: response.order.orderId, + codeUrl, + isConfirming: false, + }); + setSubmittingRechargeProductId(null); + return; + } + + throw new Error('充值支付渠道无效'); + }) + .catch((error: unknown) => { + pendingWechatRechargeOrderIdRef.current = null; + setNativeWechatPayment(null); + setRechargeError(error instanceof Error ? error.message : '充值失败'); + setSubmittingRechargeProductId(null); + }); + }, + [submittingRechargeProductId], + ); + + const confirmNativeWechatPayment = useCallback(() => { + if (!nativeWechatPayment || nativeWechatPayment.isConfirming) { + return; + } + + setNativeWechatPayment((current) => + current && current.orderId === nativeWechatPayment.orderId + ? { ...current, isConfirming: true } + : current, + ); + setRechargePaymentResult({ + kind: 'pending', + title: '正在确认支付', + message: '正在查询微信支付到账状态。', + }); + void confirmWechatRechargeOrderUntilSettled(nativeWechatPayment.orderId) + .then((response) => { + const isPaid = response.order.status === 'paid'; + setRechargeCenter(response.center); + setRechargePaymentResult( + isPaid + ? { + kind: 'success', + title: '支付成功', + message: '已到账,账户状态已刷新。', + } + : { + kind: 'pending', + title: '等待微信确认', + message: '暂时没能确认到账状态,请稍后再试。', + }, + ); + if (isPaid) { + setNativeWechatPayment(null); + pendingWechatRechargeOrderIdRef.current = null; + void onRechargeSuccess?.(); + } else { + setNativeWechatPayment((current) => + current && current.orderId === nativeWechatPayment.orderId + ? { ...current, isConfirming: false } + : current, + ); + } + }) + .catch(() => { + setRechargePaymentResult({ + kind: 'pending', + title: '等待微信确认', + message: '暂时没能确认到账状态,请稍后再试。', + }); + setNativeWechatPayment((current) => + current && current.orderId === nativeWechatPayment.orderId + ? { ...current, isConfirming: false } + : current, + ); + }) + .finally(() => setSubmittingRechargeProductId(null)); + }, [nativeWechatPayment, onRechargeSuccess]); + + // 中文注释:H5 / 小程序支付返回页、页面恢复和 hash 轮询都统一走同一套到账确认逻辑, + // 避免页面组件自己感知微信支付细节。 + useEffect(() => { + const handleHashChange = () => { + handleWechatPayResult(); + }; + const handleResume = () => { + if ( + typeof document !== 'undefined' && + document.visibilityState === 'hidden' + ) { + return; + } + if (!handleWechatPayResult()) { + confirmPendingWechatRechargeOrder(); + } + }; + + window.addEventListener('hashchange', handleHashChange); + window.addEventListener('focus', handleResume); + window.addEventListener('pageshow', handleResume); + document.addEventListener('visibilitychange', handleResume); + handleWechatPayResult(); + return () => { + window.removeEventListener('hashchange', handleHashChange); + window.removeEventListener('focus', handleResume); + window.removeEventListener('pageshow', handleResume); + document.removeEventListener('visibilitychange', handleResume); + }; + }, [confirmPendingWechatRechargeOrder, handleWechatPayResult]); + + useEffect(() => { + if (!submittingRechargeProductId || wechatRechargeOrderConfirmationState) { + return undefined; + } + + const startedAt = Date.now(); + let timer: number | null = null; + const pollPayResult = () => { + if (pollWechatPayResultFromHash()) { + return; + } + if (Date.now() - startedAt >= WECHAT_PAY_RESULT_RECHECK_TIMEOUT_MS) { + return; + } + timer = window.setTimeout( + pollPayResult, + WECHAT_PAY_RESULT_RECHECK_INTERVAL_MS, + ); + }; + + timer = window.setTimeout( + pollPayResult, + WECHAT_PAY_RESULT_RECHECK_INTERVAL_MS, + ); + return () => { + if (timer !== null) { + window.clearTimeout(timer); + } + }; + }, [ + pollWechatPayResultFromHash, + submittingRechargeProductId, + wechatRechargeOrderConfirmationState, + ]); + + const loadTaskCenter = useCallback(() => { + const requestId = ++taskCenterRequestIdRef.current; + setTaskCenterError(null); + setIsLoadingTaskCenter(true); + void getRpgProfileTasks() + .then((center) => { + if (requestId === taskCenterRequestIdRef.current) { + setTaskCenter(center); + } + }) + .catch((error: unknown) => { + if (requestId !== taskCenterRequestIdRef.current) { + return; + } + setTaskCenter(null); + setTaskCenterError( + error instanceof Error ? error.message : '读取每日任务失败', + ); + }) + .finally(() => { + if (requestId === taskCenterRequestIdRef.current) { + setIsLoadingTaskCenter(false); + } + }); + }, []); + + useEffect(() => { + if (activeTab !== 'profile' || !isAuthenticated) { + taskCenterRequestIdRef.current += 1; + setTaskCenter(null); + setTaskCenterError(null); + return; + } + + loadTaskCenter(); + }, [activeTab, isAuthenticated, loadTaskCenter, profileTaskRefreshKey]); + + useEffect(() => { + if (activeTab !== 'profile' || !isAuthenticated) { + return undefined; + } + + // 中文注释:每日任务重置依赖北京时间跨天与 access token 刷新,继续留在 controller 里集中托管。 + let cancelled = false; + let timer: number | null = null; + + const scheduleNextReset = () => { + if (cancelled) { + return; + } + timer = window.setTimeout(() => { + void refreshStoredAccessToken({ clearOnFailure: false }) + .catch(() => undefined) + .finally(() => { + if (cancelled) { + return; + } + loadTaskCenter(); + scheduleNextReset(); + }); + }, getDelayUntilNextProfileTaskReset()); + }; + + scheduleNextReset(); + return () => { + cancelled = true; + if (timer !== null) { + window.clearTimeout(timer); + } + }; + }, [activeTab, isAuthenticated, loadTaskCenter]); + + const openTaskCenterPanel = useCallback(() => { + setIsTaskCenterOpen(true); + setTaskClaimSuccess(null); + if (!taskCenter) { + loadTaskCenter(); + } + }, [loadTaskCenter, taskCenter]); + + const loadReferralCenter = useCallback(() => { + setIsLoadingReferral(true); + setIsReferralCenterInitialized(false); + void getRpgProfileReferralInviteCenter() + .then(setReferralCenter) + .catch((error: unknown) => { + setReferralCenter(null); + setReferralError( + error instanceof Error ? error.message : '读取邀请码失败', + ); + }) + .finally(() => { + setIsReferralCenterInitialized(true); + setIsLoadingReferral(false); + }); + }, []); + + useEffect(() => { + if (activeTab !== 'profile' || !isAuthenticated) { + setIsReferralCenterInitialized(false); + setReferralCenter(null); + return; + } + + loadReferralCenter(); + }, [activeTab, isAuthenticated, loadReferralCenter]); + + const openProfilePopupPanel = useCallback( + (panel: ProfileReferralPanel) => { + setProfilePopupPanel(panel); + setReferralError(null); + setReferralSuccess(null); + if (panel === 'redeem') { + setReferralRedeemCode(pendingProfileInviteCode); + } + if (panel === 'community') { + return; + } + + if (!isReferralCenterInitialized && !isLoadingReferral) { + loadReferralCenter(); + } + }, + [ + isLoadingReferral, + isReferralCenterInitialized, + loadReferralCenter, + pendingProfileInviteCode, + ], + ); + + const closeProfilePopupPanel = useCallback(() => { + setProfilePopupPanel(null); + }, []); + + const copyInviteInfo = useCallback(() => { + if (!referralCenter?.inviteCode) { + return; + } + + const inviteUrl = + typeof window === 'undefined' + ? referralCenter.inviteLinkPath + : new URL(referralCenter.inviteLinkPath, window.location.origin).href; + void copyInviteText(`${referralCenter.inviteCode} ${inviteUrl}`).then( + (copied) => { + setReferralSuccess(copied ? '已复制' : '复制失败'); + }, + ); + }, [copyInviteText, referralCenter]); + + const submitReferralRedeemCode = useCallback(() => { + const inviteCode = referralRedeemCode + .trim() + .replace(/[^0-9a-z]/gi, '') + .toUpperCase(); + if (isSubmittingReferralRedeem || !inviteCode) { + return; + } + + setIsSubmittingReferralRedeem(true); + setReferralError(null); + setReferralSuccess(null); + void redeemRpgProfileReferralInviteCode(inviteCode) + .then((response) => { + setReferralCenter(response.center); + setReferralRedeemCode(''); + setReferralSuccess('已填写'); + void onRechargeSuccess?.(); + }) + .catch((error: unknown) => { + setReferralError( + error instanceof Error ? error.message : '填写邀请码失败', + ); + }) + .finally(() => setIsSubmittingReferralRedeem(false)); + }, [isSubmittingReferralRedeem, onRechargeSuccess, referralRedeemCode]); + + const submitRewardCode = useCallback(() => { + if (isSubmittingRewardCode || !rewardCodeInput.trim()) { + return; + } + + setIsSubmittingRewardCode(true); + setRewardCodeError(null); + setRewardCodeSuccess(null); + void redeemRpgProfileRewardCode(rewardCodeInput) + .then((response: RedeemProfileRewardCodeResponse) => { + setRewardCodeInput(''); + setRewardCodeSuccess(`已到账 ${response.amountGranted} 泥点`); + void onRechargeSuccess?.(); + }) + .catch((error: unknown) => { + setRewardCodeError(error instanceof Error ? error.message : '兑换失败'); + }) + .finally(() => setIsSubmittingRewardCode(false)); + }, [isSubmittingRewardCode, onRechargeSuccess, rewardCodeInput]); + + const claimTaskReward = useCallback( + (taskId: string) => { + if (claimingTaskId) { + return; + } + + setClaimingTaskId(taskId); + setTaskCenterError(null); + setTaskClaimSuccess(null); + void claimRpgProfileTaskReward(taskId) + .then((response) => { + setTaskCenter(response.center); + setTaskClaimSuccess(`已领取 ${response.rewardPoints} 泥点`); + void onRechargeSuccess?.(); + }) + .catch((error: unknown) => { + setTaskCenterError( + error instanceof Error ? error.message : '领取任务奖励失败', + ); + }) + .finally(() => setClaimingTaskId(null)); + }, + [claimingTaskId, onRechargeSuccess], + ); + + return { + activeRechargeTab, + claimTaskReward, + claimingTaskId, + closeProfilePopupPanel, + confirmNativeWechatPayment, + inviteCopyState: inviteCopyState as CopyFeedbackState, + isLoadingRechargeCenter, + isLoadingReferral, + isLoadingTaskCenter, + isLoadingWalletLedger, + isRechargeOpen, + isRewardCodeOpen, + isSubmittingReferralRedeem, + isSubmittingRewardCode, + isTaskCenterOpen, + isWalletLedgerOpen, + loadRechargeCenter, + loadReferralCenter, + loadTaskCenter, + loadWalletLedger, + nativeWechatPayment, + openProfilePopupPanel, + openRechargeOrRewardCodeModal, + openRewardCodeModal, + openTaskCenterPanel, + openWalletLedgerPanel, + profilePopupPanel, + rechargeCenter, + rechargeError, + rechargePaymentResult, + referralCenter, + referralError, + referralRedeemCode, + referralSuccess, + rewardCodeError, + rewardCodeInput, + rewardCodeSuccess, + setActiveRechargeTab, + setIsRechargeOpen, + setIsRewardCodeOpen, + setIsTaskCenterOpen, + setIsWalletLedgerOpen, + setRechargePaymentResult, + setReferralRedeemCode, + setRewardCodeInput, + showRechargeEntry, + submittingRechargeProductId, + submitReferralRedeemCode, + submitRewardCode, + taskCenter, + taskCenterError, + taskClaimSuccess, + walletLedger, + walletLedgerError, + wechatRechargeOrderConfirmationState, + buyRechargeProduct, + copyInviteInfo, + }; +} diff --git a/src/components/rpg-entry/RpgEntryHomeView.tsx b/src/components/rpg-entry/RpgEntryHomeView.tsx index 2772d12e..d40705be 100644 --- a/src/components/rpg-entry/RpgEntryHomeView.tsx +++ b/src/components/rpg-entry/RpgEntryHomeView.tsx @@ -59,7 +59,6 @@ import communityQqQrImage from '../../../media/social-media-group/qq.png'; import communityWechatQrImage from '../../../media/social-media-group/wechat.png'; import type { PublicUserSummary } from '../../../packages/shared/src/contracts/auth'; import type { - ConfirmWechatProfileRechargeOrderResponse, CustomWorldLibraryEntry, PlatformBrowseHistoryEntry, ProfileDashboardCardKey, @@ -72,13 +71,8 @@ import type { ProfileSaveArchiveSummary, ProfileTaskCenterResponse, ProfileWalletLedgerResponse, - RedeemProfileRewardCodeResponse, - WechatMiniProgramPayParams, - WechatMiniProgramVirtualPayParams, - WechatNativePayment, } from '../../../packages/shared/src/contracts/runtime'; import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes'; -import { refreshStoredAccessToken } from '../../services/apiClient'; import { isGeneratedLegacyPath, resolveAssetReadUrl, @@ -89,26 +83,7 @@ import { getPublicAuthUserById, updateAuthProfile, } from '../../services/authService'; -import { - resolveProfileRechargeProductPaymentChannel, - shouldShowRechargeEntry, - WECHAT_H5_PAYMENT_CHANNEL, - WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_CHANNEL, - WECHAT_NATIVE_PAYMENT_CHANNEL, -} from '../../services/payment/paymentPlatform'; -import { redirectToPaymentUrl } from '../../services/payment/paymentRedirect'; -import { - claimRpgProfileTaskReward, - confirmWechatRpgProfileRechargeOrder, - createRpgProfileRechargeOrder, - getRpgProfileRechargeCenter, - getRpgProfileReferralInviteCenter, - getRpgProfileTasks, - getRpgProfileWalletLedger, - redeemRpgProfileReferralInviteCode, - redeemRpgProfileRewardCode, - watchWechatRpgProfileRechargeOrder, -} from '../../services/rpg-entry/rpgProfileClient'; +import { shouldShowRechargeEntry } from '../../services/payment/paymentPlatform'; import type { CustomWorldProfile } from '../../types'; import { useAuthUi } from '../auth/AuthUiContext'; import { CopyFeedbackButton } from '../common/CopyFeedbackButton'; @@ -156,6 +131,13 @@ import { ProfileStatCardSkeleton, } from '../platform-entry/PlatformProfilePrimitives'; import { getInitialPlatformDesktopLayout } from '../platform-entry/platformEntryResponsive'; +import { + type NativeWechatPaymentState, + type ProfileReferralPanel, + type RechargePaymentResult, + type RechargeTab, + usePlatformProfileCenterController, +} from '../platform-entry/usePlatformProfileCenterController'; import { ResolvedAssetImage } from '../ResolvedAssetImage'; import { RpgEntryBrandLogo } from './RpgEntryBrandLogo'; import { @@ -335,14 +317,10 @@ const PLATFORM_HOME_TABS: PlatformHomeTab[] = [ 'saves', 'profile', ]; -const PROFILE_TASK_DAY_MS = 24 * 60 * 60 * 1000; -const PROFILE_TASK_BEIJING_OFFSET_MS = 8 * 60 * 60 * 1000; -const PROFILE_TASK_MIN_RESET_DELAY_MS = 1000; const AVATAR_MAX_FILE_SIZE = 5 * 1024 * 1024; const AVATAR_OUTPUT_SIZE = 256; const AVATAR_ALLOWED_TYPES = new Set(['image/jpeg', 'image/png', 'image/webp']); const PLATFORM_WORK_COVER_CAROUSEL_INTERVAL_MS = 4200; -const PROFILE_INVITE_QUERY_KEYS = ['inviteCode', 'invite_code'] as const; const RECOMMEND_RUNTIME_COVER_MIN_VISIBLE_MS = 520; const RECOMMEND_RUNTIME_RESOURCE_IDLE_MS = 80; const RECOMMEND_RUNTIME_READY_FRAME_COUNT = 2; @@ -909,47 +887,14 @@ function readyRecommendRuntime( scanResources(); }); } -const WECHAT_JS_SDK_URL = 'https://res.wx.qq.com/open/js/jweixin-1.6.0.js'; -const WECHAT_PAY_CONFIRM_RETRY_DELAYS_MS = [800, 1600, 3000] as const; const WECHAT_NATIVE_PAY_QR_IMAGE_SIZE = 180; const PROFILE_QR_SCAN_INTERVAL_MS = 360; - -function getDelayUntilNextProfileTaskReset(nowMs = Date.now()) { - const shiftedNow = nowMs + PROFILE_TASK_BEIJING_OFFSET_MS; - const nextDayStart = - Math.floor(shiftedNow / PROFILE_TASK_DAY_MS) * PROFILE_TASK_DAY_MS + - PROFILE_TASK_DAY_MS; - const nextResetAt = nextDayStart - PROFILE_TASK_BEIJING_OFFSET_MS; - return Math.max(PROFILE_TASK_MIN_RESET_DELAY_MS, nextResetAt - nowMs); -} - -type ProfileReferralPanel = 'invite' | 'redeem' | 'community'; -type ProfilePopupPanel = ProfileReferralPanel | 'saveArchives'; type BarcodeDetectorLike = { detect: (source: CanvasImageSource) => Promise>; }; type BarcodeDetectorConstructorLike = new (options?: { formats?: string[]; }) => BarcodeDetectorLike; -type RechargeTab = 'points' | 'membership'; -type WechatMiniProgramPaymentStatus = 'success' | 'fail' | 'cancel'; -type WechatPayResult = { - requestId: string; - orderId: string | null; - status: WechatMiniProgramPaymentStatus; - errorMessage: string | null; -}; -type RechargePaymentResultKind = 'success' | 'pending' | 'cancel' | 'failed'; -type RechargePaymentResult = { - kind: RechargePaymentResultKind; - title: string; - message: string; -}; -type WechatRechargeOrderConfirmationState = { - orderId: string; -}; -const WECHAT_PAY_RESULT_RECHECK_INTERVAL_MS = 250; -const WECHAT_PAY_RESULT_RECHECK_TIMEOUT_MS = 10000; function getBarcodeDetectorConstructor(): BarcodeDetectorConstructorLike | null { const maybeDetector = ( @@ -959,10 +904,6 @@ function getBarcodeDetectorConstructor(): BarcodeDetectorConstructorLike | null ).BarcodeDetector; return typeof maybeDetector === 'function' ? maybeDetector : null; } -type NativeWechatPaymentState = WechatNativePayment & { - orderId: string; - isConfirming: boolean; -}; type DiscoverChannel = | 'recommend' | 'today' @@ -2510,24 +2451,6 @@ function formatSnapshotTime(value: string | null | undefined) { }); } -function normalizeProfileInviteQueryCode(value: string | null | undefined) { - return (value ?? '') - .trim() - .replace(/[^0-9a-z]/giu, '') - .toUpperCase(); -} - -function readProfileInviteCodeFromLocationSearch(search: string) { - const params = new URLSearchParams(search); - for (const key of PROFILE_INVITE_QUERY_KEYS) { - const inviteCode = normalizeProfileInviteQueryCode(params.get(key)); - if (inviteCode) { - return inviteCode; - } - } - return ''; -} - function buildPublicUserCode(user: AuthUser | null | undefined) { if (user?.publicUserCode?.trim()) { return user.publicUserCode.trim(); @@ -2735,173 +2658,6 @@ function ProfileNicknameModal({ ); } -function clearWechatPayResultHash() { - if (typeof window === 'undefined') { - return; - } - - const rawHash = window.location.hash.replace(/^#/, ''); - if (!rawHash.includes('wx_pay_result=')) { - return; - } - const params = new URLSearchParams(rawHash); - params.delete('wx_pay_result'); - const nextHash = params.toString(); - const nextUrl = `${window.location.pathname}${window.location.search}${nextHash ? `#${nextHash}` : ''}`; - window.history.replaceState(null, '', nextUrl); -} - -function readWechatPayResultFromHash(): WechatPayResult | null { - if (typeof window === 'undefined') { - return null; - } - - const result = new URLSearchParams( - window.location.hash.replace(/^#/, ''), - ).get('wx_pay_result'); - if (!result) { - return null; - } - - const [requestId = '', rawStatus = '', explicitOrderId = '', ...rawErrors] = - result.split(':'); - const inferredOrderId = requestId - .replace(/^wechat_pay_/, '') - .replace(/_\d+$/, '') - .trim(); - const orderId = explicitOrderId.trim() || inferredOrderId; - const status = - rawStatus === 'success' - ? 'success' - : rawStatus === 'cancel' - ? 'cancel' - : 'fail'; - let errorMessage: string | null = null; - const rawError = rawErrors.join(':'); - if (rawError) { - try { - errorMessage = decodeURIComponent(rawError); - } catch (_error) { - errorMessage = rawError; - } - } - - return { - requestId, - orderId: orderId || null, - status, - errorMessage, - }; -} - -function loadWechatJsSdk() { - if (typeof window === 'undefined') { - return Promise.reject(new Error('请在微信小程序内完成支付')); - } - if (window.wx?.miniProgram?.navigateTo) { - return Promise.resolve(window.wx); - } - - return new Promise>((resolve, reject) => { - const existingScript = document.querySelector( - `script[src="${WECHAT_JS_SDK_URL}"]`, - ); - const complete = () => { - if (window.wx?.miniProgram?.navigateTo) { - resolve(window.wx); - } else { - reject(new Error('请在微信小程序内完成支付')); - } - }; - - if (existingScript) { - existingScript.addEventListener('load', complete, { once: true }); - existingScript.addEventListener( - 'error', - () => reject(new Error('请在微信小程序内完成支付')), - { once: true }, - ); - complete(); - return; - } - - const script = document.createElement('script'); - script.src = WECHAT_JS_SDK_URL; - script.async = true; - script.onload = complete; - script.onerror = () => reject(new Error('请在微信小程序内完成支付')); - document.head.appendChild(script); - }); -} - -async function requestWechatMiniProgramPayment( - payload: - | WechatMiniProgramPayParams - | WechatMiniProgramVirtualPayParams - | null - | undefined, - orderId: string, -): Promise { - if (!payload) { - return Promise.reject(new Error('请在微信小程序内完成支付')); - } - const wxBridge = await loadWechatJsSdk(); - const miniProgram = wxBridge.miniProgram; - if (!miniProgram || typeof miniProgram.navigateTo !== 'function') { - return Promise.reject(new Error('请在微信小程序内完成支付')); - } - const navigateTo = miniProgram.navigateTo; - - const requestId = `wechat_pay_${orderId}_${Date.now()}`; - return new Promise((resolve, reject) => { - navigateTo({ - url: `/pages/wechat-pay/index?requestId=${encodeURIComponent(requestId)}&orderId=${encodeURIComponent(orderId)}&payParams=${encodeURIComponent(JSON.stringify(payload))}`, - success() { - resolve(); - }, - fail(error) { - console.error('[wechat-pay] navigateTo failed', error); - reject( - error instanceof Error - ? error - : new Error('请在微信小程序内完成支付'), - ); - }, - }); - }); -} - -function waitWechatPayConfirmDelay(delayMs: number) { - return new Promise((resolve) => { - window.setTimeout(resolve, delayMs); - }); -} - -async function confirmWechatRechargeOrderUntilSettled( - orderId: string, -): Promise { - let latestResponse = await confirmWechatRpgProfileRechargeOrder(orderId); - if (latestResponse.order.status !== 'pending') { - return latestResponse; - } - - for (const delayMs of WECHAT_PAY_CONFIRM_RETRY_DELAYS_MS) { - await waitWechatPayConfirmDelay(delayMs); - - latestResponse = await confirmWechatRpgProfileRechargeOrder(orderId); - if (latestResponse.order.status !== 'pending') { - return latestResponse; - } - } - - try { - const streamedResponse = await watchWechatRpgProfileRechargeOrder(orderId); - return streamedResponse; - } catch { - return latestResponse; - } -} - function useWechatNativeQrCode(codeUrl: string | null) { const [qrImageUrl, setQrImageUrl] = useState(null); @@ -4301,73 +4057,9 @@ export function RpgEntryHomeView({ const [desktopSearchKeyword, setDesktopSearchKeyword] = useState(''); const [mobileSearchKeyword, setMobileSearchKeyword] = useState(''); const [activeWorkSearchKeyword, setActiveWorkSearchKeyword] = useState(''); - const [isRewardCodeOpen, setIsRewardCodeOpen] = useState(false); - const [rewardCodeInput, setRewardCodeInput] = useState(''); - const [isSubmittingRewardCode, setIsSubmittingRewardCode] = useState(false); - const [rewardCodeError, setRewardCodeError] = useState(null); - const [rewardCodeSuccess, setRewardCodeSuccess] = useState( - null, - ); - const [isRechargeOpen, setIsRechargeOpen] = useState(false); - const [rechargeCenter, setRechargeCenter] = - useState(null); - const [isLoadingRechargeCenter, setIsLoadingRechargeCenter] = useState(false); - const [rechargeError, setRechargeError] = useState(null); - const [rechargePaymentResult, setRechargePaymentResult] = - useState(null); - const [ - wechatRechargeOrderConfirmationState, - setWechatRechargeOrderConfirmationState, - ] = useState(null); - const [nativeWechatPayment, setNativeWechatPayment] = - useState(null); - const [activeRechargeTab, setActiveRechargeTab] = - useState('points'); - const [submittingRechargeProductId, setSubmittingRechargeProductId] = - useState(null); - const [isWalletLedgerOpen, setIsWalletLedgerOpen] = useState(false); - const [walletLedger, setWalletLedger] = - useState(null); - const [walletLedgerError, setWalletLedgerError] = useState( - null, - ); - const [isLoadingWalletLedger, setIsLoadingWalletLedger] = useState(false); - const [isTaskCenterOpen, setIsTaskCenterOpen] = useState(false); - const [taskCenter, setTaskCenter] = - useState(null); - const [taskCenterError, setTaskCenterError] = useState(null); - const [isLoadingTaskCenter, setIsLoadingTaskCenter] = useState(false); - const taskCenterRequestIdRef = useRef(0); - const [claimingTaskId, setClaimingTaskId] = useState(null); - const [taskClaimSuccess, setTaskClaimSuccess] = useState(null); const [isQrScannerOpen, setIsQrScannerOpen] = useState(false); const [qrScannerError, setQrScannerError] = useState(null); const [qrScannerResult, setQrScannerResult] = useState(null); - const [profilePopupPanel, setProfilePopupPanel] = - useState(null); - const [referralCenter, setReferralCenter] = - useState(null); - const [isLoadingReferral, setIsLoadingReferral] = useState(false); - const [isReferralCenterInitialized, setIsReferralCenterInitialized] = - useState(false); - const pendingProfileInviteCode = useMemo( - () => - typeof window === 'undefined' - ? '' - : readProfileInviteCodeFromLocationSearch(window.location.search), - [], - ); - const promptedLoginForInviteQueryRef = useRef(false); - const autoOpenedInviteQueryRef = useRef(false); - const [referralRedeemCode, setReferralRedeemCode] = useState( - pendingProfileInviteCode, - ); - const [isSubmittingReferralRedeem, setIsSubmittingReferralRedeem] = - useState(false); - const [referralError, setReferralError] = useState(null); - const [referralSuccess, setReferralSuccess] = useState(null); - const { copyState: inviteCopyState, copyText: copyInviteText } = - useCopyFeedback(); const [selectedCategoryTag, setSelectedCategoryTag] = useState( null, ); @@ -4399,8 +4091,6 @@ export function RpgEntryHomeView({ const [activeLegalDocumentId, setActiveLegalDocumentId] = useState(null); const avatarFileInputRef = useRef(null); - const pendingWechatRechargeOrderIdRef = useRef(null); - const confirmingWechatRechargeOrderIdRef = useRef(null); const [isNicknameModalOpen, setIsNicknameModalOpen] = useState(false); const [nicknameInput, setNicknameInput] = useState(''); const [nicknameError, setNicknameError] = useState(null); @@ -4417,7 +4107,73 @@ export function RpgEntryHomeView({ }); const [avatarError, setAvatarError] = useState(null); const [isSavingAvatar, setIsSavingAvatar] = useState(false); - const isAuthenticated = Boolean(authUi?.user); + const currentUser = authUi?.user ?? null; + const isAuthenticated = Boolean(currentUser); + const { + activeRechargeTab, + buyRechargeProduct, + claimTaskReward, + claimingTaskId, + closeProfilePopupPanel, + confirmNativeWechatPayment, + copyInviteInfo, + inviteCopyState, + isLoadingRechargeCenter, + isLoadingReferral, + isLoadingTaskCenter, + isLoadingWalletLedger, + isRechargeOpen, + isRewardCodeOpen, + isSubmittingReferralRedeem, + isSubmittingRewardCode, + isTaskCenterOpen, + isWalletLedgerOpen, + loadRechargeCenter, + loadTaskCenter, + loadWalletLedger, + nativeWechatPayment, + openProfilePopupPanel, + openRechargeOrRewardCodeModal, + openRewardCodeModal, + openTaskCenterPanel, + openWalletLedgerPanel, + profilePopupPanel, + rechargeCenter, + rechargeError, + rechargePaymentResult, + referralCenter, + referralError, + referralRedeemCode, + referralSuccess, + rewardCodeError, + rewardCodeInput, + rewardCodeSuccess, + setActiveRechargeTab, + setIsRechargeOpen, + setIsRewardCodeOpen, + setIsTaskCenterOpen, + setIsWalletLedgerOpen, + setRechargePaymentResult, + setReferralRedeemCode, + setRewardCodeInput, + submittingRechargeProductId, + submitReferralRedeemCode, + submitRewardCode, + taskCenter, + taskCenterError, + taskClaimSuccess, + walletLedger, + walletLedgerError, + wechatRechargeOrderConfirmationState, + } = usePlatformProfileCenterController({ + activeTab, + isAuthenticated, + showRechargeEntry, + profileTaskRefreshKey, + onRechargeSuccess, + requestLogin: () => authUi?.openLoginModal(), + currentUser, + }); const edutainmentEntryEnabled = isEdutainmentEntryEnabled(); const [fallbackDesktopLayout] = useState(getInitialPlatformDesktopLayout); const isDesktopLayout = isDesktopLayoutProp ?? fallbackDesktopLayout; @@ -4699,26 +4455,6 @@ export function RpgEntryHomeView({ } authUi?.openLoginModal(); }; - - useEffect(() => { - if (!pendingProfileInviteCode || autoOpenedInviteQueryRef.current) { - return; - } - - if (!authUi?.user) { - if (!promptedLoginForInviteQueryRef.current) { - promptedLoginForInviteQueryRef.current = true; - authUi?.openLoginModal(); - } - return; - } - - autoOpenedInviteQueryRef.current = true; - setReferralRedeemCode(pendingProfileInviteCode); - setReferralError(null); - setReferralSuccess(null); - setProfilePopupPanel('redeem'); - }, [authUi, pendingProfileInviteCode]); const copyProfilePublicUserCode = () => { void copyProfileText(publicUserCode); }; @@ -4837,461 +4573,6 @@ export function RpgEntryHomeView({ }) .finally(() => setIsSavingAvatar(false)); }; - const loadWalletLedger = () => { - setWalletLedgerError(null); - setIsLoadingWalletLedger(true); - void getRpgProfileWalletLedger() - .then(setWalletLedger) - .catch((error: unknown) => { - setWalletLedger(null); - setWalletLedgerError( - error instanceof Error ? error.message : '读取泥点账单失败', - ); - }) - .finally(() => setIsLoadingWalletLedger(false)); - }; - const openWalletLedgerPanel = () => { - setIsWalletLedgerOpen(true); - loadWalletLedger(); - }; - const loadRechargeCenter = useCallback(() => { - setRechargeError(null); - setIsLoadingRechargeCenter(true); - void getRpgProfileRechargeCenter() - .then(setRechargeCenter) - .catch((error: unknown) => { - setRechargeCenter(null); - setRechargeError( - error instanceof Error ? error.message : '读取账户充值失败', - ); - }) - .finally(() => setIsLoadingRechargeCenter(false)); - }, []); - const refreshRechargeState = useCallback(() => { - loadRechargeCenter(); - setSubmittingRechargeProductId(null); - pendingWechatRechargeOrderIdRef.current = null; - confirmingWechatRechargeOrderIdRef.current = null; - setWechatRechargeOrderConfirmationState(null); - setNativeWechatPayment(null); - }, [loadRechargeCenter]); - const handleWechatPayResult = useCallback(() => { - const payResult = readWechatPayResultFromHash(); - if (!payResult) { - return false; - } - - if ( - pendingWechatRechargeOrderIdRef.current && - payResult.orderId && - payResult.orderId !== pendingWechatRechargeOrderIdRef.current - ) { - return false; - } - - if (payResult.status === 'success') { - const orderId = - payResult.orderId || pendingWechatRechargeOrderIdRef.current; - if (!orderId) { - clearWechatPayResultHash(); - return true; - } - if (confirmingWechatRechargeOrderIdRef.current === orderId) { - clearWechatPayResultHash(); - return true; - } - confirmingWechatRechargeOrderIdRef.current = orderId; - setWechatRechargeOrderConfirmationState({ orderId }); - setSubmittingRechargeProductId(null); - setRechargePaymentResult(null); - void confirmWechatRechargeOrderUntilSettled(orderId) - .then((response) => { - const isPaid = response.order.status === 'paid'; - setRechargeCenter(response.center); - pendingWechatRechargeOrderIdRef.current = null; - confirmingWechatRechargeOrderIdRef.current = null; - setWechatRechargeOrderConfirmationState(null); - setRechargePaymentResult( - isPaid - ? { - kind: 'success', - title: '支付成功', - message: '已到账,账户状态已刷新。', - } - : { - kind: 'pending', - title: '支付处理中', - message: '正在等待到账状态确认,请稍后查看余额或会员状态。', - }, - ); - if (isPaid) { - void onRechargeSuccess?.(); - } - clearWechatPayResultHash(); - }) - .catch(() => { - confirmingWechatRechargeOrderIdRef.current = null; - setWechatRechargeOrderConfirmationState(null); - setRechargePaymentResult({ - kind: 'pending', - title: '支付处理中', - message: '暂时没能确认到账状态,请稍后查看余额或会员状态。', - }); - clearWechatPayResultHash(); - }); - } else if (payResult.status === 'cancel') { - setRechargePaymentResult({ - kind: 'cancel', - title: '支付已取消', - message: '本次没有扣款,账户状态未发生变化。', - }); - setWechatRechargeOrderConfirmationState(null); - refreshRechargeState(); - } else { - const detail = payResult.errorMessage - ? `微信返回:${payResult.errorMessage}` - : '微信支付没有完成,本次不会入账。'; - setRechargePaymentResult({ - kind: 'failed', - title: '支付未完成', - message: detail, - }); - setWechatRechargeOrderConfirmationState(null); - refreshRechargeState(); - } - - clearWechatPayResultHash(); - return true; - }, [onRechargeSuccess, refreshRechargeState]); - const pollWechatPayResultFromHash = useCallback( - () => handleWechatPayResult(), - [handleWechatPayResult], - ); - const confirmPendingWechatRechargeOrder = useCallback(() => { - const orderId = pendingWechatRechargeOrderIdRef.current; - if (!orderId || confirmingWechatRechargeOrderIdRef.current === orderId) { - return false; - } - - confirmingWechatRechargeOrderIdRef.current = orderId; - setWechatRechargeOrderConfirmationState({ orderId }); - setRechargePaymentResult(null); - void confirmWechatRechargeOrderUntilSettled(orderId) - .then((response) => { - const isPaid = response.order.status === 'paid'; - setRechargeCenter(response.center); - pendingWechatRechargeOrderIdRef.current = null; - confirmingWechatRechargeOrderIdRef.current = null; - setWechatRechargeOrderConfirmationState(null); - setSubmittingRechargeProductId(null); - setRechargePaymentResult( - isPaid - ? { - kind: 'success', - title: '支付成功', - message: '已到账,账户状态已刷新。', - } - : { - kind: 'pending', - title: '支付处理中', - message: '正在等待到账状态确认,请稍后查看余额或会员状态。', - }, - ); - if (isPaid) { - void onRechargeSuccess?.(); - } - }) - .catch(() => { - confirmingWechatRechargeOrderIdRef.current = null; - setWechatRechargeOrderConfirmationState(null); - setRechargePaymentResult({ - kind: 'pending', - title: '支付处理中', - message: '暂时没能确认到账状态,请稍后查看余额或会员状态。', - }); - }); - return true; - }, [onRechargeSuccess]); - const openRechargeModal = () => { - if (!authUi?.user) { - authUi?.openLoginModal(); - return; - } - - setIsRechargeOpen(true); - loadRechargeCenter(); - }; - const openRechargeOrRewardCodeModal = () => { - if (showRechargeEntry) { - openRechargeModal(); - return; - } - - openRewardCodeModal(); - }; - const buyRechargeProduct = (product: ProfileRechargeProduct) => { - if (submittingRechargeProductId) { - return; - } - - const paymentChannel = resolveProfileRechargeProductPaymentChannel( - { kind: product.kind }, - {}, - ); - setSubmittingRechargeProductId(product.productId); - setRechargeError(null); - setRechargePaymentResult(null); - setWechatRechargeOrderConfirmationState(null); - setNativeWechatPayment(null); - void createRpgProfileRechargeOrder(product.productId, paymentChannel) - .then(async (response) => { - if (paymentChannel === WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_CHANNEL) { - pendingWechatRechargeOrderIdRef.current = response.order.orderId; - setRechargeCenter(response.center); - await requestWechatMiniProgramPayment( - response.wechatMiniProgramPayParams, - response.order.orderId, - ); - return; - } - if (paymentChannel === WECHAT_H5_PAYMENT_CHANNEL) { - const h5Url = response.wechatH5Payment?.h5Url?.trim(); - if (!h5Url) { - throw new Error('微信 H5 支付链接生成失败'); - } - pendingWechatRechargeOrderIdRef.current = response.order.orderId; - setRechargeCenter(response.center); - setRechargePaymentResult({ - kind: 'pending', - title: '正在打开微信支付', - message: '完成支付后返回页面确认到账状态。', - }); - redirectToPaymentUrl(h5Url); - return; - } - if (paymentChannel === WECHAT_NATIVE_PAYMENT_CHANNEL) { - const codeUrl = response.wechatNativePayment?.codeUrl?.trim(); - if (!codeUrl) { - throw new Error('微信 Native 支付二维码生成失败'); - } - pendingWechatRechargeOrderIdRef.current = response.order.orderId; - setRechargeCenter(response.center); - setNativeWechatPayment({ - orderId: response.order.orderId, - codeUrl, - isConfirming: false, - }); - setSubmittingRechargeProductId(null); - return; - } - - throw new Error('充值支付渠道无效'); - }) - .catch((error: unknown) => { - pendingWechatRechargeOrderIdRef.current = null; - setNativeWechatPayment(null); - setRechargeError(error instanceof Error ? error.message : '充值失败'); - setSubmittingRechargeProductId(null); - }); - }; - const confirmNativeWechatPayment = useCallback(() => { - if (!nativeWechatPayment || nativeWechatPayment.isConfirming) { - return; - } - - setNativeWechatPayment((current) => - current && current.orderId === nativeWechatPayment.orderId - ? { ...current, isConfirming: true } - : current, - ); - setRechargePaymentResult({ - kind: 'pending', - title: '正在确认支付', - message: '正在查询微信支付到账状态。', - }); - void confirmWechatRechargeOrderUntilSettled(nativeWechatPayment.orderId) - .then((response) => { - const isPaid = response.order.status === 'paid'; - setRechargeCenter(response.center); - setRechargePaymentResult( - isPaid - ? { - kind: 'success', - title: '支付成功', - message: '已到账,账户状态已刷新。', - } - : { - kind: 'pending', - title: '等待微信确认', - message: '暂时没能确认到账状态,请稍后再试。', - }, - ); - if (isPaid) { - setNativeWechatPayment(null); - pendingWechatRechargeOrderIdRef.current = null; - void onRechargeSuccess?.(); - } else { - setNativeWechatPayment((current) => - current && current.orderId === nativeWechatPayment.orderId - ? { ...current, isConfirming: false } - : current, - ); - } - }) - .catch(() => { - setRechargePaymentResult({ - kind: 'pending', - title: '等待微信确认', - message: '暂时没能确认到账状态,请稍后再试。', - }); - setNativeWechatPayment((current) => - current && current.orderId === nativeWechatPayment.orderId - ? { ...current, isConfirming: false } - : current, - ); - }) - .finally(() => setSubmittingRechargeProductId(null)); - }, [nativeWechatPayment, onRechargeSuccess]); - useEffect(() => { - const handleHashChange = () => { - handleWechatPayResult(); - }; - const handleResume = () => { - if ( - typeof document !== 'undefined' && - document.visibilityState === 'hidden' - ) { - return; - } - if (!handleWechatPayResult()) { - confirmPendingWechatRechargeOrder(); - } - }; - - window.addEventListener('hashchange', handleHashChange); - window.addEventListener('focus', handleResume); - window.addEventListener('pageshow', handleResume); - document.addEventListener('visibilitychange', handleResume); - handleWechatPayResult(); - return () => { - window.removeEventListener('hashchange', handleHashChange); - window.removeEventListener('focus', handleResume); - window.removeEventListener('pageshow', handleResume); - document.removeEventListener('visibilitychange', handleResume); - }; - }, [confirmPendingWechatRechargeOrder, handleWechatPayResult]); - useEffect(() => { - if (!submittingRechargeProductId || wechatRechargeOrderConfirmationState) { - return undefined; - } - - const startedAt = Date.now(); - let timer: number | null = null; - const pollPayResult = () => { - if (pollWechatPayResultFromHash()) { - return; - } - if (Date.now() - startedAt >= WECHAT_PAY_RESULT_RECHECK_TIMEOUT_MS) { - return; - } - timer = window.setTimeout( - pollPayResult, - WECHAT_PAY_RESULT_RECHECK_INTERVAL_MS, - ); - }; - - timer = window.setTimeout( - pollPayResult, - WECHAT_PAY_RESULT_RECHECK_INTERVAL_MS, - ); - return () => { - if (timer !== null) { - window.clearTimeout(timer); - } - }; - }, [ - pollWechatPayResultFromHash, - submittingRechargeProductId, - wechatRechargeOrderConfirmationState, - ]); - const loadTaskCenter = useCallback(() => { - const requestId = ++taskCenterRequestIdRef.current; - setTaskCenterError(null); - setIsLoadingTaskCenter(true); - void getRpgProfileTasks() - .then((center) => { - if (requestId === taskCenterRequestIdRef.current) { - setTaskCenter(center); - } - }) - .catch((error: unknown) => { - if (requestId !== taskCenterRequestIdRef.current) { - return; - } - setTaskCenter(null); - setTaskCenterError( - error instanceof Error ? error.message : '读取每日任务失败', - ); - }) - .finally(() => { - if (requestId === taskCenterRequestIdRef.current) { - setIsLoadingTaskCenter(false); - } - }); - }, []); - - useEffect(() => { - if (activeTab !== 'profile' || !isAuthenticated) { - taskCenterRequestIdRef.current += 1; - setTaskCenter(null); - setTaskCenterError(null); - return; - } - - loadTaskCenter(); - }, [activeTab, isAuthenticated, loadTaskCenter, profileTaskRefreshKey]); - - useEffect(() => { - if (activeTab !== 'profile' || !isAuthenticated) { - return undefined; - } - - let cancelled = false; - let timer: number | null = null; - - const scheduleNextReset = () => { - if (cancelled) { - return; - } - timer = window.setTimeout(() => { - void refreshStoredAccessToken({ clearOnFailure: false }) - .catch(() => undefined) - .finally(() => { - if (cancelled) { - return; - } - loadTaskCenter(); - scheduleNextReset(); - }); - }, getDelayUntilNextProfileTaskReset()); - }; - - scheduleNextReset(); - return () => { - cancelled = true; - if (timer !== null) { - window.clearTimeout(timer); - } - }; - }, [activeTab, isAuthenticated, loadTaskCenter]); - - const openTaskCenterPanel = () => { - setIsTaskCenterOpen(true); - setTaskClaimSuccess(null); - if (!taskCenter) { - loadTaskCenter(); - } - }; const openQrScannerPanel = () => { if (!authUi?.user) { authUi?.openLoginModal(); @@ -5302,132 +4583,6 @@ export function RpgEntryHomeView({ setQrScannerResult(null); setIsQrScannerOpen(true); }; - const loadReferralCenter = useCallback(() => { - setIsLoadingReferral(true); - setIsReferralCenterInitialized(false); - void getRpgProfileReferralInviteCenter() - .then(setReferralCenter) - .catch((error: unknown) => { - setReferralCenter(null); - setReferralError( - error instanceof Error ? error.message : '读取邀请码失败', - ); - }) - .finally(() => { - setIsReferralCenterInitialized(true); - setIsLoadingReferral(false); - }); - }, []); - useEffect(() => { - if (activeTab !== 'profile' || !isAuthenticated) { - setIsReferralCenterInitialized(false); - setReferralCenter(null); - return; - } - - loadReferralCenter(); - }, [activeTab, isAuthenticated, loadReferralCenter]); - const openProfilePopupPanel = (panel: ProfileReferralPanel) => { - setProfilePopupPanel(panel); - setReferralError(null); - setReferralSuccess(null); - if (panel === 'redeem') { - setReferralRedeemCode(pendingProfileInviteCode); - } - if (panel === 'community') { - return; - } - - if (!isReferralCenterInitialized && !isLoadingReferral) { - loadReferralCenter(); - } - }; - const copyInviteInfo = () => { - if (!referralCenter?.inviteCode) { - return; - } - - const inviteUrl = - typeof window === 'undefined' - ? referralCenter.inviteLinkPath - : new URL(referralCenter.inviteLinkPath, window.location.origin).href; - void copyInviteText(`${referralCenter.inviteCode} ${inviteUrl}`).then( - (copied) => { - setReferralSuccess(copied ? '已复制' : '复制失败'); - }, - ); - }; - const submitReferralRedeemCode = () => { - const inviteCode = referralRedeemCode - .trim() - .replace(/[^0-9a-z]/gi, '') - .toUpperCase(); - if (isSubmittingReferralRedeem || !inviteCode) { - return; - } - - setIsSubmittingReferralRedeem(true); - setReferralError(null); - setReferralSuccess(null); - void redeemRpgProfileReferralInviteCode(inviteCode) - .then((response) => { - setReferralCenter(response.center); - setReferralRedeemCode(''); - setReferralSuccess('已填写'); - void onRechargeSuccess?.(); - }) - .catch((error: unknown) => { - setReferralError( - error instanceof Error ? error.message : '填写邀请码失败', - ); - }) - .finally(() => setIsSubmittingReferralRedeem(false)); - }; - const openRewardCodeModal = () => { - setIsRewardCodeOpen(true); - setRewardCodeError(null); - setRewardCodeSuccess(null); - }; - const submitRewardCode = () => { - if (isSubmittingRewardCode || !rewardCodeInput.trim()) { - return; - } - - setIsSubmittingRewardCode(true); - setRewardCodeError(null); - setRewardCodeSuccess(null); - void redeemRpgProfileRewardCode(rewardCodeInput) - .then((response: RedeemProfileRewardCodeResponse) => { - setRewardCodeInput(''); - setRewardCodeSuccess(`已到账 ${response.amountGranted} 泥点`); - void onRechargeSuccess?.(); - }) - .catch((error: unknown) => { - setRewardCodeError(error instanceof Error ? error.message : '兑换失败'); - }) - .finally(() => setIsSubmittingRewardCode(false)); - }; - const claimTaskReward = (taskId: string) => { - if (claimingTaskId) { - return; - } - - setClaimingTaskId(taskId); - setTaskCenterError(null); - setTaskClaimSuccess(null); - void claimRpgProfileTaskReward(taskId) - .then((response) => { - setTaskCenter(response.center); - setTaskClaimSuccess(`已领取 ${response.rewardPoints} 泥点`); - void onRechargeSuccess?.(); - }) - .catch((error: unknown) => { - setTaskCenterError( - error instanceof Error ? error.message : '领取任务奖励失败', - ); - }) - .finally(() => setClaimingTaskId(null)); - }; const clearWorkSearch = () => { setActiveWorkSearchKeyword(''); setDesktopSearchKeyword(''); @@ -7359,7 +6514,7 @@ export function RpgEntryHomeView({ saveEntries={saveEntries} saveError={saveError} isResumingSaveWorldKey={isResumingSaveWorldKey} - onClose={() => setProfilePopupPanel(null)} + onClose={closeProfilePopupPanel} onResumeSave={onResumeSave} /> ) : profilePopupPanel ? ( @@ -7372,7 +6527,7 @@ export function RpgEntryHomeView({ copyInviteState={inviteCopyState} error={referralError} success={referralSuccess} - onClose={() => setProfilePopupPanel(null)} + onClose={closeProfilePopupPanel} onCopyInvite={copyInviteInfo} onRedeemCodeChange={setReferralRedeemCode} onSubmitRedeemCode={submitReferralRedeemCode} @@ -7549,7 +6704,7 @@ export function RpgEntryHomeView({ saveEntries={saveEntries} saveError={saveError} isResumingSaveWorldKey={isResumingSaveWorldKey} - onClose={() => setProfilePopupPanel(null)} + onClose={closeProfilePopupPanel} onResumeSave={onResumeSave} /> ) : profilePopupPanel ? ( @@ -7562,7 +6717,7 @@ export function RpgEntryHomeView({ copyInviteState={inviteCopyState} error={referralError} success={referralSuccess} - onClose={() => setProfilePopupPanel(null)} + onClose={closeProfilePopupPanel} onCopyInvite={copyInviteInfo} onRedeemCodeChange={setReferralRedeemCode} onSubmitRedeemCode={submitReferralRedeemCode}