From 914b74ce8e0ec935849a5c6a76e8b33a464bb692 Mon Sep 17 00:00:00 2001 From: kdletters Date: Wed, 10 Jun 2026 20:06:06 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8B=86=E5=87=BA=E4=B8=AA=E4=BA=BA=E4=B8=AD?= =?UTF-8?q?=E5=BF=83=E5=89=A9=E4=BD=99=E5=BC=B9=E5=B1=82=E7=BB=84=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增充值账单任务兑换码共享组件并补齐组件级测试 - 让 RpgEntryHomeView 改为复用新的 profile 弹层组件并删除内联实现 - 更新 PlatformUiKit 收口文档与团队共享记忆记录新的组件沉淀 --- .hermes/shared-memory/decision-log.md | 2 + ...】PlatformUiKit弹窗组件收口计划-2026-06-08.md | 2 + .../PlatformProfileRechargeModal.test.tsx | 100 +++ .../PlatformProfileRechargeModal.tsx | 269 ++++++++ ...tformProfileRewardCodeRedeemModal.test.tsx | 54 ++ .../PlatformProfileRewardCodeRedeemModal.tsx | 84 +++ .../PlatformProfileTaskCenterModal.test.tsx | 98 +++ .../PlatformProfileTaskCenterModal.tsx | 143 +++++ .../PlatformProfileWalletLedgerModal.test.tsx | 58 ++ .../PlatformProfileWalletLedgerModal.tsx | 130 ++++ src/components/rpg-entry/RpgEntryHomeView.tsx | 587 +----------------- 11 files changed, 952 insertions(+), 575 deletions(-) create mode 100644 src/components/platform-entry/PlatformProfileRechargeModal.test.tsx create mode 100644 src/components/platform-entry/PlatformProfileRechargeModal.tsx create mode 100644 src/components/platform-entry/PlatformProfileRewardCodeRedeemModal.test.tsx create mode 100644 src/components/platform-entry/PlatformProfileRewardCodeRedeemModal.tsx create mode 100644 src/components/platform-entry/PlatformProfileTaskCenterModal.test.tsx create mode 100644 src/components/platform-entry/PlatformProfileTaskCenterModal.tsx create mode 100644 src/components/platform-entry/PlatformProfileWalletLedgerModal.test.tsx create mode 100644 src/components/platform-entry/PlatformProfileWalletLedgerModal.tsx diff --git a/.hermes/shared-memory/decision-log.md b/.hermes/shared-memory/decision-log.md index f6150f07..9ea92175 100644 --- a/.hermes/shared-memory/decision-log.md +++ b/.hermes/shared-memory/decision-log.md @@ -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 追加:个人中心标准头部弹窗与白底副弹层的共享壳层统一抽到 `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/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-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 1bf6cf28..cff0c72a 100644 --- a/docs/technical/【前端架构】PlatformUiKit弹窗组件收口计划-2026-06-08.md +++ b/docs/technical/【前端架构】PlatformUiKit弹窗组件收口计划-2026-06-08.md @@ -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.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.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.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/PlatformProfileRechargeModal.test.tsx b/src/components/platform-entry/PlatformProfileRechargeModal.test.tsx new file mode 100644 index 00000000..b5ae13bb --- /dev/null +++ b/src/components/platform-entry/PlatformProfileRechargeModal.test.tsx @@ -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( + , + ); + + 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( + , + ); + + expect(screen.getByText('暂无可购买套餐')).toBeTruthy(); + }); +}); diff --git a/src/components/platform-entry/PlatformProfileRechargeModal.tsx b/src/components/platform-entry/PlatformProfileRechargeModal.tsx new file mode 100644 index 00000000..d0e1a0ed --- /dev/null +++ b/src/components/platform-entry/PlatformProfileRechargeModal.tsx @@ -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(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 ( + 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 ? ( + + {badgeLabel} + + ) : null} +
+ {product.title} +
+
+ {value} +
+
+ + {formatRechargePrice(product.priceCents)} + + + {submitting ? '处理中' : '购买'} + +
+
+ ); +} + +/** + * 个人中心充值弹窗,共享给不同入口复用,但保持现有 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 ( + +
+ + +
+ + {error ? ( + +
{error}
+ + 重新加载 + +
+ ) : null} + + {isLoading ? ( +
+ {Array.from({ length: 4 }).map((_, index) => ( +
+ ))} +
+ ) : products.length > 0 ? ( +
+ {products.map((product) => ( + + ))} +
+ ) : ( + + 暂无可购买套餐 + + )} + + {nativePayment ? ( + +
微信扫码支付
+
+ {nativeQrImageUrl ? ( + 微信 Native 支付二维码 + ) : ( + + 生成中 + + )} +
+ + {nativePayment.isConfirming ? '确认中' : '我已支付'} + +
+ ) : null} + + ); +} diff --git a/src/components/platform-entry/PlatformProfileRewardCodeRedeemModal.test.tsx b/src/components/platform-entry/PlatformProfileRewardCodeRedeemModal.test.tsx new file mode 100644 index 00000000..2eace460 --- /dev/null +++ b/src/components/platform-entry/PlatformProfileRewardCodeRedeemModal.test.tsx @@ -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( + , + ); + + 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( + , + ); + + expect( + screen.getByRole('button', { name: '兑换' }).hasAttribute('disabled'), + ).toBe(true); + }); +}); diff --git a/src/components/platform-entry/PlatformProfileRewardCodeRedeemModal.tsx b/src/components/platform-entry/PlatformProfileRewardCodeRedeemModal.tsx new file mode 100644 index 00000000..14e0f774 --- /dev/null +++ b/src/components/platform-entry/PlatformProfileRewardCodeRedeemModal.tsx @@ -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 ( + + onChange(event.target.value)} + onKeyDown={(event) => { + if (event.key === 'Enter') { + onSubmit(); + } + }} + size="sm" + density="roomy" + className="uppercase tracking-normal" + placeholder="输入兑换码" + aria-label="兑换码" + autoFocus + /> + + {isSubmitting ? '兑换中' : '兑换'} + + {error ? ( + + {error} + + ) : null} + {success ? ( + + {success} + + ) : null} + + ); +} diff --git a/src/components/platform-entry/PlatformProfileTaskCenterModal.test.tsx b/src/components/platform-entry/PlatformProfileTaskCenterModal.test.tsx new file mode 100644 index 00000000..6ec26d62 --- /dev/null +++ b/src/components/platform-entry/PlatformProfileTaskCenterModal.test.tsx @@ -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( + , + ); + + 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( + , + ); + + expect( + screen.getByRole('button', { name: '未完成' }).hasAttribute('disabled'), + ).toBe(true); + }); +}); diff --git a/src/components/platform-entry/PlatformProfileTaskCenterModal.tsx b/src/components/platform-entry/PlatformProfileTaskCenterModal.tsx new file mode 100644 index 00000000..e3879031 --- /dev/null +++ b/src/components/platform-entry/PlatformProfileTaskCenterModal.tsx @@ -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 ( + + {error ? ( + +
{error}
+ + 重新加载 + +
+ ) : null} + {success ? ( + + {success} + + ) : null} + {isLoading ? ( +
+ {Array.from({ length: 2 }).map((_, index) => ( +
+ ))} +
+ ) : tasks.length === 0 ? ( + + 暂无任务 + + ) : ( +
+ {tasks.map((task) => { + const isClaimable = task.status === 'claimable'; + const isClaiming = claimingTaskId === task.taskId; + const progressLabel = buildProfileTaskProgressLabel(task); + + return ( + +
+
+
+ {task.title} +
+
+ {progressLabel} +
+
+
+
+ +{task.rewardPoints} +
+
+ {getProfileTaskStatusLabel(task.status)} +
+
+
+ onClaim(task.taskId)} + > + {getProfileTaskClaimButtonLabel(task, isClaiming)} + +
+ ); + })} +
+ )} + + ); +} diff --git a/src/components/platform-entry/PlatformProfileWalletLedgerModal.test.tsx b/src/components/platform-entry/PlatformProfileWalletLedgerModal.test.tsx new file mode 100644 index 00000000..3ab86924 --- /dev/null +++ b/src/components/platform-entry/PlatformProfileWalletLedgerModal.test.tsx @@ -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( + , + ); + + 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( + , + ); + + await user.click(screen.getByRole('button', { name: '重新加载' })); + expect(onRetry).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/components/platform-entry/PlatformProfileWalletLedgerModal.tsx b/src/components/platform-entry/PlatformProfileWalletLedgerModal.tsx new file mode 100644 index 00000000..fb72af98 --- /dev/null +++ b/src/components/platform-entry/PlatformProfileWalletLedgerModal.tsx @@ -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 ( + +
+
+ LEDGER +
+
泥点账单
+ } + className="mt-3 bg-white/70" + > + {walletLedgerPresentation.balanceLabel} + +
+ + {error ? ( + +
{error}
+ + 重新加载 + +
+ ) : isLoading ? ( +
+ {Array.from({ length: 5 }).map((_, index) => ( +
+ ))} +
+ ) : entries.length === 0 ? ( + + 暂无账单记录 + + ) : ( +
+ {entries.map((entry) => ( + +
+
+ {entry.sourceLabel} +
+
+ {formatPlatformWorldTime(entry.createdAt)} +
+
+
+
+ {entry.amountLabel} +
+
+ {entry.balanceLabel} +
+
+
+ ))} +
+ )} + + ); +} diff --git a/src/components/rpg-entry/RpgEntryHomeView.tsx b/src/components/rpg-entry/RpgEntryHomeView.tsx index b2508faa..6d0da2b0 100644 --- a/src/components/rpg-entry/RpgEntryHomeView.tsx +++ b/src/components/rpg-entry/RpgEntryHomeView.tsx @@ -30,7 +30,6 @@ import { UserRound, XCircle, } from 'lucide-react'; -import QRCode from 'qrcode'; import { type ComponentType, type CSSProperties, @@ -62,11 +61,7 @@ import type { ProfileDashboardSummary, ProfilePlayedWorkSummary, ProfilePlayStatsResponse, - ProfileRechargeCenterResponse, - ProfileRechargeProduct, ProfileSaveArchiveSummary, - ProfileTaskCenterResponse, - ProfileWalletLedgerResponse, } from '../../../packages/shared/src/contracts/runtime'; import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes'; import { @@ -125,17 +120,16 @@ import { ProfileStatCard, ProfileStatCardSkeleton, } from '../platform-entry/PlatformProfilePrimitives'; -import { - PlatformProfileModalShell, - PlatformProfileSecondaryModalShell, -} from '../platform-entry/PlatformProfileModalShell'; +import { PlatformProfileModalShell } from '../platform-entry/PlatformProfileModalShell'; import { PlatformProfilePlayedWorksModal } from '../platform-entry/PlatformProfilePlayedWorksModal'; +import { PlatformProfileRechargeModal } from '../platform-entry/PlatformProfileRechargeModal'; 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 { - type NativeWechatPaymentState, type RechargePaymentResult, - type RechargeTab, usePlatformProfileCenterController, } from '../platform-entry/usePlatformProfileCenterController'; import { ResolvedAssetImage } from '../ResolvedAssetImage'; @@ -144,19 +138,7 @@ import { buildProfileDashboardPresentation, formatSnapshotTime, } from './rpgEntryProfileDashboardPresentation'; -import { - buildMembershipLabel, - buildRechargeProductValueLabel, - buildWalletLedgerPresentation, - formatRechargePrice, -} from './rpgEntryProfileFundsViewModel'; -import { - buildProfileTaskCardSummary, - buildProfileTaskProgressLabel, - getProfileTaskClaimButtonLabel, - getProfileTaskStatusLabel, - selectProfileTaskCenterTasks, -} from './rpgEntryProfileTaskViewModel'; +import { buildProfileTaskCardSummary } from './rpgEntryProfileTaskViewModel'; import { buildPlatformRankingEntries, buildPlatformRecommendFeedEntries, @@ -884,7 +866,6 @@ function readyRecommendRuntime( scanResources(); }); } -const WECHAT_NATIVE_PAY_QR_IMAGE_SIZE = 180; const PROFILE_QR_SCAN_INTERVAL_MS = 360; type BarcodeDetectorLike = { detect: (source: CanvasImageSource) => Promise>; @@ -2496,239 +2477,6 @@ function ProfileNicknameModal({ ); } -function useWechatNativeQrCode(codeUrl: string | null) { - const [qrImageUrl, setQrImageUrl] = useState(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 ( - 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 ? ( - - {badgeLabel} - - ) : null} -
- {product.title} -
-
- {value} -
-
- - {formatRechargePrice(product.priceCents)} - - - {submitting ? '处理中' : '购买'} - -
-
- ); -} - -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 ( - -
- - -
- - {error ? ( - -
{error}
- - 重新加载 - -
- ) : null} - - {isLoading ? ( -
- {Array.from({ length: 4 }).map((_, index) => ( -
- ))} -
- ) : products.length > 0 ? ( -
- {products.map((product) => ( - - ))} -
- ) : ( - - 暂无可购买套餐 - - )} - - {nativePayment ? ( - -
微信扫码支付
-
- {nativeQrImageUrl ? ( - 微信 Native 支付二维码 - ) : ( - - 生成中 - - )} -
- - {nativePayment.isConfirming ? '确认中' : '我已支付'} - -
- ) : null} - - ); -} - function RechargePaymentResultModal({ result, 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 ( - -
-
- LEDGER -
-
泥点账单
- } - className="mt-3 bg-white/70" - > - {walletLedgerPresentation.balanceLabel} - -
- - {error ? ( - -
{error}
- - 重新加载 - -
- ) : isLoading ? ( -
- {Array.from({ length: 5 }).map((_, index) => ( -
- ))} -
- ) : entries.length === 0 ? ( - - 暂无账单记录 - - ) : ( -
- {entries.map((entry) => { - return ( - -
-
- {entry.sourceLabel} -
-
- {formatPlatformWorldTime(entry.createdAt)} -
-
-
-
- {entry.amountLabel} -
-
- {entry.balanceLabel} -
-
-
- ); - })} -
- )} - - ); -} - -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 ( - - {error ? ( - -
{error}
- - 重新加载 - -
- ) : null} - {success ? ( - - {success} - - ) : null} - {isLoading ? ( -
- {Array.from({ length: 2 }).map((_, index) => ( -
- ))} -
- ) : tasks.length === 0 ? ( - - 暂无任务 - - ) : ( -
- {tasks.map((task) => { - const isClaimable = task.status === 'claimable'; - const isClaiming = claimingTaskId === task.taskId; - const progressLabel = buildProfileTaskProgressLabel(task); - - return ( - -
-
-
- {task.title} -
-
- {progressLabel} -
-
-
-
- +{task.rewardPoints} -
-
- {getProfileTaskStatusLabel(task.status)} -
-
-
- onClaim(task.taskId)} - > - {getProfileTaskClaimButtonLabel(task, isClaiming)} - -
- ); - })} -
- )} - - ); -} - -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 ( - - onChange(event.target.value)} - onKeyDown={(event) => { - if (event.key === 'Enter') { - onSubmit(); - } - }} - size="sm" - density="roomy" - className="uppercase tracking-normal" - placeholder="输入兑换码" - aria-label="兑换码" - autoFocus - /> - - {isSubmitting ? '兑换中' : '兑换'} - - {error ? ( - - {error} - - ) : null} - {success ? ( - - {success} - - ) : null} - - ); -} - function ProfileQrScannerModal({ error, result, @@ -5653,7 +5090,7 @@ export function RpgEntryHomeView({ ); const rewardCodeModal: ReactNode = isRewardCodeOpen ? ( - ) : null; const rechargeModal: ReactNode = isRechargeOpen ? ( - ) : null} {isWalletLedgerOpen ? ( - ) : null} {isWalletLedgerOpen ? ( -