拆出个人中心剩余弹层组件
- 新增充值账单任务兑换码共享组件并补齐组件级测试 - 让 RpgEntryHomeView 改为复用新的 profile 弹层组件并删除内联实现 - 更新 PlatformUiKit 收口文档与团队共享记忆记录新的组件沉淀
This commit is contained in:
@@ -42,6 +42,8 @@
|
|||||||
- 2026-06-10 追加:RPG 首页个人中心的“玩过 / 可继续”历史弹层统一抽到 `src/components/platform-entry/PlatformProfilePlayedWorksModal.tsx`;`RpgEntryHomeView` 不再内联 `SaveArchiveCard`、`ProfilePlayedWorksModal` 和未连通的 `ProfileSaveArchivesModal`。当前产品语义已经把存档恢复并入“玩过”弹层的“可继续”分区,因此 controller 里的 `ProfilePopupPanel` 也去掉了没有真实入口的 `saveArchives` 分支。验证命令:`npm run test -- src/components/platform-entry/PlatformProfilePlayedWorksModal.test.tsx src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx`、`npm run typecheck`。
|
- 2026-06-10 追加:RPG 首页个人中心的“玩过 / 可继续”历史弹层统一抽到 `src/components/platform-entry/PlatformProfilePlayedWorksModal.tsx`;`RpgEntryHomeView` 不再内联 `SaveArchiveCard`、`ProfilePlayedWorksModal` 和未连通的 `ProfileSaveArchivesModal`。当前产品语义已经把存档恢复并入“玩过”弹层的“可继续”分区,因此 controller 里的 `ProfilePopupPanel` 也去掉了没有真实入口的 `saveArchives` 分支。验证命令:`npm run test -- src/components/platform-entry/PlatformProfilePlayedWorksModal.test.tsx src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx`、`npm run typecheck`。
|
||||||
- 2026-06-10 追加:个人中心标准头部弹窗与白底副弹层的共享壳层统一抽到 `src/components/platform-entry/PlatformProfileModalShell.tsx`;标准头部弹窗优先复用 `PlatformProfileModalShell`,白底副弹层优先复用 `PlatformProfileSecondaryModalShell`,不再在业务页重复手写 profile overlay、header、title、description、floating close 和关闭策略。昵称修改、账户充值、每日任务、兑换码、泥点账单、“玩过 / 可继续”以及邀请相关弹层已接入这套壳层。
|
- 2026-06-10 追加:个人中心标准头部弹窗与白底副弹层的共享壳层统一抽到 `src/components/platform-entry/PlatformProfileModalShell.tsx`;标准头部弹窗优先复用 `PlatformProfileModalShell`,白底副弹层优先复用 `PlatformProfileSecondaryModalShell`,不再在业务页重复手写 profile overlay、header、title、description、floating close 和关闭策略。昵称修改、账户充值、每日任务、兑换码、泥点账单、“玩过 / 可继续”以及邀请相关弹层已接入这套壳层。
|
||||||
- 2026-06-10 追加:RPG 首页个人中心的邀请好友 / 填邀请码 / 玩家社区三态弹层统一抽到 `src/components/platform-entry/PlatformProfileReferralModal.tsx`;首页不再内联邀请码规范化、社区二维码卡片和邀请用户头像行,后续 profile 侧同类二级弹层优先按“独立组件 + `PlatformProfileSecondaryModalShell`”继续收口。
|
- 2026-06-10 追加:RPG 首页个人中心的邀请好友 / 填邀请码 / 玩家社区三态弹层统一抽到 `src/components/platform-entry/PlatformProfileReferralModal.tsx`;首页不再内联邀请码规范化、社区二维码卡片和邀请用户头像行,后续 profile 侧同类二级弹层优先按“独立组件 + `PlatformProfileSecondaryModalShell`”继续收口。
|
||||||
|
- 2026-06-10 追加:RPG 首页个人中心的账户充值弹层统一抽到 `src/components/platform-entry/PlatformProfileRechargeModal.tsx`;充值 tab、套餐卡片、Native 二维码生成和确认支付入口不再内联在 `RpgEntryHomeView`,后续 profile 侧充值入口优先复用同一个组件。
|
||||||
|
- 2026-06-10 追加:RPG 首页个人中心的泥点账单、每日任务和兑换码弹层统一抽到 `src/components/platform-entry/PlatformProfileWalletLedgerModal.tsx`、`src/components/platform-entry/PlatformProfileTaskCenterModal.tsx` 与 `src/components/platform-entry/PlatformProfileRewardCodeRedeemModal.tsx`;`RpgEntryHomeView` 只保留打开条件和数据流,标准 profile 弹层内容以后优先沉到 `platform-entry` 独立组件,不在首页继续堆叠。
|
||||||
- 2026-06-09 追加:通用输入 Composer 的上传参考图、发送和移除参考图已迁移到 `PlatformIconButton`;图标上传仍使用 `asChild="label"` 保留 label + file input 语义,公共组件会自动写入隐藏文本,确保内嵌 file input 继承可访问名称。
|
- 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 追加: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`。
|
- 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`。
|
||||||
|
|||||||
@@ -238,6 +238,8 @@
|
|||||||
19.3.14. RPG 首页个人中心的“玩过 / 可继续”历史弹层抽到 `src/components/platform-entry/PlatformProfilePlayedWorksModal.tsx`;`RpgEntryHomeView` 不再内联 `SaveArchiveCard`、`ProfilePlayedWorksModal` 和旧的 `ProfileSaveArchivesModal`。当前真实产品语义已经把存档恢复并入“玩过”弹层的“可继续”分区,因此未连通的 `saveArchives` profile popup 分支一并删除,避免继续维护没有入口的独立壳层。组件级验证新增 `src/components/platform-entry/PlatformProfilePlayedWorksModal.test.tsx`,并继续复用 `RpgEntryHomeView.recharge.test.tsx` 的个人中心集成断言。验证命令:`npm run test -- src/components/platform-entry/PlatformProfilePlayedWorksModal.test.tsx src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx`、`npm run typecheck`。
|
19.3.14. RPG 首页个人中心的“玩过 / 可继续”历史弹层抽到 `src/components/platform-entry/PlatformProfilePlayedWorksModal.tsx`;`RpgEntryHomeView` 不再内联 `SaveArchiveCard`、`ProfilePlayedWorksModal` 和旧的 `ProfileSaveArchivesModal`。当前真实产品语义已经把存档恢复并入“玩过”弹层的“可继续”分区,因此未连通的 `saveArchives` profile popup 分支一并删除,避免继续维护没有入口的独立壳层。组件级验证新增 `src/components/platform-entry/PlatformProfilePlayedWorksModal.test.tsx`,并继续复用 `RpgEntryHomeView.recharge.test.tsx` 的个人中心集成断言。验证命令:`npm run test -- src/components/platform-entry/PlatformProfilePlayedWorksModal.test.tsx src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx`、`npm run typecheck`。
|
||||||
19.3.15. 个人中心标准头部弹窗与白底副弹层壳层统一抽到 `src/components/platform-entry/PlatformProfileModalShell.tsx`;`PlatformProfileModalShell` 负责标准账户弹窗的 overlay、header、title、description、close variant 和 `closeOnBackdrop={false} / closeOnEscape={false}` 约束,`PlatformProfileSecondaryModalShell` 负责白底副弹层的 overlay、floating close、`bodyClassName="!p-0"` 和内容外壳。`RpgEntryHomeView` 内的昵称修改、账户充值、每日任务、兑换码、泥点账单与“玩过”弹层已接到共享壳层,页面不再重复手写个人中心弹窗的基础 chrome 与关闭策略。
|
19.3.15. 个人中心标准头部弹窗与白底副弹层壳层统一抽到 `src/components/platform-entry/PlatformProfileModalShell.tsx`;`PlatformProfileModalShell` 负责标准账户弹窗的 overlay、header、title、description、close variant 和 `closeOnBackdrop={false} / closeOnEscape={false}` 约束,`PlatformProfileSecondaryModalShell` 负责白底副弹层的 overlay、floating close、`bodyClassName="!p-0"` 和内容外壳。`RpgEntryHomeView` 内的昵称修改、账户充值、每日任务、兑换码、泥点账单与“玩过”弹层已接到共享壳层,页面不再重复手写个人中心弹窗的基础 chrome 与关闭策略。
|
||||||
19.3.16. RPG 首页个人中心的邀请好友 / 填邀请码 / 玩家社区三态弹层抽到 `src/components/platform-entry/PlatformProfileReferralModal.tsx`;组件统一复用 `PlatformProfileSecondaryModalShell` 承接居中白底浮层、floatingPlain 关闭按钮和成功 / 失败提示区,`RpgEntryHomeView` 不再内联邀请码规范化、社区二维码卡片和邀请用户头像行。组件级验证新增 `src/components/platform-entry/PlatformProfileReferralModal.test.tsx`,首页继续复用 `RpgEntryHomeView.recharge.test.tsx` 的邀请链路断言。验证命令:`npm run test -- src/components/platform-entry/PlatformProfileReferralModal.test.tsx src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx -t "renders invite panel with shared profile content|submits redeem panel with the shared form shell|renders community QR panels|profile community shortcut shows reward subtitle and invited users|invite query opens redeem modal directly for logged in users|profile redeem invite query modal submits code after login"`、`npm run typecheck`。
|
19.3.16. RPG 首页个人中心的邀请好友 / 填邀请码 / 玩家社区三态弹层抽到 `src/components/platform-entry/PlatformProfileReferralModal.tsx`;组件统一复用 `PlatformProfileSecondaryModalShell` 承接居中白底浮层、floatingPlain 关闭按钮和成功 / 失败提示区,`RpgEntryHomeView` 不再内联邀请码规范化、社区二维码卡片和邀请用户头像行。组件级验证新增 `src/components/platform-entry/PlatformProfileReferralModal.test.tsx`,首页继续复用 `RpgEntryHomeView.recharge.test.tsx` 的邀请链路断言。验证命令:`npm run test -- src/components/platform-entry/PlatformProfileReferralModal.test.tsx src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx -t "renders invite panel with shared profile content|submits redeem panel with the shared form shell|renders community QR panels|profile community shortcut shows reward subtitle and invited users|invite query opens redeem modal directly for logged in users|profile redeem invite query modal submits code after login"`、`npm run typecheck`。
|
||||||
|
19.3.17. RPG 首页个人中心的账户充值弹层抽到 `src/components/platform-entry/PlatformProfileRechargeModal.tsx`;组件承接 Native 二维码生成、点数 / 会员 tab、套餐卡片、空态和错误重试,继续复用 `PlatformProfileModalShell` 与平台白底卡片 token,`RpgEntryHomeView` 不再内联 `useWechatNativeQrCode`、`RechargeProductCard` 和 `ProfileRechargeModal`。组件级验证新增 `src/components/platform-entry/PlatformProfileRechargeModal.test.tsx`,首页继续复用 `RpgEntryHomeView.recharge.test.tsx` 的充值入口与 Native 二维码断言。验证命令:`npm run test -- src/components/platform-entry/PlatformProfileRechargeModal.test.tsx src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx -t "renders point products and forwards buy action|shows empty state when the selected tab has no products|profile recharge modal shows native qr code on desktop web by default|create tab wallet chip opens recharge when recharge entry is enabled"`、`npm run typecheck`。
|
||||||
|
19.3.18. RPG 首页个人中心的泥点账单、每日任务和兑换码三类标准 profile 弹层分别抽到 `src/components/platform-entry/PlatformProfileWalletLedgerModal.tsx`、`src/components/platform-entry/PlatformProfileTaskCenterModal.tsx` 与 `src/components/platform-entry/PlatformProfileRewardCodeRedeemModal.tsx`;账单继续复用 `PlatformProfileSecondaryModalShell`,任务和兑换码继续复用 `PlatformProfileModalShell`,页面不再内联账单余额 badge、任务领取列表和兑换码输入提交实现。三者均新增组件级测试,并继续复用 `RpgEntryHomeView.recharge.test.tsx` 的真实入口断言。验证命令:`npm run test -- src/components/platform-entry/PlatformProfileWalletLedgerModal.test.tsx src/components/platform-entry/PlatformProfileTaskCenterModal.test.tsx src/components/platform-entry/PlatformProfileRewardCodeRedeemModal.test.tsx src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx -t "renders ledger entries with shared balance presentation|retries from the shared error state|renders claimable tasks and forwards claim action|keeps incomplete tasks disabled|submits on button click and enter key|disables submit when the code is blank|opens wallet ledger modal from narrative coin card|profile daily task shortcut reflects task progress and claim updates|wallet ledger modal shows empty and error states|opens reward code modal from profile action on mobile|create tab wallet chip opens reward code when recharge entry is hidden"`、`npm run typecheck`。
|
||||||
19.3. creative-agent 首页的侧边栏菜单、账号入口、开启新对话、我的创作、首页激励 CTA 和 prompt suggestion 按钮迁移到 `PlatformIconButton` / `PlatformActionButton`;首页继续保留 `creative-agent-home__*` 本地 class 承接透明顶栏、抽屉和品牌化胶囊视觉,不把视觉回收和语义收口绑成一次大改。`Beta` 徽标和历史记录纯文本行暂保留本地实现,等出现更多同构轻量列表行后再评估是否抽新的共享 row primitive。
|
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 顶栏按钮壳。
|
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"`。
|
20. 平台方形上传入口和紧凑虚线新增入口迁移到 `PlatformUploadTile`,上传后的图片预览迁移到 `PlatformUploadPreviewCard`;反馈页上传凭证入口 / 预览、敲木鱼工作台新增功德词条入口、通用创作图片面板的提示词参考图缩略图、抓大鹅封面编辑参考图缩略图、通用输入 Composer 已选参考图条、creation-agent 已选参考图条和拼图结果页关卡引用图横条已先迁移。方形缩略图使用默认 `layout="square"`,横向“已选择参考图 / 文件名 / 素材名 / 移除”条使用 `layout="inline"`;只读引用图条不传 `onRemove`,避免公共组件额外渲染删除入口。后续继续收口结果页素材上传、工作台参考图上传、紧凑虚线新增入口等上传 / 动作块时,业务页只保留文件选择、预览数组、预览回调、删除回调、校验逻辑或新增回调,上传方块外观、主副文案、缩略图壳、预览按钮、标题行、横向已选条、移除按钮和禁用态统一由 Module 承接;工具栏中的小图标上传仍继续使用 `PlatformIconButton asChild="label"`。
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
/* @vitest-environment jsdom */
|
||||||
|
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { describe, expect, test, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { PlatformProfileRechargeModal } from './PlatformProfileRechargeModal';
|
||||||
|
|
||||||
|
describe('PlatformProfileRechargeModal', () => {
|
||||||
|
test('renders point products and forwards buy action', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const onBuy = vi.fn();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<PlatformProfileRechargeModal
|
||||||
|
center={{
|
||||||
|
walletBalance: 29,
|
||||||
|
membership: {
|
||||||
|
status: 'normal',
|
||||||
|
tier: 'normal',
|
||||||
|
startedAt: null,
|
||||||
|
expiresAt: null,
|
||||||
|
updatedAt: null,
|
||||||
|
},
|
||||||
|
pointProducts: [
|
||||||
|
{
|
||||||
|
productId: 'points_60',
|
||||||
|
title: '60泥点',
|
||||||
|
priceCents: 600,
|
||||||
|
kind: 'points',
|
||||||
|
pointsAmount: 60,
|
||||||
|
bonusPoints: 30,
|
||||||
|
durationDays: 0,
|
||||||
|
badgeLabel: '首充加赠',
|
||||||
|
description: '首充加赠30泥点',
|
||||||
|
tier: 'normal',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
membershipProducts: [],
|
||||||
|
benefits: [],
|
||||||
|
latestOrder: null,
|
||||||
|
hasPointsRecharged: false,
|
||||||
|
}}
|
||||||
|
isLoading={false}
|
||||||
|
error={null}
|
||||||
|
submittingProductId={null}
|
||||||
|
nativePayment={null}
|
||||||
|
activeTab="points"
|
||||||
|
onTabChange={vi.fn()}
|
||||||
|
onClose={vi.fn()}
|
||||||
|
onRetry={vi.fn()}
|
||||||
|
onBuy={onBuy}
|
||||||
|
onConfirmNativePayment={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('账户充值')).toBeTruthy();
|
||||||
|
expect(screen.getByText('29泥点 · 普通用户')).toBeTruthy();
|
||||||
|
expect(screen.getByText('60+30泥点')).toBeTruthy();
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', { name: /60泥点.*购买/u }));
|
||||||
|
expect(onBuy).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ productId: 'points_60' }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shows empty state when the selected tab has no products', () => {
|
||||||
|
render(
|
||||||
|
<PlatformProfileRechargeModal
|
||||||
|
center={{
|
||||||
|
walletBalance: 0,
|
||||||
|
membership: {
|
||||||
|
status: 'normal',
|
||||||
|
tier: 'normal',
|
||||||
|
startedAt: null,
|
||||||
|
expiresAt: null,
|
||||||
|
updatedAt: null,
|
||||||
|
},
|
||||||
|
pointProducts: [],
|
||||||
|
membershipProducts: [],
|
||||||
|
benefits: [],
|
||||||
|
latestOrder: null,
|
||||||
|
hasPointsRecharged: false,
|
||||||
|
}}
|
||||||
|
isLoading={false}
|
||||||
|
error={null}
|
||||||
|
submittingProductId={null}
|
||||||
|
nativePayment={null}
|
||||||
|
activeTab="membership"
|
||||||
|
onTabChange={vi.fn()}
|
||||||
|
onClose={vi.fn()}
|
||||||
|
onRetry={vi.fn()}
|
||||||
|
onBuy={vi.fn()}
|
||||||
|
onConfirmNativePayment={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('暂无可购买套餐')).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
269
src/components/platform-entry/PlatformProfileRechargeModal.tsx
Normal file
269
src/components/platform-entry/PlatformProfileRechargeModal.tsx
Normal file
@@ -0,0 +1,269 @@
|
|||||||
|
import QRCode from 'qrcode';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
import type {
|
||||||
|
ProfileRechargeCenterResponse,
|
||||||
|
ProfileRechargeProduct,
|
||||||
|
} from '../../../packages/shared/src/contracts/runtime';
|
||||||
|
import { PlatformActionButton } from '../common/PlatformActionButton';
|
||||||
|
import { PlatformEmptyState } from '../common/PlatformEmptyState';
|
||||||
|
import { PlatformPillBadge } from '../common/PlatformPillBadge';
|
||||||
|
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
|
||||||
|
import { PlatformSubpanel } from '../common/PlatformSubpanel';
|
||||||
|
import { PlatformProfileModalShell } from './PlatformProfileModalShell';
|
||||||
|
import type {
|
||||||
|
NativeWechatPaymentState,
|
||||||
|
RechargeTab,
|
||||||
|
} from './usePlatformProfileCenterController';
|
||||||
|
import { buildMembershipLabel } from '../rpg-entry/rpgEntryProfileFundsViewModel';
|
||||||
|
import { formatSnapshotTime } from '../rpg-entry/rpgEntryProfileDashboardPresentation';
|
||||||
|
import {
|
||||||
|
buildRechargeProductValueLabel,
|
||||||
|
formatRechargePrice,
|
||||||
|
} from '../rpg-entry/rpgEntryProfileFundsViewModel';
|
||||||
|
|
||||||
|
const WECHAT_NATIVE_PAY_QR_IMAGE_SIZE = 180;
|
||||||
|
|
||||||
|
export type PlatformProfileRechargeModalProps = {
|
||||||
|
center: ProfileRechargeCenterResponse | null;
|
||||||
|
isLoading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
submittingProductId: string | null;
|
||||||
|
nativePayment: NativeWechatPaymentState | null;
|
||||||
|
activeTab: RechargeTab;
|
||||||
|
onTabChange: (tab: RechargeTab) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
onRetry: () => void;
|
||||||
|
onBuy: (product: ProfileRechargeProduct) => void;
|
||||||
|
onConfirmNativePayment: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成微信 Native 支付二维码图片,保持首页现有二维码尺寸与容错行为。
|
||||||
|
*/
|
||||||
|
function useWechatNativeQrCode(codeUrl: string | null) {
|
||||||
|
const [qrImageUrl, setQrImageUrl] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
setQrImageUrl(null);
|
||||||
|
if (!codeUrl) {
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
void QRCode.toDataURL(codeUrl, {
|
||||||
|
errorCorrectionLevel: 'M',
|
||||||
|
margin: 1,
|
||||||
|
width: WECHAT_NATIVE_PAY_QR_IMAGE_SIZE,
|
||||||
|
}).then((dataUrl) => {
|
||||||
|
if (!cancelled) {
|
||||||
|
setQrImageUrl(dataUrl);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [codeUrl]);
|
||||||
|
|
||||||
|
return qrImageUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 充值套餐卡片,沿用 RPG 首页当前视觉和交互语义。
|
||||||
|
*/
|
||||||
|
function RechargeProductCard({
|
||||||
|
product,
|
||||||
|
submittingProductId,
|
||||||
|
onBuy,
|
||||||
|
}: {
|
||||||
|
product: ProfileRechargeProduct;
|
||||||
|
submittingProductId: string | null;
|
||||||
|
onBuy: (product: ProfileRechargeProduct) => void;
|
||||||
|
}) {
|
||||||
|
const submitting = submittingProductId === product.productId;
|
||||||
|
const badgeLabel = product.badgeLabel;
|
||||||
|
const value = buildRechargeProductValueLabel(product);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PlatformSubpanel
|
||||||
|
as="button"
|
||||||
|
type="button"
|
||||||
|
surface="platform"
|
||||||
|
onClick={() => onBuy(product)}
|
||||||
|
disabled={Boolean(submittingProductId)}
|
||||||
|
interactive
|
||||||
|
radius="sm"
|
||||||
|
padding="none"
|
||||||
|
className="platform-interactive-card relative min-h-[7.25rem] px-3.5 py-3.5 text-left"
|
||||||
|
>
|
||||||
|
{badgeLabel ? (
|
||||||
|
<PlatformPillBadge
|
||||||
|
tone="warning"
|
||||||
|
size="xxs"
|
||||||
|
className="absolute right-3 top-3 max-w-[7rem] truncate px-2 py-0.5 tracking-[0.18em]"
|
||||||
|
>
|
||||||
|
{badgeLabel}
|
||||||
|
</PlatformPillBadge>
|
||||||
|
) : null}
|
||||||
|
<div className="pr-20 text-sm font-black text-[var(--platform-text-strong)]">
|
||||||
|
{product.title}
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 text-2xl font-black text-[var(--platform-text-strong)]">
|
||||||
|
{value}
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 flex items-center justify-between gap-3">
|
||||||
|
<span className="text-sm font-bold text-[var(--platform-text-soft)]">
|
||||||
|
{formatRechargePrice(product.priceCents)}
|
||||||
|
</span>
|
||||||
|
<span className="platform-primary-button rounded-full px-3 py-1.5 text-xs font-black">
|
||||||
|
{submitting ? '处理中' : '购买'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</PlatformSubpanel>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 个人中心充值弹窗,共享给不同入口复用,但保持现有 props 与文案不变。
|
||||||
|
*/
|
||||||
|
export function PlatformProfileRechargeModal({
|
||||||
|
center,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
submittingProductId,
|
||||||
|
nativePayment,
|
||||||
|
activeTab,
|
||||||
|
onTabChange,
|
||||||
|
onClose,
|
||||||
|
onRetry,
|
||||||
|
onBuy,
|
||||||
|
onConfirmNativePayment,
|
||||||
|
}: PlatformProfileRechargeModalProps) {
|
||||||
|
const nativeQrImageUrl = useWechatNativeQrCode(
|
||||||
|
nativePayment?.codeUrl ?? null,
|
||||||
|
);
|
||||||
|
const products =
|
||||||
|
activeTab === 'points'
|
||||||
|
? (center?.pointProducts ?? [])
|
||||||
|
: (center?.membershipProducts ?? []);
|
||||||
|
const memberLabel = buildMembershipLabel(
|
||||||
|
center?.membership,
|
||||||
|
formatSnapshotTime,
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PlatformProfileModalShell
|
||||||
|
title="账户充值"
|
||||||
|
description={
|
||||||
|
center ? `${center.walletBalance}泥点 · ${memberLabel}` : '读取中'
|
||||||
|
}
|
||||||
|
onClose={onClose}
|
||||||
|
closeLabel="关闭账户充值"
|
||||||
|
size="md"
|
||||||
|
panelClassName="platform-recharge-modal !max-w-[34rem] rounded-[1.4rem]"
|
||||||
|
bodyClassName="max-h-[min(76vh,36rem)] overflow-y-auto px-5 py-5"
|
||||||
|
>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onTabChange('points')}
|
||||||
|
className={`platform-category-chip justify-center ${activeTab === 'points' ? 'platform-category-chip--active' : ''}`}
|
||||||
|
>
|
||||||
|
泥点充值
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onTabChange('membership')}
|
||||||
|
className={`platform-category-chip justify-center ${activeTab === 'membership' ? 'platform-category-chip--active' : ''}`}
|
||||||
|
>
|
||||||
|
会员卡
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error ? (
|
||||||
|
<PlatformStatusMessage
|
||||||
|
tone="error"
|
||||||
|
surface="profile"
|
||||||
|
size="xs"
|
||||||
|
className="mt-4 rounded-2xl font-semibold"
|
||||||
|
>
|
||||||
|
<div>{error}</div>
|
||||||
|
<PlatformActionButton
|
||||||
|
surface="profile"
|
||||||
|
size="xs"
|
||||||
|
className="mt-3"
|
||||||
|
onClick={onRetry}
|
||||||
|
>
|
||||||
|
重新加载
|
||||||
|
</PlatformActionButton>
|
||||||
|
</PlatformStatusMessage>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="mt-4 grid gap-3 sm:grid-cols-2">
|
||||||
|
{Array.from({ length: 4 }).map((_, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className="h-28 animate-pulse rounded-[1.15rem] bg-white/10"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : products.length > 0 ? (
|
||||||
|
<div className="mt-4 grid gap-3 sm:grid-cols-2">
|
||||||
|
{products.map((product) => (
|
||||||
|
<RechargeProductCard
|
||||||
|
key={product.productId}
|
||||||
|
product={product}
|
||||||
|
submittingProductId={submittingProductId}
|
||||||
|
onBuy={onBuy}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<PlatformEmptyState
|
||||||
|
surface="subpanel"
|
||||||
|
size="inline"
|
||||||
|
className="mt-4"
|
||||||
|
>
|
||||||
|
暂无可购买套餐
|
||||||
|
</PlatformEmptyState>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{nativePayment ? (
|
||||||
|
<PlatformSubpanel
|
||||||
|
as="div"
|
||||||
|
radius="sm"
|
||||||
|
padding="md"
|
||||||
|
className="mt-4 text-center"
|
||||||
|
>
|
||||||
|
<div className="text-sm font-black">微信扫码支付</div>
|
||||||
|
<div className="mx-auto mt-3 flex h-[180px] w-[180px] items-center justify-center rounded-xl bg-white p-2">
|
||||||
|
{nativeQrImageUrl ? (
|
||||||
|
<img
|
||||||
|
src={nativeQrImageUrl}
|
||||||
|
alt="微信 Native 支付二维码"
|
||||||
|
className="h-full w-full"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs font-semibold text-slate-500">
|
||||||
|
生成中
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<PlatformActionButton
|
||||||
|
surface="profile"
|
||||||
|
size="xs"
|
||||||
|
className="mt-4 disabled:cursor-wait"
|
||||||
|
onClick={onConfirmNativePayment}
|
||||||
|
disabled={nativePayment.isConfirming}
|
||||||
|
>
|
||||||
|
{nativePayment.isConfirming ? '确认中' : '我已支付'}
|
||||||
|
</PlatformActionButton>
|
||||||
|
</PlatformSubpanel>
|
||||||
|
) : null}
|
||||||
|
</PlatformProfileModalShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
/* @vitest-environment jsdom */
|
||||||
|
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { describe, expect, test, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { PlatformProfileRewardCodeRedeemModal } from './PlatformProfileRewardCodeRedeemModal';
|
||||||
|
|
||||||
|
describe('PlatformProfileRewardCodeRedeemModal', () => {
|
||||||
|
test('submits on button click and enter key', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const onSubmit = vi.fn();
|
||||||
|
const onChange = vi.fn();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<PlatformProfileRewardCodeRedeemModal
|
||||||
|
value="ab12"
|
||||||
|
isSubmitting={false}
|
||||||
|
error={null}
|
||||||
|
success={null}
|
||||||
|
onChange={onChange}
|
||||||
|
onSubmit={onSubmit}
|
||||||
|
onClose={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const input = screen.getByRole('textbox', { name: '兑换码' });
|
||||||
|
|
||||||
|
await user.type(input, 'c');
|
||||||
|
await user.keyboard('{Enter}');
|
||||||
|
await user.click(screen.getByRole('button', { name: '兑换' }));
|
||||||
|
|
||||||
|
expect(onChange).toHaveBeenCalled();
|
||||||
|
expect(onSubmit).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('disables submit when the code is blank', () => {
|
||||||
|
render(
|
||||||
|
<PlatformProfileRewardCodeRedeemModal
|
||||||
|
value=" "
|
||||||
|
isSubmitting={false}
|
||||||
|
error={null}
|
||||||
|
success={null}
|
||||||
|
onChange={vi.fn()}
|
||||||
|
onSubmit={vi.fn()}
|
||||||
|
onClose={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.getByRole('button', { name: '兑换' }).hasAttribute('disabled'),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import { PlatformActionButton } from '../common/PlatformActionButton';
|
||||||
|
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
|
||||||
|
import { PlatformTextField } from '../common/PlatformTextField';
|
||||||
|
import { PlatformProfileModalShell } from './PlatformProfileModalShell';
|
||||||
|
|
||||||
|
export type PlatformProfileRewardCodeRedeemModalProps = {
|
||||||
|
value: string;
|
||||||
|
isSubmitting: boolean;
|
||||||
|
error: string | null;
|
||||||
|
success: string | null;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
onSubmit: () => void;
|
||||||
|
onClose: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 个人中心兑换码弹窗。
|
||||||
|
* 保持原有输入、回车提交、禁用态和反馈消息语义不变。
|
||||||
|
*/
|
||||||
|
export function PlatformProfileRewardCodeRedeemModal({
|
||||||
|
value,
|
||||||
|
isSubmitting,
|
||||||
|
error,
|
||||||
|
success,
|
||||||
|
onChange,
|
||||||
|
onSubmit,
|
||||||
|
onClose,
|
||||||
|
}: PlatformProfileRewardCodeRedeemModalProps) {
|
||||||
|
return (
|
||||||
|
<PlatformProfileModalShell
|
||||||
|
title="兑换码"
|
||||||
|
onClose={onClose}
|
||||||
|
closeLabel="关闭兑换码"
|
||||||
|
panelClassName="platform-recharge-modal !max-w-sm rounded-[1.4rem]"
|
||||||
|
bodyClassName="space-y-3 px-5 py-5"
|
||||||
|
>
|
||||||
|
<PlatformTextField
|
||||||
|
value={value}
|
||||||
|
onChange={(event) => onChange(event.target.value)}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === 'Enter') {
|
||||||
|
onSubmit();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
size="sm"
|
||||||
|
density="roomy"
|
||||||
|
className="uppercase tracking-normal"
|
||||||
|
placeholder="输入兑换码"
|
||||||
|
aria-label="兑换码"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<PlatformActionButton
|
||||||
|
surface="profile"
|
||||||
|
fullWidth
|
||||||
|
size="md"
|
||||||
|
className="disabled:opacity-50"
|
||||||
|
onClick={onSubmit}
|
||||||
|
disabled={isSubmitting || !value.trim()}
|
||||||
|
>
|
||||||
|
{isSubmitting ? '兑换中' : '兑换'}
|
||||||
|
</PlatformActionButton>
|
||||||
|
{error ? (
|
||||||
|
<PlatformStatusMessage
|
||||||
|
tone="error"
|
||||||
|
surface="profile"
|
||||||
|
size="xs"
|
||||||
|
className="rounded-2xl font-semibold"
|
||||||
|
>
|
||||||
|
{error}
|
||||||
|
</PlatformStatusMessage>
|
||||||
|
) : null}
|
||||||
|
{success ? (
|
||||||
|
<PlatformStatusMessage
|
||||||
|
tone="success"
|
||||||
|
surface="profile"
|
||||||
|
size="xs"
|
||||||
|
className="rounded-2xl font-semibold"
|
||||||
|
>
|
||||||
|
{success}
|
||||||
|
</PlatformStatusMessage>
|
||||||
|
) : null}
|
||||||
|
</PlatformProfileModalShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
/* @vitest-environment jsdom */
|
||||||
|
|
||||||
|
import { render, screen, within } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { describe, expect, test, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { PlatformProfileTaskCenterModal } from './PlatformProfileTaskCenterModal';
|
||||||
|
|
||||||
|
describe('PlatformProfileTaskCenterModal', () => {
|
||||||
|
test('renders claimable tasks and forwards claim action', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const onClaim = vi.fn();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<PlatformProfileTaskCenterModal
|
||||||
|
center={{
|
||||||
|
dayKey: 20260610,
|
||||||
|
walletBalance: 66,
|
||||||
|
updatedAt: '2026-06-10T08:00:00.000Z',
|
||||||
|
tasks: [
|
||||||
|
{
|
||||||
|
taskId: 'task-1',
|
||||||
|
title: '每日登录',
|
||||||
|
description: '登录一次',
|
||||||
|
eventKey: 'daily_login',
|
||||||
|
cycle: 'daily',
|
||||||
|
rewardPoints: 10,
|
||||||
|
status: 'claimable',
|
||||||
|
progressCount: 1,
|
||||||
|
threshold: 1,
|
||||||
|
dayKey: 20260610,
|
||||||
|
claimedAt: null,
|
||||||
|
updatedAt: '2026-06-10T08:00:00.000Z',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}}
|
||||||
|
isLoading={false}
|
||||||
|
error={null}
|
||||||
|
success={null}
|
||||||
|
claimingTaskId={null}
|
||||||
|
fallbackBalance={12}
|
||||||
|
onClose={vi.fn()}
|
||||||
|
onRetry={vi.fn()}
|
||||||
|
onClaim={onClaim}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const dialog = screen.getByRole('dialog', { name: '每日任务' });
|
||||||
|
|
||||||
|
expect(within(dialog).getByText('66泥点')).toBeTruthy();
|
||||||
|
expect(within(dialog).getByText('每日登录')).toBeTruthy();
|
||||||
|
expect(within(dialog).getByText('1/1')).toBeTruthy();
|
||||||
|
expect(within(dialog).getByText('可领取')).toBeTruthy();
|
||||||
|
|
||||||
|
await user.click(within(dialog).getByRole('button', { name: '领取' }));
|
||||||
|
expect(onClaim).toHaveBeenCalledWith('task-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('keeps incomplete tasks disabled', () => {
|
||||||
|
render(
|
||||||
|
<PlatformProfileTaskCenterModal
|
||||||
|
center={{
|
||||||
|
dayKey: 20260610,
|
||||||
|
walletBalance: 20,
|
||||||
|
updatedAt: '2026-06-10T08:00:00.000Z',
|
||||||
|
tasks: [
|
||||||
|
{
|
||||||
|
taskId: 'task-2',
|
||||||
|
title: '分享一次',
|
||||||
|
description: '完成一次分享',
|
||||||
|
eventKey: 'daily_share',
|
||||||
|
cycle: 'daily',
|
||||||
|
rewardPoints: 8,
|
||||||
|
status: 'incomplete',
|
||||||
|
progressCount: 0,
|
||||||
|
threshold: 1,
|
||||||
|
dayKey: 20260610,
|
||||||
|
claimedAt: null,
|
||||||
|
updatedAt: '2026-06-10T08:00:00.000Z',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}}
|
||||||
|
isLoading={false}
|
||||||
|
error={null}
|
||||||
|
success={null}
|
||||||
|
claimingTaskId={null}
|
||||||
|
fallbackBalance={12}
|
||||||
|
onClose={vi.fn()}
|
||||||
|
onRetry={vi.fn()}
|
||||||
|
onClaim={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.getByRole('button', { name: '未完成' }).hasAttribute('disabled'),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
143
src/components/platform-entry/PlatformProfileTaskCenterModal.tsx
Normal file
143
src/components/platform-entry/PlatformProfileTaskCenterModal.tsx
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
import type { ProfileTaskCenterResponse } from '../../../packages/shared/src/contracts/runtime';
|
||||||
|
import { PlatformActionButton } from '../common/PlatformActionButton';
|
||||||
|
import { PlatformEmptyState } from '../common/PlatformEmptyState';
|
||||||
|
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
|
||||||
|
import { PlatformSubpanel } from '../common/PlatformSubpanel';
|
||||||
|
import { PlatformProfileModalShell } from './PlatformProfileModalShell';
|
||||||
|
import {
|
||||||
|
buildProfileTaskProgressLabel,
|
||||||
|
getProfileTaskClaimButtonLabel,
|
||||||
|
getProfileTaskStatusLabel,
|
||||||
|
selectProfileTaskCenterTasks,
|
||||||
|
} from '../rpg-entry/rpgEntryProfileTaskViewModel';
|
||||||
|
|
||||||
|
export type PlatformProfileTaskCenterModalProps = {
|
||||||
|
center: ProfileTaskCenterResponse | null;
|
||||||
|
isLoading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
success: string | null;
|
||||||
|
claimingTaskId: string | null;
|
||||||
|
fallbackBalance: number;
|
||||||
|
onClose: () => void;
|
||||||
|
onRetry: () => void;
|
||||||
|
onClaim: (taskId: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 个人中心每日任务弹窗。
|
||||||
|
* 复用任务中心 view model,保持原有任务筛选、状态文案和领取交互不变。
|
||||||
|
*/
|
||||||
|
export function PlatformProfileTaskCenterModal({
|
||||||
|
center,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
success,
|
||||||
|
claimingTaskId,
|
||||||
|
fallbackBalance,
|
||||||
|
onClose,
|
||||||
|
onRetry,
|
||||||
|
onClaim,
|
||||||
|
}: PlatformProfileTaskCenterModalProps) {
|
||||||
|
const tasks = selectProfileTaskCenterTasks(center?.tasks ?? []);
|
||||||
|
const walletBalance = center?.walletBalance ?? fallbackBalance;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PlatformProfileModalShell
|
||||||
|
title="每日任务"
|
||||||
|
description={`${walletBalance}泥点`}
|
||||||
|
onClose={onClose}
|
||||||
|
closeLabel="关闭每日任务"
|
||||||
|
panelClassName="platform-recharge-modal !max-w-md rounded-[1.4rem]"
|
||||||
|
bodyClassName="space-y-3 px-5 py-5"
|
||||||
|
>
|
||||||
|
{error ? (
|
||||||
|
<PlatformStatusMessage
|
||||||
|
tone="error"
|
||||||
|
surface="profile"
|
||||||
|
size="xs"
|
||||||
|
className="rounded-2xl font-semibold"
|
||||||
|
>
|
||||||
|
<div>{error}</div>
|
||||||
|
<PlatformActionButton
|
||||||
|
surface="profile"
|
||||||
|
size="xs"
|
||||||
|
className="mt-3"
|
||||||
|
onClick={onRetry}
|
||||||
|
>
|
||||||
|
重新加载
|
||||||
|
</PlatformActionButton>
|
||||||
|
</PlatformStatusMessage>
|
||||||
|
) : null}
|
||||||
|
{success ? (
|
||||||
|
<PlatformStatusMessage
|
||||||
|
tone="success"
|
||||||
|
surface="profile"
|
||||||
|
size="xs"
|
||||||
|
className="rounded-2xl font-semibold"
|
||||||
|
>
|
||||||
|
{success}
|
||||||
|
</PlatformStatusMessage>
|
||||||
|
) : null}
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{Array.from({ length: 2 }).map((_, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className="h-20 animate-pulse rounded-2xl bg-white/10"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : tasks.length === 0 ? (
|
||||||
|
<PlatformEmptyState surface="subpanel" size="inline">
|
||||||
|
暂无任务
|
||||||
|
</PlatformEmptyState>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{tasks.map((task) => {
|
||||||
|
const isClaimable = task.status === 'claimable';
|
||||||
|
const isClaiming = claimingTaskId === task.taskId;
|
||||||
|
const progressLabel = buildProfileTaskProgressLabel(task);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PlatformSubpanel
|
||||||
|
as="div"
|
||||||
|
key={task.taskId}
|
||||||
|
radius="sm"
|
||||||
|
padding="md"
|
||||||
|
>
|
||||||
|
<div className="flex min-w-0 items-start justify-between gap-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="text-base font-black text-[var(--platform-text-strong)]">
|
||||||
|
{task.title}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-xs font-semibold text-[var(--platform-text-soft)]">
|
||||||
|
{progressLabel}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="shrink-0 text-right">
|
||||||
|
<div className="text-sm font-black text-[var(--platform-text-strong)]">
|
||||||
|
+{task.rewardPoints}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-[11px] font-semibold text-[var(--platform-text-soft)]">
|
||||||
|
{getProfileTaskStatusLabel(task.status)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<PlatformActionButton
|
||||||
|
surface="profile"
|
||||||
|
fullWidth
|
||||||
|
size="sm"
|
||||||
|
className="mt-3 disabled:opacity-50"
|
||||||
|
disabled={!isClaimable || Boolean(claimingTaskId)}
|
||||||
|
onClick={() => onClaim(task.taskId)}
|
||||||
|
>
|
||||||
|
{getProfileTaskClaimButtonLabel(task, isClaiming)}
|
||||||
|
</PlatformActionButton>
|
||||||
|
</PlatformSubpanel>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</PlatformProfileModalShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
/* @vitest-environment jsdom */
|
||||||
|
|
||||||
|
import { render, screen, within } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { describe, expect, test, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { PlatformProfileWalletLedgerModal } from './PlatformProfileWalletLedgerModal';
|
||||||
|
|
||||||
|
describe('PlatformProfileWalletLedgerModal', () => {
|
||||||
|
test('renders ledger entries with shared balance presentation', () => {
|
||||||
|
render(
|
||||||
|
<PlatformProfileWalletLedgerModal
|
||||||
|
ledger={{
|
||||||
|
entries: [
|
||||||
|
{
|
||||||
|
id: 'ledger-1',
|
||||||
|
sourceType: 'daily_task_reward',
|
||||||
|
amountDelta: 12,
|
||||||
|
balanceAfter: 88,
|
||||||
|
createdAt: '2026-06-10T08:00:00.000Z',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}}
|
||||||
|
fallbackBalance={40}
|
||||||
|
isLoading={false}
|
||||||
|
error={null}
|
||||||
|
onClose={vi.fn()}
|
||||||
|
onRetry={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const dialog = screen.getByRole('dialog', { name: '泥点账单' });
|
||||||
|
|
||||||
|
expect(within(dialog).getByText('88泥点')).toBeTruthy();
|
||||||
|
expect(within(dialog).getByText('每日任务奖励')).toBeTruthy();
|
||||||
|
expect(within(dialog).getByText('+12')).toBeTruthy();
|
||||||
|
expect(within(dialog).getByText('余额 88')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('retries from the shared error state', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const onRetry = vi.fn();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<PlatformProfileWalletLedgerModal
|
||||||
|
ledger={null}
|
||||||
|
fallbackBalance={40}
|
||||||
|
isLoading={false}
|
||||||
|
error="账单加载失败"
|
||||||
|
onClose={vi.fn()}
|
||||||
|
onRetry={onRetry}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', { name: '重新加载' }));
|
||||||
|
expect(onRetry).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
import { Coins } from 'lucide-react';
|
||||||
|
|
||||||
|
import type { ProfileWalletLedgerResponse } from '../../../packages/shared/src/contracts/runtime';
|
||||||
|
import { PlatformActionButton } from '../common/PlatformActionButton';
|
||||||
|
import { PlatformEmptyState } from '../common/PlatformEmptyState';
|
||||||
|
import { PlatformPillBadge } from '../common/PlatformPillBadge';
|
||||||
|
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
|
||||||
|
import { PlatformSubpanel } from '../common/PlatformSubpanel';
|
||||||
|
import { PlatformProfileSecondaryModalShell } from './PlatformProfileModalShell';
|
||||||
|
import { buildWalletLedgerPresentation } from '../rpg-entry/rpgEntryProfileFundsViewModel';
|
||||||
|
import { formatPlatformWorldTime } from '../rpg-entry/rpgEntryWorldPresentation';
|
||||||
|
|
||||||
|
export type PlatformProfileWalletLedgerModalProps = {
|
||||||
|
ledger: ProfileWalletLedgerResponse | null;
|
||||||
|
fallbackBalance: number;
|
||||||
|
isLoading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
onClose: () => void;
|
||||||
|
onRetry: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 个人中心泥点账单弹窗。
|
||||||
|
* 保持 RPG 首页里既有的展示文案、状态分支和交互,仅把实现提取为共享组件。
|
||||||
|
*/
|
||||||
|
export function PlatformProfileWalletLedgerModal({
|
||||||
|
ledger,
|
||||||
|
fallbackBalance,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
onClose,
|
||||||
|
onRetry,
|
||||||
|
}: PlatformProfileWalletLedgerModalProps) {
|
||||||
|
const walletLedgerPresentation = buildWalletLedgerPresentation(
|
||||||
|
ledger,
|
||||||
|
fallbackBalance,
|
||||||
|
);
|
||||||
|
const entries = walletLedgerPresentation.entries;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PlatformProfileSecondaryModalShell
|
||||||
|
title="泥点账单"
|
||||||
|
onClose={onClose}
|
||||||
|
closeLabel="关闭泥点账单"
|
||||||
|
closeButtonClassName="bg-white/78"
|
||||||
|
panelClassName="relative !max-h-[min(92vh,42rem)] !max-w-[30rem] bg-[linear-gradient(180deg,#fff7f8_0%,#ffffff_38%,#f8fafc_100%)] text-zinc-950 shadow-2xl !rounded-[1.35rem] sm:!rounded-[1.35rem]"
|
||||||
|
contentClassName="relative max-h-[min(92vh,42rem)] overflow-y-auto px-4 pb-5 pt-4 sm:px-5"
|
||||||
|
>
|
||||||
|
<div className="pr-10">
|
||||||
|
<div className="text-[10px] font-black tracking-[0.22em] text-[#ff4056]">
|
||||||
|
LEDGER
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-2xl font-black">泥点账单</div>
|
||||||
|
<PlatformPillBadge
|
||||||
|
tone="profile"
|
||||||
|
icon={<Coins className="h-3.5 w-3.5 text-[#ff4056]" />}
|
||||||
|
className="mt-3 bg-white/70"
|
||||||
|
>
|
||||||
|
{walletLedgerPresentation.balanceLabel}
|
||||||
|
</PlatformPillBadge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error ? (
|
||||||
|
<PlatformStatusMessage tone="error" className="mt-4 rounded-xl py-3">
|
||||||
|
<div>{error}</div>
|
||||||
|
<PlatformActionButton
|
||||||
|
surface="profile"
|
||||||
|
shape="pill"
|
||||||
|
size="xs"
|
||||||
|
className="mt-3"
|
||||||
|
onClick={onRetry}
|
||||||
|
>
|
||||||
|
重新加载
|
||||||
|
</PlatformActionButton>
|
||||||
|
</PlatformStatusMessage>
|
||||||
|
) : isLoading ? (
|
||||||
|
<div className="mt-5 space-y-3">
|
||||||
|
{Array.from({ length: 5 }).map((_, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className="h-16 animate-pulse rounded-xl bg-zinc-100"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : entries.length === 0 ? (
|
||||||
|
<PlatformEmptyState
|
||||||
|
surface="subpanel"
|
||||||
|
size="inline"
|
||||||
|
className="mt-5 py-8"
|
||||||
|
>
|
||||||
|
暂无账单记录
|
||||||
|
</PlatformEmptyState>
|
||||||
|
) : (
|
||||||
|
<div className="mt-5 space-y-2.5">
|
||||||
|
{entries.map((entry) => (
|
||||||
|
<PlatformSubpanel
|
||||||
|
as="div"
|
||||||
|
key={entry.id}
|
||||||
|
surface="flat"
|
||||||
|
radius="xs"
|
||||||
|
padding="none"
|
||||||
|
className="flex items-center justify-between gap-3 px-3 py-3 shadow-sm"
|
||||||
|
>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="truncate text-sm font-black text-zinc-900">
|
||||||
|
{entry.sourceLabel}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-xs font-semibold text-zinc-500">
|
||||||
|
{formatPlatformWorldTime(entry.createdAt)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="shrink-0 text-right">
|
||||||
|
<div
|
||||||
|
className={`text-base font-black ${
|
||||||
|
entry.isIncome ? 'text-emerald-600' : 'text-rose-500'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{entry.amountLabel}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-[11px] font-semibold text-zinc-400">
|
||||||
|
{entry.balanceLabel}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</PlatformSubpanel>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</PlatformProfileSecondaryModalShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -30,7 +30,6 @@ import {
|
|||||||
UserRound,
|
UserRound,
|
||||||
XCircle,
|
XCircle,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import QRCode from 'qrcode';
|
|
||||||
import {
|
import {
|
||||||
type ComponentType,
|
type ComponentType,
|
||||||
type CSSProperties,
|
type CSSProperties,
|
||||||
@@ -62,11 +61,7 @@ import type {
|
|||||||
ProfileDashboardSummary,
|
ProfileDashboardSummary,
|
||||||
ProfilePlayedWorkSummary,
|
ProfilePlayedWorkSummary,
|
||||||
ProfilePlayStatsResponse,
|
ProfilePlayStatsResponse,
|
||||||
ProfileRechargeCenterResponse,
|
|
||||||
ProfileRechargeProduct,
|
|
||||||
ProfileSaveArchiveSummary,
|
ProfileSaveArchiveSummary,
|
||||||
ProfileTaskCenterResponse,
|
|
||||||
ProfileWalletLedgerResponse,
|
|
||||||
} from '../../../packages/shared/src/contracts/runtime';
|
} from '../../../packages/shared/src/contracts/runtime';
|
||||||
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
|
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
|
||||||
import {
|
import {
|
||||||
@@ -125,17 +120,16 @@ import {
|
|||||||
ProfileStatCard,
|
ProfileStatCard,
|
||||||
ProfileStatCardSkeleton,
|
ProfileStatCardSkeleton,
|
||||||
} from '../platform-entry/PlatformProfilePrimitives';
|
} from '../platform-entry/PlatformProfilePrimitives';
|
||||||
import {
|
import { PlatformProfileModalShell } from '../platform-entry/PlatformProfileModalShell';
|
||||||
PlatformProfileModalShell,
|
|
||||||
PlatformProfileSecondaryModalShell,
|
|
||||||
} from '../platform-entry/PlatformProfileModalShell';
|
|
||||||
import { PlatformProfilePlayedWorksModal } from '../platform-entry/PlatformProfilePlayedWorksModal';
|
import { PlatformProfilePlayedWorksModal } from '../platform-entry/PlatformProfilePlayedWorksModal';
|
||||||
|
import { PlatformProfileRechargeModal } from '../platform-entry/PlatformProfileRechargeModal';
|
||||||
import { PlatformProfileReferralModal } from '../platform-entry/PlatformProfileReferralModal';
|
import { PlatformProfileReferralModal } from '../platform-entry/PlatformProfileReferralModal';
|
||||||
|
import { PlatformProfileRewardCodeRedeemModal } from '../platform-entry/PlatformProfileRewardCodeRedeemModal';
|
||||||
|
import { PlatformProfileTaskCenterModal } from '../platform-entry/PlatformProfileTaskCenterModal';
|
||||||
|
import { PlatformProfileWalletLedgerModal } from '../platform-entry/PlatformProfileWalletLedgerModal';
|
||||||
import { getInitialPlatformDesktopLayout } from '../platform-entry/platformEntryResponsive';
|
import { getInitialPlatformDesktopLayout } from '../platform-entry/platformEntryResponsive';
|
||||||
import {
|
import {
|
||||||
type NativeWechatPaymentState,
|
|
||||||
type RechargePaymentResult,
|
type RechargePaymentResult,
|
||||||
type RechargeTab,
|
|
||||||
usePlatformProfileCenterController,
|
usePlatformProfileCenterController,
|
||||||
} from '../platform-entry/usePlatformProfileCenterController';
|
} from '../platform-entry/usePlatformProfileCenterController';
|
||||||
import { ResolvedAssetImage } from '../ResolvedAssetImage';
|
import { ResolvedAssetImage } from '../ResolvedAssetImage';
|
||||||
@@ -144,19 +138,7 @@ import {
|
|||||||
buildProfileDashboardPresentation,
|
buildProfileDashboardPresentation,
|
||||||
formatSnapshotTime,
|
formatSnapshotTime,
|
||||||
} from './rpgEntryProfileDashboardPresentation';
|
} from './rpgEntryProfileDashboardPresentation';
|
||||||
import {
|
import { buildProfileTaskCardSummary } from './rpgEntryProfileTaskViewModel';
|
||||||
buildMembershipLabel,
|
|
||||||
buildRechargeProductValueLabel,
|
|
||||||
buildWalletLedgerPresentation,
|
|
||||||
formatRechargePrice,
|
|
||||||
} from './rpgEntryProfileFundsViewModel';
|
|
||||||
import {
|
|
||||||
buildProfileTaskCardSummary,
|
|
||||||
buildProfileTaskProgressLabel,
|
|
||||||
getProfileTaskClaimButtonLabel,
|
|
||||||
getProfileTaskStatusLabel,
|
|
||||||
selectProfileTaskCenterTasks,
|
|
||||||
} from './rpgEntryProfileTaskViewModel';
|
|
||||||
import {
|
import {
|
||||||
buildPlatformRankingEntries,
|
buildPlatformRankingEntries,
|
||||||
buildPlatformRecommendFeedEntries,
|
buildPlatformRecommendFeedEntries,
|
||||||
@@ -884,7 +866,6 @@ function readyRecommendRuntime(
|
|||||||
scanResources();
|
scanResources();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
const WECHAT_NATIVE_PAY_QR_IMAGE_SIZE = 180;
|
|
||||||
const PROFILE_QR_SCAN_INTERVAL_MS = 360;
|
const PROFILE_QR_SCAN_INTERVAL_MS = 360;
|
||||||
type BarcodeDetectorLike = {
|
type BarcodeDetectorLike = {
|
||||||
detect: (source: CanvasImageSource) => Promise<Array<{ rawValue?: string }>>;
|
detect: (source: CanvasImageSource) => Promise<Array<{ rawValue?: string }>>;
|
||||||
@@ -2496,239 +2477,6 @@ function ProfileNicknameModal({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function useWechatNativeQrCode(codeUrl: string | null) {
|
|
||||||
const [qrImageUrl, setQrImageUrl] = useState<string | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let cancelled = false;
|
|
||||||
setQrImageUrl(null);
|
|
||||||
if (!codeUrl) {
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
void QRCode.toDataURL(codeUrl, {
|
|
||||||
errorCorrectionLevel: 'M',
|
|
||||||
margin: 1,
|
|
||||||
width: WECHAT_NATIVE_PAY_QR_IMAGE_SIZE,
|
|
||||||
}).then((dataUrl) => {
|
|
||||||
if (!cancelled) {
|
|
||||||
setQrImageUrl(dataUrl);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
};
|
|
||||||
}, [codeUrl]);
|
|
||||||
|
|
||||||
return qrImageUrl;
|
|
||||||
}
|
|
||||||
|
|
||||||
function RechargeProductCard({
|
|
||||||
product,
|
|
||||||
submittingProductId,
|
|
||||||
onBuy,
|
|
||||||
}: {
|
|
||||||
product: ProfileRechargeProduct;
|
|
||||||
submittingProductId: string | null;
|
|
||||||
onBuy: (product: ProfileRechargeProduct) => void;
|
|
||||||
}) {
|
|
||||||
const submitting = submittingProductId === product.productId;
|
|
||||||
const badgeLabel = product.badgeLabel;
|
|
||||||
const value = buildRechargeProductValueLabel(product);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<PlatformSubpanel
|
|
||||||
as="button"
|
|
||||||
type="button"
|
|
||||||
surface="platform"
|
|
||||||
onClick={() => onBuy(product)}
|
|
||||||
disabled={Boolean(submittingProductId)}
|
|
||||||
interactive
|
|
||||||
radius="sm"
|
|
||||||
padding="none"
|
|
||||||
className="platform-interactive-card relative min-h-[7.25rem] px-3.5 py-3.5 text-left"
|
|
||||||
>
|
|
||||||
{badgeLabel ? (
|
|
||||||
<PlatformPillBadge
|
|
||||||
tone="warning"
|
|
||||||
size="xxs"
|
|
||||||
className="absolute right-3 top-3 max-w-[7rem] truncate px-2 py-0.5 tracking-[0.18em]"
|
|
||||||
>
|
|
||||||
{badgeLabel}
|
|
||||||
</PlatformPillBadge>
|
|
||||||
) : null}
|
|
||||||
<div className="pr-20 text-sm font-black text-[var(--platform-text-strong)]">
|
|
||||||
{product.title}
|
|
||||||
</div>
|
|
||||||
<div className="mt-3 text-2xl font-black text-[var(--platform-text-strong)]">
|
|
||||||
{value}
|
|
||||||
</div>
|
|
||||||
<div className="mt-2 flex items-center justify-between gap-3">
|
|
||||||
<span className="text-sm font-bold text-[var(--platform-text-soft)]">
|
|
||||||
{formatRechargePrice(product.priceCents)}
|
|
||||||
</span>
|
|
||||||
<span className="platform-primary-button rounded-full px-3 py-1.5 text-xs font-black">
|
|
||||||
{submitting ? '处理中' : '购买'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</PlatformSubpanel>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ProfileRechargeModal({
|
|
||||||
center,
|
|
||||||
isLoading,
|
|
||||||
error,
|
|
||||||
submittingProductId,
|
|
||||||
nativePayment,
|
|
||||||
activeTab,
|
|
||||||
onTabChange,
|
|
||||||
onClose,
|
|
||||||
onRetry,
|
|
||||||
onBuy,
|
|
||||||
onConfirmNativePayment,
|
|
||||||
}: {
|
|
||||||
center: ProfileRechargeCenterResponse | null;
|
|
||||||
isLoading: boolean;
|
|
||||||
error: string | null;
|
|
||||||
submittingProductId: string | null;
|
|
||||||
nativePayment: NativeWechatPaymentState | null;
|
|
||||||
activeTab: RechargeTab;
|
|
||||||
onTabChange: (tab: RechargeTab) => void;
|
|
||||||
onClose: () => void;
|
|
||||||
onRetry: () => void;
|
|
||||||
onBuy: (product: ProfileRechargeProduct) => void;
|
|
||||||
onConfirmNativePayment: () => void;
|
|
||||||
}) {
|
|
||||||
const nativeQrImageUrl = useWechatNativeQrCode(
|
|
||||||
nativePayment?.codeUrl ?? null,
|
|
||||||
);
|
|
||||||
const products =
|
|
||||||
activeTab === 'points'
|
|
||||||
? (center?.pointProducts ?? [])
|
|
||||||
: (center?.membershipProducts ?? []);
|
|
||||||
const memberLabel = buildMembershipLabel(
|
|
||||||
center?.membership,
|
|
||||||
formatSnapshotTime,
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<PlatformProfileModalShell
|
|
||||||
title="账户充值"
|
|
||||||
description={
|
|
||||||
center ? `${center.walletBalance}泥点 · ${memberLabel}` : '读取中'
|
|
||||||
}
|
|
||||||
onClose={onClose}
|
|
||||||
closeLabel="关闭账户充值"
|
|
||||||
size="md"
|
|
||||||
panelClassName="platform-recharge-modal !max-w-[34rem] rounded-[1.4rem]"
|
|
||||||
bodyClassName="max-h-[min(76vh,36rem)] overflow-y-auto px-5 py-5"
|
|
||||||
>
|
|
||||||
<div className="grid grid-cols-2 gap-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => onTabChange('points')}
|
|
||||||
className={`platform-category-chip justify-center ${activeTab === 'points' ? 'platform-category-chip--active' : ''}`}
|
|
||||||
>
|
|
||||||
泥点充值
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => onTabChange('membership')}
|
|
||||||
className={`platform-category-chip justify-center ${activeTab === 'membership' ? 'platform-category-chip--active' : ''}`}
|
|
||||||
>
|
|
||||||
会员卡
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{error ? (
|
|
||||||
<PlatformStatusMessage
|
|
||||||
tone="error"
|
|
||||||
surface="profile"
|
|
||||||
size="xs"
|
|
||||||
className="mt-4 rounded-2xl font-semibold"
|
|
||||||
>
|
|
||||||
<div>{error}</div>
|
|
||||||
<PlatformActionButton
|
|
||||||
surface="profile"
|
|
||||||
size="xs"
|
|
||||||
className="mt-3"
|
|
||||||
onClick={onRetry}
|
|
||||||
>
|
|
||||||
重新加载
|
|
||||||
</PlatformActionButton>
|
|
||||||
</PlatformStatusMessage>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{isLoading ? (
|
|
||||||
<div className="mt-4 grid gap-3 sm:grid-cols-2">
|
|
||||||
{Array.from({ length: 4 }).map((_, index) => (
|
|
||||||
<div
|
|
||||||
key={index}
|
|
||||||
className="h-28 animate-pulse rounded-[1.15rem] bg-white/10"
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : products.length > 0 ? (
|
|
||||||
<div className="mt-4 grid gap-3 sm:grid-cols-2">
|
|
||||||
{products.map((product) => (
|
|
||||||
<RechargeProductCard
|
|
||||||
key={product.productId}
|
|
||||||
product={product}
|
|
||||||
submittingProductId={submittingProductId}
|
|
||||||
onBuy={onBuy}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<PlatformEmptyState
|
|
||||||
surface="subpanel"
|
|
||||||
size="inline"
|
|
||||||
className="mt-4"
|
|
||||||
>
|
|
||||||
暂无可购买套餐
|
|
||||||
</PlatformEmptyState>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{nativePayment ? (
|
|
||||||
<PlatformSubpanel
|
|
||||||
as="div"
|
|
||||||
radius="sm"
|
|
||||||
padding="md"
|
|
||||||
className="mt-4 text-center"
|
|
||||||
>
|
|
||||||
<div className="text-sm font-black">微信扫码支付</div>
|
|
||||||
<div className="mx-auto mt-3 flex h-[180px] w-[180px] items-center justify-center rounded-xl bg-white p-2">
|
|
||||||
{nativeQrImageUrl ? (
|
|
||||||
<img
|
|
||||||
src={nativeQrImageUrl}
|
|
||||||
alt="微信 Native 支付二维码"
|
|
||||||
className="h-full w-full"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<span className="text-xs font-semibold text-slate-500">
|
|
||||||
生成中
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<PlatformActionButton
|
|
||||||
surface="profile"
|
|
||||||
size="xs"
|
|
||||||
className="mt-4 disabled:cursor-wait"
|
|
||||||
onClick={onConfirmNativePayment}
|
|
||||||
disabled={nativePayment.isConfirming}
|
|
||||||
>
|
|
||||||
{nativePayment.isConfirming ? '确认中' : '我已支付'}
|
|
||||||
</PlatformActionButton>
|
|
||||||
</PlatformSubpanel>
|
|
||||||
) : null}
|
|
||||||
</PlatformProfileModalShell>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function RechargePaymentResultModal({
|
function RechargePaymentResultModal({
|
||||||
result,
|
result,
|
||||||
onClose,
|
onClose,
|
||||||
@@ -2823,317 +2571,6 @@ function RechargePaymentConfirmationMask({ orderId }: { orderId: string }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function WalletLedgerModal({
|
|
||||||
ledger,
|
|
||||||
fallbackBalance,
|
|
||||||
isLoading,
|
|
||||||
error,
|
|
||||||
onClose,
|
|
||||||
onRetry,
|
|
||||||
}: {
|
|
||||||
ledger: ProfileWalletLedgerResponse | null;
|
|
||||||
fallbackBalance: number;
|
|
||||||
isLoading: boolean;
|
|
||||||
error: string | null;
|
|
||||||
onClose: () => void;
|
|
||||||
onRetry: () => void;
|
|
||||||
}) {
|
|
||||||
const walletLedgerPresentation = buildWalletLedgerPresentation(
|
|
||||||
ledger,
|
|
||||||
fallbackBalance,
|
|
||||||
);
|
|
||||||
const entries = walletLedgerPresentation.entries;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<PlatformProfileSecondaryModalShell
|
|
||||||
title="泥点账单"
|
|
||||||
onClose={onClose}
|
|
||||||
closeLabel="关闭泥点账单"
|
|
||||||
closeButtonClassName="bg-white/78"
|
|
||||||
panelClassName="relative !max-h-[min(92vh,42rem)] !max-w-[30rem] bg-[linear-gradient(180deg,#fff7f8_0%,#ffffff_38%,#f8fafc_100%)] text-zinc-950 shadow-2xl !rounded-[1.35rem] sm:!rounded-[1.35rem]"
|
|
||||||
contentClassName="relative max-h-[min(92vh,42rem)] overflow-y-auto px-4 pb-5 pt-4 sm:px-5"
|
|
||||||
>
|
|
||||||
<div className="pr-10">
|
|
||||||
<div className="text-[10px] font-black tracking-[0.22em] text-[#ff4056]">
|
|
||||||
LEDGER
|
|
||||||
</div>
|
|
||||||
<div className="mt-1 text-2xl font-black">泥点账单</div>
|
|
||||||
<PlatformPillBadge
|
|
||||||
tone="profile"
|
|
||||||
icon={<Coins className="h-3.5 w-3.5 text-[#ff4056]" />}
|
|
||||||
className="mt-3 bg-white/70"
|
|
||||||
>
|
|
||||||
{walletLedgerPresentation.balanceLabel}
|
|
||||||
</PlatformPillBadge>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{error ? (
|
|
||||||
<PlatformStatusMessage tone="error" className="mt-4 rounded-xl py-3">
|
|
||||||
<div>{error}</div>
|
|
||||||
<PlatformActionButton
|
|
||||||
surface="profile"
|
|
||||||
shape="pill"
|
|
||||||
size="xs"
|
|
||||||
className="mt-3"
|
|
||||||
onClick={onRetry}
|
|
||||||
>
|
|
||||||
重新加载
|
|
||||||
</PlatformActionButton>
|
|
||||||
</PlatformStatusMessage>
|
|
||||||
) : isLoading ? (
|
|
||||||
<div className="mt-5 space-y-3">
|
|
||||||
{Array.from({ length: 5 }).map((_, index) => (
|
|
||||||
<div key={index} className="h-16 animate-pulse rounded-xl bg-zinc-100" />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : entries.length === 0 ? (
|
|
||||||
<PlatformEmptyState
|
|
||||||
surface="subpanel"
|
|
||||||
size="inline"
|
|
||||||
className="mt-5 py-8"
|
|
||||||
>
|
|
||||||
暂无账单记录
|
|
||||||
</PlatformEmptyState>
|
|
||||||
) : (
|
|
||||||
<div className="mt-5 space-y-2.5">
|
|
||||||
{entries.map((entry) => {
|
|
||||||
return (
|
|
||||||
<PlatformSubpanel
|
|
||||||
as="div"
|
|
||||||
key={entry.id}
|
|
||||||
surface="flat"
|
|
||||||
radius="xs"
|
|
||||||
padding="none"
|
|
||||||
className="flex items-center justify-between gap-3 px-3 py-3 shadow-sm"
|
|
||||||
>
|
|
||||||
<div className="min-w-0">
|
|
||||||
<div className="truncate text-sm font-black text-zinc-900">
|
|
||||||
{entry.sourceLabel}
|
|
||||||
</div>
|
|
||||||
<div className="mt-1 text-xs font-semibold text-zinc-500">
|
|
||||||
{formatPlatformWorldTime(entry.createdAt)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="shrink-0 text-right">
|
|
||||||
<div
|
|
||||||
className={`text-base font-black ${
|
|
||||||
entry.isIncome ? 'text-emerald-600' : 'text-rose-500'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{entry.amountLabel}
|
|
||||||
</div>
|
|
||||||
<div className="mt-1 text-[11px] font-semibold text-zinc-400">
|
|
||||||
{entry.balanceLabel}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</PlatformSubpanel>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</PlatformProfileSecondaryModalShell>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ProfileTaskCenterModal({
|
|
||||||
center,
|
|
||||||
isLoading,
|
|
||||||
error,
|
|
||||||
success,
|
|
||||||
claimingTaskId,
|
|
||||||
fallbackBalance,
|
|
||||||
onClose,
|
|
||||||
onRetry,
|
|
||||||
onClaim,
|
|
||||||
}: {
|
|
||||||
center: ProfileTaskCenterResponse | null;
|
|
||||||
isLoading: boolean;
|
|
||||||
error: string | null;
|
|
||||||
success: string | null;
|
|
||||||
claimingTaskId: string | null;
|
|
||||||
fallbackBalance: number;
|
|
||||||
onClose: () => void;
|
|
||||||
onRetry: () => void;
|
|
||||||
onClaim: (taskId: string) => void;
|
|
||||||
}) {
|
|
||||||
const tasks = selectProfileTaskCenterTasks(center?.tasks ?? []);
|
|
||||||
const walletBalance = center?.walletBalance ?? fallbackBalance;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<PlatformProfileModalShell
|
|
||||||
title="每日任务"
|
|
||||||
description={`${walletBalance}泥点`}
|
|
||||||
onClose={onClose}
|
|
||||||
closeLabel="关闭每日任务"
|
|
||||||
panelClassName="platform-recharge-modal !max-w-md rounded-[1.4rem]"
|
|
||||||
bodyClassName="space-y-3 px-5 py-5"
|
|
||||||
>
|
|
||||||
{error ? (
|
|
||||||
<PlatformStatusMessage
|
|
||||||
tone="error"
|
|
||||||
surface="profile"
|
|
||||||
size="xs"
|
|
||||||
className="rounded-2xl font-semibold"
|
|
||||||
>
|
|
||||||
<div>{error}</div>
|
|
||||||
<PlatformActionButton
|
|
||||||
surface="profile"
|
|
||||||
size="xs"
|
|
||||||
className="mt-3"
|
|
||||||
onClick={onRetry}
|
|
||||||
>
|
|
||||||
重新加载
|
|
||||||
</PlatformActionButton>
|
|
||||||
</PlatformStatusMessage>
|
|
||||||
) : null}
|
|
||||||
{success ? (
|
|
||||||
<PlatformStatusMessage
|
|
||||||
tone="success"
|
|
||||||
surface="profile"
|
|
||||||
size="xs"
|
|
||||||
className="rounded-2xl font-semibold"
|
|
||||||
>
|
|
||||||
{success}
|
|
||||||
</PlatformStatusMessage>
|
|
||||||
) : null}
|
|
||||||
{isLoading ? (
|
|
||||||
<div className="space-y-3">
|
|
||||||
{Array.from({ length: 2 }).map((_, index) => (
|
|
||||||
<div
|
|
||||||
key={index}
|
|
||||||
className="h-20 animate-pulse rounded-2xl bg-white/10"
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : tasks.length === 0 ? (
|
|
||||||
<PlatformEmptyState surface="subpanel" size="inline">
|
|
||||||
暂无任务
|
|
||||||
</PlatformEmptyState>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-3">
|
|
||||||
{tasks.map((task) => {
|
|
||||||
const isClaimable = task.status === 'claimable';
|
|
||||||
const isClaiming = claimingTaskId === task.taskId;
|
|
||||||
const progressLabel = buildProfileTaskProgressLabel(task);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<PlatformSubpanel
|
|
||||||
as="div"
|
|
||||||
key={task.taskId}
|
|
||||||
radius="sm"
|
|
||||||
padding="md"
|
|
||||||
>
|
|
||||||
<div className="flex min-w-0 items-start justify-between gap-3">
|
|
||||||
<div className="min-w-0">
|
|
||||||
<div className="text-base font-black text-[var(--platform-text-strong)]">
|
|
||||||
{task.title}
|
|
||||||
</div>
|
|
||||||
<div className="mt-1 text-xs font-semibold text-[var(--platform-text-soft)]">
|
|
||||||
{progressLabel}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="shrink-0 text-right">
|
|
||||||
<div className="text-sm font-black text-[var(--platform-text-strong)]">
|
|
||||||
+{task.rewardPoints}
|
|
||||||
</div>
|
|
||||||
<div className="mt-1 text-[11px] font-semibold text-[var(--platform-text-soft)]">
|
|
||||||
{getProfileTaskStatusLabel(task.status)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<PlatformActionButton
|
|
||||||
surface="profile"
|
|
||||||
fullWidth
|
|
||||||
size="sm"
|
|
||||||
className="mt-3 disabled:opacity-50"
|
|
||||||
disabled={!isClaimable || Boolean(claimingTaskId)}
|
|
||||||
onClick={() => onClaim(task.taskId)}
|
|
||||||
>
|
|
||||||
{getProfileTaskClaimButtonLabel(task, isClaiming)}
|
|
||||||
</PlatformActionButton>
|
|
||||||
</PlatformSubpanel>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</PlatformProfileModalShell>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function RewardCodeRedeemModal({
|
|
||||||
value,
|
|
||||||
isSubmitting,
|
|
||||||
error,
|
|
||||||
success,
|
|
||||||
onChange,
|
|
||||||
onSubmit,
|
|
||||||
onClose,
|
|
||||||
}: {
|
|
||||||
value: string;
|
|
||||||
isSubmitting: boolean;
|
|
||||||
error: string | null;
|
|
||||||
success: string | null;
|
|
||||||
onChange: (value: string) => void;
|
|
||||||
onSubmit: () => void;
|
|
||||||
onClose: () => void;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<PlatformProfileModalShell
|
|
||||||
title="兑换码"
|
|
||||||
onClose={onClose}
|
|
||||||
closeLabel="关闭兑换码"
|
|
||||||
panelClassName="platform-recharge-modal !max-w-sm rounded-[1.4rem]"
|
|
||||||
bodyClassName="space-y-3 px-5 py-5"
|
|
||||||
>
|
|
||||||
<PlatformTextField
|
|
||||||
value={value}
|
|
||||||
onChange={(event) => onChange(event.target.value)}
|
|
||||||
onKeyDown={(event) => {
|
|
||||||
if (event.key === 'Enter') {
|
|
||||||
onSubmit();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
size="sm"
|
|
||||||
density="roomy"
|
|
||||||
className="uppercase tracking-normal"
|
|
||||||
placeholder="输入兑换码"
|
|
||||||
aria-label="兑换码"
|
|
||||||
autoFocus
|
|
||||||
/>
|
|
||||||
<PlatformActionButton
|
|
||||||
surface="profile"
|
|
||||||
fullWidth
|
|
||||||
size="md"
|
|
||||||
className="disabled:opacity-50"
|
|
||||||
onClick={onSubmit}
|
|
||||||
disabled={isSubmitting || !value.trim()}
|
|
||||||
>
|
|
||||||
{isSubmitting ? '兑换中' : '兑换'}
|
|
||||||
</PlatformActionButton>
|
|
||||||
{error ? (
|
|
||||||
<PlatformStatusMessage
|
|
||||||
tone="error"
|
|
||||||
surface="profile"
|
|
||||||
size="xs"
|
|
||||||
className="rounded-2xl font-semibold"
|
|
||||||
>
|
|
||||||
{error}
|
|
||||||
</PlatformStatusMessage>
|
|
||||||
) : null}
|
|
||||||
{success ? (
|
|
||||||
<PlatformStatusMessage
|
|
||||||
tone="success"
|
|
||||||
surface="profile"
|
|
||||||
size="xs"
|
|
||||||
className="rounded-2xl font-semibold"
|
|
||||||
>
|
|
||||||
{success}
|
|
||||||
</PlatformStatusMessage>
|
|
||||||
) : null}
|
|
||||||
</PlatformProfileModalShell>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ProfileQrScannerModal({
|
function ProfileQrScannerModal({
|
||||||
error,
|
error,
|
||||||
result,
|
result,
|
||||||
@@ -5653,7 +5090,7 @@ export function RpgEntryHomeView({
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
const rewardCodeModal: ReactNode = isRewardCodeOpen ? (
|
const rewardCodeModal: ReactNode = isRewardCodeOpen ? (
|
||||||
<RewardCodeRedeemModal
|
<PlatformProfileRewardCodeRedeemModal
|
||||||
value={rewardCodeInput}
|
value={rewardCodeInput}
|
||||||
isSubmitting={isSubmittingRewardCode}
|
isSubmitting={isSubmittingRewardCode}
|
||||||
error={rewardCodeError}
|
error={rewardCodeError}
|
||||||
@@ -5664,7 +5101,7 @@ export function RpgEntryHomeView({
|
|||||||
/>
|
/>
|
||||||
) : null;
|
) : null;
|
||||||
const rechargeModal: ReactNode = isRechargeOpen ? (
|
const rechargeModal: ReactNode = isRechargeOpen ? (
|
||||||
<ProfileRechargeModal
|
<PlatformProfileRechargeModal
|
||||||
center={rechargeCenter}
|
center={rechargeCenter}
|
||||||
isLoading={isLoadingRechargeCenter}
|
isLoading={isLoadingRechargeCenter}
|
||||||
error={rechargeError}
|
error={rechargeError}
|
||||||
@@ -5835,7 +5272,7 @@ export function RpgEntryHomeView({
|
|||||||
{qrScannerModal}
|
{qrScannerModal}
|
||||||
{categoryFilterDialog}
|
{categoryFilterDialog}
|
||||||
{isTaskCenterOpen ? (
|
{isTaskCenterOpen ? (
|
||||||
<ProfileTaskCenterModal
|
<PlatformProfileTaskCenterModal
|
||||||
center={taskCenter}
|
center={taskCenter}
|
||||||
isLoading={isLoadingTaskCenter}
|
isLoading={isLoadingTaskCenter}
|
||||||
error={taskCenterError}
|
error={taskCenterError}
|
||||||
@@ -5861,7 +5298,7 @@ export function RpgEntryHomeView({
|
|||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{isWalletLedgerOpen ? (
|
{isWalletLedgerOpen ? (
|
||||||
<WalletLedgerModal
|
<PlatformProfileWalletLedgerModal
|
||||||
ledger={walletLedger}
|
ledger={walletLedger}
|
||||||
fallbackBalance={remainingNarrativeCoins}
|
fallbackBalance={remainingNarrativeCoins}
|
||||||
isLoading={isLoadingWalletLedger}
|
isLoading={isLoadingWalletLedger}
|
||||||
@@ -5983,7 +5420,7 @@ export function RpgEntryHomeView({
|
|||||||
{rechargePaymentResultModal}
|
{rechargePaymentResultModal}
|
||||||
{categoryFilterDialog}
|
{categoryFilterDialog}
|
||||||
{isTaskCenterOpen ? (
|
{isTaskCenterOpen ? (
|
||||||
<ProfileTaskCenterModal
|
<PlatformProfileTaskCenterModal
|
||||||
center={taskCenter}
|
center={taskCenter}
|
||||||
isLoading={isLoadingTaskCenter}
|
isLoading={isLoadingTaskCenter}
|
||||||
error={taskCenterError}
|
error={taskCenterError}
|
||||||
@@ -6025,7 +5462,7 @@ export function RpgEntryHomeView({
|
|||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{isWalletLedgerOpen ? (
|
{isWalletLedgerOpen ? (
|
||||||
<WalletLedgerModal
|
<PlatformProfileWalletLedgerModal
|
||||||
ledger={walletLedger}
|
ledger={walletLedger}
|
||||||
fallbackBalance={remainingNarrativeCoins}
|
fallbackBalance={remainingNarrativeCoins}
|
||||||
isLoading={isLoadingWalletLedger}
|
isLoading={isLoadingWalletLedger}
|
||||||
|
|||||||
Reference in New Issue
Block a user