收口个人中心状态弹层与扫码组件
新增 PlatformStatusDialog 统一支付结果与确认中状态弹层 新增 PlatformProfileQrScannerModal 统一个人中心扫码面板 RpgEntryHomeView 改用共享组件并删除内联支付与扫码实现 更新 PlatformUiKit 收口文档与团队决策记录
This commit is contained in:
@@ -44,6 +44,7 @@
|
|||||||
- 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/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-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-10 追加:个人中心支付结果提示与支付确认遮罩统一抽到 `src/components/common/PlatformStatusDialog.tsx`,扫码面板统一抽到 `src/components/platform-entry/PlatformProfileQrScannerModal.tsx`;`RpgEntryHomeView` 只保留支付结果 kind 到 `success / loading / cancel / error` 的映射、确认遮罩开关和扫码结果写回,不再内联 profile 状态弹层壳层、二维码摄像头启动或 `BarcodeDetector` 轮询。后续 profile 侧同类“状态图标 + 标题正文 + 可选主动作”弹层优先复用 `PlatformStatusDialog`,扫码类弹层优先复用 `PlatformProfileQrScannerModal`。
|
||||||
- 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`。
|
||||||
|
|||||||
@@ -240,6 +240,7 @@
|
|||||||
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.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.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.19. RPG 首页个人中心的支付结果提示、支付确认遮罩与扫码面板继续向共享组件收口:支付结果 / 确认中弹层统一抽到 `src/components/common/PlatformStatusDialog.tsx`,扫码面板统一抽到 `src/components/platform-entry/PlatformProfileQrScannerModal.tsx`;`RpgEntryHomeView` 仅保留支付状态映射、扫码打开关闭和结果写回,不再内联 `RechargePaymentResultModal`、`RechargePaymentConfirmationMask`、`ProfileQrScannerModal`、`BarcodeDetector` 启动逻辑和 profile 弹层壳层参数。组件级验证新增 `src/components/common/PlatformStatusDialog.test.tsx` 与 `src/components/platform-entry/PlatformProfileQrScannerModal.test.tsx`,首页继续复用 `RpgEntryHomeView.recharge.test.tsx` 的支付 / 扫码入口断言。验证命令:`npm run test -- src/components/common/PlatformStatusDialog.test.tsx`、`npm run test -- src/components/platform-entry/PlatformProfileQrScannerModal.test.tsx`、`npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx -t "profile recharge modal jumps to h5 payment on mobile web by default|profile recharge modal posts mini program payment request and reacts to success hash result|profile recharge modal releases submitting state and shows virtual payment failure detail|profile recharge modal eventually shows error text even when hashchange is not dispatched|profile recharge modal resumes virtual payment confirmation when pageshow returns with paid order|profile recharge modal blocks tab navigation while virtual payment confirmation is pending|profile scan action opens camera scanner instead of recharge panel"`、`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"`。
|
||||||
|
|||||||
67
src/components/common/PlatformStatusDialog.test.tsx
Normal file
67
src/components/common/PlatformStatusDialog.test.tsx
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
/* @vitest-environment jsdom */
|
||||||
|
|
||||||
|
import { fireEvent, render, screen } from '@testing-library/react';
|
||||||
|
import { expect, test, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { PlatformStatusDialog } from './PlatformStatusDialog';
|
||||||
|
|
||||||
|
test('renders result state with description and primary action', () => {
|
||||||
|
const onClose = vi.fn();
|
||||||
|
const onAction = vi.fn();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<PlatformStatusDialog
|
||||||
|
status="success"
|
||||||
|
title="支付成功"
|
||||||
|
description="账户状态已刷新"
|
||||||
|
onClose={onClose}
|
||||||
|
action={{ label: '知道了', onClick: onAction }}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const dialog = screen.getByRole('dialog', { name: '支付成功' });
|
||||||
|
const badge = dialog.querySelector('.platform-icon-badge');
|
||||||
|
const action = screen.getByRole('button', { name: '知道了' });
|
||||||
|
const visibleDescription = dialog.querySelector(
|
||||||
|
'.mt-3.text-sm.font-semibold.leading-6.text-\\[var\\(--platform-text-soft\\)\\]',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(dialog).toBeTruthy();
|
||||||
|
expect(visibleDescription?.textContent).toBe('账户状态已刷新');
|
||||||
|
expect(badge?.className).toContain('text-[var(--platform-success-text)]');
|
||||||
|
expect(action.className).toContain('platform-primary-button');
|
||||||
|
|
||||||
|
fireEvent.click(action);
|
||||||
|
expect(onAction).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('supports blocking confirming state without close action', () => {
|
||||||
|
const onClose = vi.fn();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<PlatformStatusDialog
|
||||||
|
status="confirming"
|
||||||
|
title="正在确认支付"
|
||||||
|
description="订单 A100 正在同步到账状态,请先停留在当前页面。"
|
||||||
|
onClose={onClose}
|
||||||
|
closeDisabled
|
||||||
|
zIndexClassName="z-[95]"
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const dialog = screen.getByRole('dialog', { name: '正在确认支付' });
|
||||||
|
const overlay = dialog.parentElement as HTMLElement;
|
||||||
|
const spinner = dialog.querySelector('.platform-icon-badge svg');
|
||||||
|
|
||||||
|
expect(overlay.className).toContain('z-[95]');
|
||||||
|
expect(spinner?.getAttribute('class')).toContain('animate-spin');
|
||||||
|
expect(
|
||||||
|
screen.queryByRole('button', { name: '关闭' }) ||
|
||||||
|
screen.queryByRole('button', { name: '知道了' }),
|
||||||
|
).toBeNull();
|
||||||
|
|
||||||
|
fireEvent.click(overlay);
|
||||||
|
fireEvent.keyDown(window, { key: 'Escape' });
|
||||||
|
|
||||||
|
expect(onClose).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
154
src/components/common/PlatformStatusDialog.tsx
Normal file
154
src/components/common/PlatformStatusDialog.tsx
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
import type { ReactNode } from 'react';
|
||||||
|
|
||||||
|
import {
|
||||||
|
AlertCircle,
|
||||||
|
CheckCircle2,
|
||||||
|
Loader2,
|
||||||
|
XCircle,
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
import { PlatformActionButton } from './PlatformActionButton';
|
||||||
|
import { PlatformIconBadge } from './PlatformIconBadge';
|
||||||
|
import { UnifiedModal } from './UnifiedModal';
|
||||||
|
|
||||||
|
export type PlatformStatusDialogStatus =
|
||||||
|
| 'success'
|
||||||
|
| 'cancel'
|
||||||
|
| 'error'
|
||||||
|
| 'loading'
|
||||||
|
| 'confirming';
|
||||||
|
|
||||||
|
type PlatformStatusDialogAction = {
|
||||||
|
label: string;
|
||||||
|
onClick: () => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type PlatformStatusDialogProps = {
|
||||||
|
open?: boolean;
|
||||||
|
status: PlatformStatusDialogStatus;
|
||||||
|
title: string;
|
||||||
|
description?: ReactNode;
|
||||||
|
onClose: () => void;
|
||||||
|
action?: PlatformStatusDialogAction;
|
||||||
|
closeDisabled?: boolean;
|
||||||
|
zIndexClassName?: string;
|
||||||
|
overlayClassName?: string;
|
||||||
|
panelClassName?: string;
|
||||||
|
bodyClassName?: string;
|
||||||
|
iconClassName?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type PlatformStatusVisualConfig = {
|
||||||
|
icon: ReactNode;
|
||||||
|
iconClassName: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEFAULT_OVERLAY_CLASS =
|
||||||
|
'platform-profile-modal-overlay bg-slate-950/72 backdrop-blur-xl';
|
||||||
|
const DEFAULT_PANEL_CLASS =
|
||||||
|
'platform-remap-surface !max-w-sm rounded-[1.4rem]';
|
||||||
|
const DEFAULT_BODY_CLASS = 'px-5 pb-5 pt-6 text-center';
|
||||||
|
|
||||||
|
function getStatusVisualConfig(
|
||||||
|
status: PlatformStatusDialogStatus,
|
||||||
|
): PlatformStatusVisualConfig {
|
||||||
|
switch (status) {
|
||||||
|
case 'success':
|
||||||
|
return {
|
||||||
|
icon: <CheckCircle2 className="h-8 w-8" aria-hidden="true" />,
|
||||||
|
iconClassName: 'text-[var(--platform-success-text)]',
|
||||||
|
};
|
||||||
|
case 'cancel':
|
||||||
|
return {
|
||||||
|
icon: <XCircle className="h-8 w-8" aria-hidden="true" />,
|
||||||
|
iconClassName: 'text-[var(--platform-text-soft)]',
|
||||||
|
};
|
||||||
|
case 'error':
|
||||||
|
return {
|
||||||
|
icon: <AlertCircle className="h-8 w-8" aria-hidden="true" />,
|
||||||
|
iconClassName: 'text-[var(--platform-button-danger-text)]',
|
||||||
|
};
|
||||||
|
case 'loading':
|
||||||
|
case 'confirming':
|
||||||
|
return {
|
||||||
|
icon: (
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin" aria-hidden="true" />
|
||||||
|
),
|
||||||
|
iconClassName: 'text-[var(--platform-accent)]',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 平台状态弹窗。
|
||||||
|
* 收口个人中心这类“状态图标 + 标题正文 + 可选主动作”的无头弹窗模式。
|
||||||
|
*/
|
||||||
|
export function PlatformStatusDialog({
|
||||||
|
open = true,
|
||||||
|
status,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
onClose,
|
||||||
|
action,
|
||||||
|
closeDisabled = false,
|
||||||
|
zIndexClassName = 'z-[90]',
|
||||||
|
overlayClassName = DEFAULT_OVERLAY_CLASS,
|
||||||
|
panelClassName = DEFAULT_PANEL_CLASS,
|
||||||
|
bodyClassName = DEFAULT_BODY_CLASS,
|
||||||
|
iconClassName,
|
||||||
|
}: PlatformStatusDialogProps) {
|
||||||
|
const visualConfig = getStatusVisualConfig(status);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<UnifiedModal
|
||||||
|
open={open}
|
||||||
|
title={title}
|
||||||
|
onClose={onClose}
|
||||||
|
showHeader={false}
|
||||||
|
showCloseButton={false}
|
||||||
|
closeDisabled={closeDisabled}
|
||||||
|
closeOnBackdrop={false}
|
||||||
|
closeOnEscape={false}
|
||||||
|
portal={false}
|
||||||
|
size="sm"
|
||||||
|
zIndexClassName={zIndexClassName}
|
||||||
|
overlayClassName={overlayClassName}
|
||||||
|
panelClassName={panelClassName}
|
||||||
|
bodyClassName={bodyClassName}
|
||||||
|
>
|
||||||
|
<PlatformIconBadge
|
||||||
|
icon={visualConfig.icon}
|
||||||
|
size="xl"
|
||||||
|
tone="neutral"
|
||||||
|
className={[
|
||||||
|
'mx-auto bg-white/10',
|
||||||
|
visualConfig.iconClassName,
|
||||||
|
iconClassName,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' ')}
|
||||||
|
/>
|
||||||
|
<div className="mt-4 text-xl font-black text-[var(--platform-text-strong)]">
|
||||||
|
{title}
|
||||||
|
</div>
|
||||||
|
{description ? (
|
||||||
|
<div className="mt-3 text-sm font-semibold leading-6 text-[var(--platform-text-soft)]">
|
||||||
|
{description}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{action ? (
|
||||||
|
<PlatformActionButton
|
||||||
|
surface="profile"
|
||||||
|
fullWidth
|
||||||
|
size="md"
|
||||||
|
className="mt-5"
|
||||||
|
onClick={action.onClick}
|
||||||
|
disabled={action.disabled}
|
||||||
|
>
|
||||||
|
{action.label}
|
||||||
|
</PlatformActionButton>
|
||||||
|
) : null}
|
||||||
|
</UnifiedModal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
/* @vitest-environment jsdom */
|
||||||
|
|
||||||
|
import { act, render, screen } from '@testing-library/react';
|
||||||
|
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { PlatformProfileQrScannerModal } from './PlatformProfileQrScannerModal';
|
||||||
|
|
||||||
|
type MockTrack = {
|
||||||
|
stop: ReturnType<typeof vi.fn>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type MockStream = {
|
||||||
|
getTracks: () => MockTrack[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const originalBarcodeDetector = (
|
||||||
|
globalThis as typeof globalThis & {
|
||||||
|
BarcodeDetector?: unknown;
|
||||||
|
}
|
||||||
|
).BarcodeDetector;
|
||||||
|
|
||||||
|
describe('PlatformProfileQrScannerModal', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.spyOn(HTMLMediaElement.prototype, 'play').mockResolvedValue(undefined);
|
||||||
|
Object.defineProperty(HTMLMediaElement.prototype, 'readyState', {
|
||||||
|
configurable: true,
|
||||||
|
get: () => 4,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.runOnlyPendingTimers();
|
||||||
|
vi.useRealTimers();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
if (originalBarcodeDetector === undefined) {
|
||||||
|
delete (
|
||||||
|
globalThis as typeof globalThis & {
|
||||||
|
BarcodeDetector?: unknown;
|
||||||
|
}
|
||||||
|
).BarcodeDetector;
|
||||||
|
} else {
|
||||||
|
(
|
||||||
|
globalThis as typeof globalThis & {
|
||||||
|
BarcodeDetector?: unknown;
|
||||||
|
}
|
||||||
|
).BarcodeDetector = originalBarcodeDetector;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('detects qr result and stops camera tracks', async () => {
|
||||||
|
const stop = vi.fn();
|
||||||
|
const stream = buildStream([{ stop }]);
|
||||||
|
const getUserMedia = vi.fn().mockResolvedValue(stream);
|
||||||
|
const detect = vi.fn().mockResolvedValue([{ rawValue: ' hello-world ' }]);
|
||||||
|
const onResult = vi.fn();
|
||||||
|
|
||||||
|
installMediaDevices(getUserMedia);
|
||||||
|
installBarcodeDetector(detect);
|
||||||
|
|
||||||
|
render(
|
||||||
|
<PlatformProfileQrScannerModal
|
||||||
|
error={null}
|
||||||
|
result={null}
|
||||||
|
onClose={vi.fn()}
|
||||||
|
onError={vi.fn()}
|
||||||
|
onResult={onResult}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await flushPromises();
|
||||||
|
});
|
||||||
|
expect(getUserMedia).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
vi.advanceTimersByTime(360);
|
||||||
|
await flushPromises();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(onResult).toHaveBeenCalledWith('hello-world');
|
||||||
|
expect(detect).toHaveBeenCalledTimes(1);
|
||||||
|
expect(stop).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('releases camera resource when modal unmounts before recognition', async () => {
|
||||||
|
const stop = vi.fn();
|
||||||
|
const stream = buildStream([{ stop }]);
|
||||||
|
const getUserMedia = vi.fn().mockResolvedValue(stream);
|
||||||
|
const detect = vi.fn().mockResolvedValue([]);
|
||||||
|
|
||||||
|
installMediaDevices(getUserMedia);
|
||||||
|
installBarcodeDetector(detect);
|
||||||
|
|
||||||
|
const { unmount } = render(
|
||||||
|
<PlatformProfileQrScannerModal
|
||||||
|
error={null}
|
||||||
|
result={null}
|
||||||
|
onClose={vi.fn()}
|
||||||
|
onError={vi.fn()}
|
||||||
|
onResult={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await flushPromises();
|
||||||
|
});
|
||||||
|
expect(getUserMedia).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
|
||||||
|
expect(stop).toHaveBeenCalledTimes(1);
|
||||||
|
expect(screen.queryByRole('dialog', { name: '扫码' })).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function buildStream(tracks: MockTrack[]): MockStream {
|
||||||
|
return {
|
||||||
|
getTracks: () => tracks,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function installMediaDevices(getUserMedia: ReturnType<typeof vi.fn>) {
|
||||||
|
Object.defineProperty(globalThis.navigator, 'mediaDevices', {
|
||||||
|
configurable: true,
|
||||||
|
value: { getUserMedia },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function installBarcodeDetector(detect: ReturnType<typeof vi.fn>) {
|
||||||
|
class MockBarcodeDetector {
|
||||||
|
detect = detect;
|
||||||
|
}
|
||||||
|
|
||||||
|
(
|
||||||
|
globalThis as typeof globalThis & {
|
||||||
|
BarcodeDetector?: unknown;
|
||||||
|
}
|
||||||
|
).BarcodeDetector = MockBarcodeDetector;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function flushPromises() {
|
||||||
|
await Promise.resolve();
|
||||||
|
}
|
||||||
196
src/components/platform-entry/PlatformProfileQrScannerModal.tsx
Normal file
196
src/components/platform-entry/PlatformProfileQrScannerModal.tsx
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
|
||||||
|
import { PlatformModalCloseButton } from '../common/PlatformModalCloseButton';
|
||||||
|
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
|
||||||
|
import { PlatformProfileModalShell } from './PlatformProfileModalShell';
|
||||||
|
|
||||||
|
const PROFILE_QR_SCAN_INTERVAL_MS = 360;
|
||||||
|
|
||||||
|
type BarcodeDetectorLike = {
|
||||||
|
detect: (source: CanvasImageSource) => Promise<Array<{ rawValue?: string }>>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type BarcodeDetectorConstructorLike = new (options?: {
|
||||||
|
formats?: string[];
|
||||||
|
}) => BarcodeDetectorLike;
|
||||||
|
|
||||||
|
export type PlatformProfileQrScannerModalProps = {
|
||||||
|
error: string | null;
|
||||||
|
result: string | null;
|
||||||
|
onClose: () => void;
|
||||||
|
onError: (message: string) => void;
|
||||||
|
onResult: (value: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function getBarcodeDetectorConstructor(): BarcodeDetectorConstructorLike | null {
|
||||||
|
const maybeDetector = (
|
||||||
|
globalThis as unknown as {
|
||||||
|
BarcodeDetector?: BarcodeDetectorConstructorLike;
|
||||||
|
}
|
||||||
|
).BarcodeDetector;
|
||||||
|
return typeof maybeDetector === 'function' ? maybeDetector : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 个人中心共享扫码弹层。
|
||||||
|
* 保持首页现有扫码语义:申请摄像头、轮询识别、关闭时释放视频流。
|
||||||
|
*/
|
||||||
|
export function PlatformProfileQrScannerModal({
|
||||||
|
error,
|
||||||
|
result,
|
||||||
|
onClose,
|
||||||
|
onError,
|
||||||
|
onResult,
|
||||||
|
}: PlatformProfileQrScannerModalProps) {
|
||||||
|
const videoRef = useRef<HTMLVideoElement | null>(null);
|
||||||
|
const streamRef = useRef<MediaStream | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const videoElement = videoRef.current;
|
||||||
|
if (!videoElement) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let isMounted = true;
|
||||||
|
let scanTimer: number | null = null;
|
||||||
|
const detectorCtor = getBarcodeDetectorConstructor();
|
||||||
|
const detector = detectorCtor
|
||||||
|
? new detectorCtor({ formats: ['qr_code'] })
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const clearScanTimer = () => {
|
||||||
|
if (scanTimer !== null) {
|
||||||
|
window.clearTimeout(scanTimer);
|
||||||
|
scanTimer = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const stopCamera = () => {
|
||||||
|
const stream = streamRef.current;
|
||||||
|
streamRef.current = null;
|
||||||
|
if (stream) {
|
||||||
|
stream.getTracks().forEach((track) => track.stop());
|
||||||
|
}
|
||||||
|
videoElement.srcObject = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const scanVideo = async () => {
|
||||||
|
if (!isMounted || !detector || videoElement.readyState < 2) {
|
||||||
|
if (isMounted && detector) {
|
||||||
|
scanTimer = window.setTimeout(scanVideo, PROFILE_QR_SCAN_INTERVAL_MS);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const codes = await detector.detect(videoElement);
|
||||||
|
const rawValue = codes[0]?.rawValue?.trim();
|
||||||
|
if (rawValue) {
|
||||||
|
clearScanTimer();
|
||||||
|
stopCamera();
|
||||||
|
onResult(rawValue);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
onError('扫码识别失败,请调整二维码位置');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isMounted) {
|
||||||
|
scanTimer = window.setTimeout(scanVideo, PROFILE_QR_SCAN_INTERVAL_MS);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const startCamera = async () => {
|
||||||
|
if (
|
||||||
|
typeof navigator === 'undefined' ||
|
||||||
|
!navigator.mediaDevices?.getUserMedia
|
||||||
|
) {
|
||||||
|
onError('当前浏览器不支持摄像头扫码');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const stream = await navigator.mediaDevices.getUserMedia({
|
||||||
|
audio: false,
|
||||||
|
video: { facingMode: { ideal: 'environment' } },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!isMounted) {
|
||||||
|
stream.getTracks().forEach((track) => track.stop());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
streamRef.current?.getTracks().forEach((track) => track.stop());
|
||||||
|
streamRef.current = stream;
|
||||||
|
videoElement.srcObject = stream;
|
||||||
|
await videoElement.play();
|
||||||
|
if (!detector) {
|
||||||
|
onError('当前浏览器暂不支持二维码识别');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
scanTimer = window.setTimeout(scanVideo, PROFILE_QR_SCAN_INTERVAL_MS);
|
||||||
|
} catch {
|
||||||
|
onError('无法打开摄像头,请检查权限');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
void startCamera();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
isMounted = false;
|
||||||
|
clearScanTimer();
|
||||||
|
stopCamera();
|
||||||
|
};
|
||||||
|
}, [onError, onResult]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PlatformProfileModalShell
|
||||||
|
title="扫码"
|
||||||
|
onClose={onClose}
|
||||||
|
showHeader={false}
|
||||||
|
showCloseButton={false}
|
||||||
|
size="sm"
|
||||||
|
panelClassName="platform-qr-scanner-modal !max-w-sm rounded-[1.4rem]"
|
||||||
|
bodyClassName="!p-0"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between border-b border-white/10 px-5 py-4">
|
||||||
|
<div className="text-base font-black">扫码</div>
|
||||||
|
<PlatformModalCloseButton
|
||||||
|
label="关闭扫码"
|
||||||
|
onClick={onClose}
|
||||||
|
icon="×"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-3 px-5 py-5">
|
||||||
|
<div className="platform-qr-scanner-modal__viewport">
|
||||||
|
<video
|
||||||
|
ref={videoRef}
|
||||||
|
className="h-full w-full object-cover"
|
||||||
|
playsInline
|
||||||
|
muted
|
||||||
|
/>
|
||||||
|
<span className="platform-qr-scanner-modal__frame" />
|
||||||
|
</div>
|
||||||
|
{result ? (
|
||||||
|
<PlatformStatusMessage
|
||||||
|
tone="success"
|
||||||
|
surface="profile"
|
||||||
|
size="xs"
|
||||||
|
className="rounded-2xl font-semibold"
|
||||||
|
>
|
||||||
|
已识别:{result}
|
||||||
|
</PlatformStatusMessage>
|
||||||
|
) : error ? (
|
||||||
|
<PlatformStatusMessage
|
||||||
|
tone="error"
|
||||||
|
surface="profile"
|
||||||
|
size="xs"
|
||||||
|
className="rounded-2xl font-semibold"
|
||||||
|
>
|
||||||
|
{error}
|
||||||
|
</PlatformStatusMessage>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</PlatformProfileModalShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,9 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
AlertCircle,
|
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
BookOpen,
|
BookOpen,
|
||||||
Camera,
|
Camera,
|
||||||
CheckCircle2,
|
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
Clock3,
|
Clock3,
|
||||||
@@ -13,7 +11,6 @@ import {
|
|||||||
Gamepad2,
|
Gamepad2,
|
||||||
GitFork,
|
GitFork,
|
||||||
Heart,
|
Heart,
|
||||||
Loader2,
|
|
||||||
LogIn,
|
LogIn,
|
||||||
MessageCircle,
|
MessageCircle,
|
||||||
Palette,
|
Palette,
|
||||||
@@ -28,7 +25,6 @@ import {
|
|||||||
ThumbsUp,
|
ThumbsUp,
|
||||||
Ticket,
|
Ticket,
|
||||||
UserRound,
|
UserRound,
|
||||||
XCircle,
|
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
type ComponentType,
|
type ComponentType,
|
||||||
@@ -90,6 +86,7 @@ import { PlatformIconBadge } from '../common/PlatformIconBadge';
|
|||||||
import { PlatformIconButton } from '../common/PlatformIconButton';
|
import { PlatformIconButton } from '../common/PlatformIconButton';
|
||||||
import { PlatformModalCloseButton } from '../common/PlatformModalCloseButton';
|
import { PlatformModalCloseButton } from '../common/PlatformModalCloseButton';
|
||||||
import { PlatformPillBadge } from '../common/PlatformPillBadge';
|
import { PlatformPillBadge } from '../common/PlatformPillBadge';
|
||||||
|
import { PlatformStatusDialog } from '../common/PlatformStatusDialog';
|
||||||
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
|
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
|
||||||
import { PlatformSubpanel } from '../common/PlatformSubpanel';
|
import { PlatformSubpanel } from '../common/PlatformSubpanel';
|
||||||
import { PlatformTextField } from '../common/PlatformTextField';
|
import { PlatformTextField } from '../common/PlatformTextField';
|
||||||
@@ -122,6 +119,7 @@ import {
|
|||||||
} from '../platform-entry/PlatformProfilePrimitives';
|
} from '../platform-entry/PlatformProfilePrimitives';
|
||||||
import { PlatformProfileModalShell } from '../platform-entry/PlatformProfileModalShell';
|
import { PlatformProfileModalShell } from '../platform-entry/PlatformProfileModalShell';
|
||||||
import { PlatformProfilePlayedWorksModal } from '../platform-entry/PlatformProfilePlayedWorksModal';
|
import { PlatformProfilePlayedWorksModal } from '../platform-entry/PlatformProfilePlayedWorksModal';
|
||||||
|
import { PlatformProfileQrScannerModal } from '../platform-entry/PlatformProfileQrScannerModal';
|
||||||
import { PlatformProfileRechargeModal } from '../platform-entry/PlatformProfileRechargeModal';
|
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 { PlatformProfileRewardCodeRedeemModal } from '../platform-entry/PlatformProfileRewardCodeRedeemModal';
|
||||||
@@ -866,22 +864,6 @@ function readyRecommendRuntime(
|
|||||||
scanResources();
|
scanResources();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
const PROFILE_QR_SCAN_INTERVAL_MS = 360;
|
|
||||||
type BarcodeDetectorLike = {
|
|
||||||
detect: (source: CanvasImageSource) => Promise<Array<{ rawValue?: string }>>;
|
|
||||||
};
|
|
||||||
type BarcodeDetectorConstructorLike = new (options?: {
|
|
||||||
formats?: string[];
|
|
||||||
}) => BarcodeDetectorLike;
|
|
||||||
|
|
||||||
function getBarcodeDetectorConstructor(): BarcodeDetectorConstructorLike | null {
|
|
||||||
const maybeDetector = (
|
|
||||||
globalThis as unknown as {
|
|
||||||
BarcodeDetector?: BarcodeDetectorConstructorLike;
|
|
||||||
}
|
|
||||||
).BarcodeDetector;
|
|
||||||
return typeof maybeDetector === 'function' ? maybeDetector : null;
|
|
||||||
}
|
|
||||||
type DiscoverChannel =
|
type DiscoverChannel =
|
||||||
| 'recommend'
|
| 'recommend'
|
||||||
| 'today'
|
| 'today'
|
||||||
@@ -2484,261 +2466,39 @@ function RechargePaymentResultModal({
|
|||||||
result: RechargePaymentResult;
|
result: RechargePaymentResult;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}) {
|
}) {
|
||||||
const Icon =
|
const status =
|
||||||
result.kind === 'success'
|
result.kind === 'success'
|
||||||
? CheckCircle2
|
? 'success'
|
||||||
|
: result.kind === 'pending'
|
||||||
|
? 'loading'
|
||||||
: result.kind === 'cancel'
|
: result.kind === 'cancel'
|
||||||
? XCircle
|
? 'cancel'
|
||||||
: AlertCircle;
|
: 'error';
|
||||||
const iconClass =
|
|
||||||
result.kind === 'success'
|
|
||||||
? 'text-[var(--platform-success-text)]'
|
|
||||||
: result.kind === 'cancel'
|
|
||||||
? 'text-[var(--platform-text-soft)]'
|
|
||||||
: 'text-[var(--platform-button-danger-text)]';
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<UnifiedModal
|
<PlatformStatusDialog
|
||||||
open
|
status={status}
|
||||||
title={result.title}
|
title={result.title}
|
||||||
|
description={result.message}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
showHeader={false}
|
action={{ label: '知道了', onClick: onClose }}
|
||||||
showCloseButton={false}
|
|
||||||
closeOnBackdrop={false}
|
|
||||||
closeOnEscape={false}
|
|
||||||
portal={false}
|
|
||||||
size="sm"
|
|
||||||
zIndexClassName="z-[90]"
|
zIndexClassName="z-[90]"
|
||||||
overlayClassName={PROFILE_MODAL_OVERLAY_CLASS}
|
overlayClassName={PROFILE_MODAL_OVERLAY_CLASS}
|
||||||
panelClassName="platform-remap-surface !max-w-sm rounded-[1.4rem]"
|
|
||||||
bodyClassName="px-5 pb-5 pt-6 text-center"
|
|
||||||
>
|
|
||||||
<PlatformIconBadge
|
|
||||||
icon={<Icon className="h-8 w-8" aria-hidden="true" />}
|
|
||||||
size="xl"
|
|
||||||
tone="neutral"
|
|
||||||
className={`mx-auto bg-white/10 ${iconClass}`}
|
|
||||||
/>
|
/>
|
||||||
<div className="mt-4 text-xl font-black text-[var(--platform-text-strong)]">
|
|
||||||
{result.title}
|
|
||||||
</div>
|
|
||||||
<div className="mt-3 text-sm font-semibold leading-6 text-[var(--platform-text-soft)]">
|
|
||||||
{result.message}
|
|
||||||
</div>
|
|
||||||
<PlatformActionButton
|
|
||||||
surface="profile"
|
|
||||||
fullWidth
|
|
||||||
size="md"
|
|
||||||
className="mt-5"
|
|
||||||
onClick={onClose}
|
|
||||||
>
|
|
||||||
知道了
|
|
||||||
</PlatformActionButton>
|
|
||||||
</UnifiedModal>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function RechargePaymentConfirmationMask({ orderId }: { orderId: string }) {
|
function RechargePaymentConfirmationMask({ orderId }: { orderId: string }) {
|
||||||
return (
|
return (
|
||||||
<UnifiedModal
|
<PlatformStatusDialog
|
||||||
open
|
status="confirming"
|
||||||
title="正在确认支付"
|
title="正在确认支付"
|
||||||
|
description={`订单 ${orderId} 正在同步到账状态,请先停留在当前页面。`}
|
||||||
onClose={() => undefined}
|
onClose={() => undefined}
|
||||||
showHeader={false}
|
closeDisabled
|
||||||
showCloseButton={false}
|
|
||||||
closeOnBackdrop={false}
|
|
||||||
closeOnEscape={false}
|
|
||||||
portal={false}
|
|
||||||
size="sm"
|
|
||||||
zIndexClassName="z-[95]"
|
zIndexClassName="z-[95]"
|
||||||
overlayClassName={PROFILE_MODAL_OVERLAY_CLASS}
|
overlayClassName={PROFILE_MODAL_OVERLAY_CLASS}
|
||||||
panelClassName="platform-remap-surface !max-w-sm rounded-[1.4rem]"
|
|
||||||
bodyClassName="px-5 pb-5 pt-6 text-center"
|
|
||||||
>
|
|
||||||
<PlatformIconBadge
|
|
||||||
icon={<Loader2 className="h-8 w-8 animate-spin" aria-hidden="true" />}
|
|
||||||
size="xl"
|
|
||||||
tone="neutral"
|
|
||||||
className="mx-auto bg-white/10 text-[var(--platform-accent)]"
|
|
||||||
/>
|
/>
|
||||||
<div className="mt-4 text-xl font-black text-[var(--platform-text-strong)]">
|
|
||||||
正在确认支付
|
|
||||||
</div>
|
|
||||||
<div className="mt-3 text-sm font-semibold leading-6 text-[var(--platform-text-soft)]">
|
|
||||||
订单 {orderId} 正在同步到账状态,请先停留在当前页面。
|
|
||||||
</div>
|
|
||||||
</UnifiedModal>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ProfileQrScannerModal({
|
|
||||||
error,
|
|
||||||
result,
|
|
||||||
onClose,
|
|
||||||
onError,
|
|
||||||
onResult,
|
|
||||||
}: {
|
|
||||||
error: string | null;
|
|
||||||
result: string | null;
|
|
||||||
onClose: () => void;
|
|
||||||
onError: (message: string) => void;
|
|
||||||
onResult: (value: string) => void;
|
|
||||||
}) {
|
|
||||||
const videoRef = useRef<HTMLVideoElement | null>(null);
|
|
||||||
const streamRef = useRef<MediaStream | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const videoElement = videoRef.current;
|
|
||||||
if (!videoElement) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let isMounted = true;
|
|
||||||
let scanTimer: number | null = null;
|
|
||||||
const detectorCtor = getBarcodeDetectorConstructor();
|
|
||||||
const detector = detectorCtor
|
|
||||||
? new detectorCtor({ formats: ['qr_code'] })
|
|
||||||
: null;
|
|
||||||
|
|
||||||
const clearScanTimer = () => {
|
|
||||||
if (scanTimer !== null) {
|
|
||||||
window.clearTimeout(scanTimer);
|
|
||||||
scanTimer = null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const stopCamera = () => {
|
|
||||||
const stream = streamRef.current;
|
|
||||||
streamRef.current = null;
|
|
||||||
if (stream) {
|
|
||||||
stream.getTracks().forEach((track) => track.stop());
|
|
||||||
}
|
|
||||||
videoElement.srcObject = null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const scanVideo = async () => {
|
|
||||||
if (!isMounted || !detector || videoElement.readyState < 2) {
|
|
||||||
if (isMounted && detector) {
|
|
||||||
scanTimer = window.setTimeout(scanVideo, PROFILE_QR_SCAN_INTERVAL_MS);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const codes = await detector.detect(videoElement);
|
|
||||||
const rawValue = codes[0]?.rawValue?.trim();
|
|
||||||
if (rawValue) {
|
|
||||||
clearScanTimer();
|
|
||||||
stopCamera();
|
|
||||||
onResult(rawValue);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
onError('扫码识别失败,请调整二维码位置');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isMounted) {
|
|
||||||
scanTimer = window.setTimeout(scanVideo, PROFILE_QR_SCAN_INTERVAL_MS);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const startCamera = async () => {
|
|
||||||
if (
|
|
||||||
typeof navigator === 'undefined' ||
|
|
||||||
!navigator.mediaDevices?.getUserMedia
|
|
||||||
) {
|
|
||||||
onError('当前浏览器不支持摄像头扫码');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const stream = await navigator.mediaDevices.getUserMedia({
|
|
||||||
audio: false,
|
|
||||||
video: { facingMode: { ideal: 'environment' } },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!isMounted) {
|
|
||||||
stream.getTracks().forEach((track) => track.stop());
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
streamRef.current?.getTracks().forEach((track) => track.stop());
|
|
||||||
streamRef.current = stream;
|
|
||||||
videoElement.srcObject = stream;
|
|
||||||
await videoElement.play();
|
|
||||||
if (!detector) {
|
|
||||||
onError('当前浏览器暂不支持二维码识别');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
scanTimer = window.setTimeout(scanVideo, PROFILE_QR_SCAN_INTERVAL_MS);
|
|
||||||
} catch {
|
|
||||||
onError('无法打开摄像头,请检查权限');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
void startCamera();
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
isMounted = false;
|
|
||||||
clearScanTimer();
|
|
||||||
stopCamera();
|
|
||||||
};
|
|
||||||
}, [onError, onResult]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<UnifiedModal
|
|
||||||
open
|
|
||||||
title="扫码"
|
|
||||||
onClose={onClose}
|
|
||||||
showHeader={false}
|
|
||||||
showCloseButton={false}
|
|
||||||
closeOnBackdrop={false}
|
|
||||||
closeOnEscape={false}
|
|
||||||
portal={false}
|
|
||||||
size="sm"
|
|
||||||
zIndexClassName="z-[80]"
|
|
||||||
overlayClassName={PROFILE_MODAL_OVERLAY_CLASS}
|
|
||||||
panelClassName="platform-qr-scanner-modal !max-w-sm rounded-[1.4rem]"
|
|
||||||
bodyClassName="!p-0"
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between border-b border-white/10 px-5 py-4">
|
|
||||||
<div className="text-base font-black">扫码</div>
|
|
||||||
<PlatformModalCloseButton
|
|
||||||
label="关闭扫码"
|
|
||||||
onClick={onClose}
|
|
||||||
icon="×"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-3 px-5 py-5">
|
|
||||||
<div className="platform-qr-scanner-modal__viewport">
|
|
||||||
<video
|
|
||||||
ref={videoRef}
|
|
||||||
className="h-full w-full object-cover"
|
|
||||||
playsInline
|
|
||||||
muted
|
|
||||||
/>
|
|
||||||
<span className="platform-qr-scanner-modal__frame" />
|
|
||||||
</div>
|
|
||||||
{result ? (
|
|
||||||
<PlatformStatusMessage
|
|
||||||
tone="success"
|
|
||||||
surface="profile"
|
|
||||||
size="xs"
|
|
||||||
className="rounded-2xl font-semibold"
|
|
||||||
>
|
|
||||||
已识别:{result}
|
|
||||||
</PlatformStatusMessage>
|
|
||||||
) : error ? (
|
|
||||||
<PlatformStatusMessage
|
|
||||||
tone="error"
|
|
||||||
surface="profile"
|
|
||||||
size="xs"
|
|
||||||
className="rounded-2xl font-semibold"
|
|
||||||
>
|
|
||||||
{error}
|
|
||||||
</PlatformStatusMessage>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</UnifiedModal>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5145,7 +4905,7 @@ export function RpgEntryHomeView({
|
|||||||
/>
|
/>
|
||||||
) : null;
|
) : null;
|
||||||
const qrScannerModal: ReactNode = isQrScannerOpen ? (
|
const qrScannerModal: ReactNode = isQrScannerOpen ? (
|
||||||
<ProfileQrScannerModal
|
<PlatformProfileQrScannerModal
|
||||||
error={qrScannerError}
|
error={qrScannerError}
|
||||||
result={qrScannerResult}
|
result={qrScannerResult}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
|
|||||||
Reference in New Issue
Block a user