收口个人中心状态弹层与扫码组件
新增 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/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 追加:个人中心支付结果提示与支付确认遮罩统一抽到 `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-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`。
|
||||
|
||||
@@ -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.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.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.4. 大鱼吃小鱼结果页 hero 的返回入口迁移到 `PlatformIconButton variant="darkMini"`,测试 / 发布动作迁移到 `PlatformActionButton surface="editorDark"`;结果页只保留测试运行、发布提交和文案状态语义,不再手写 hero 顶栏按钮壳。
|
||||
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 {
|
||||
AlertCircle,
|
||||
ArrowRight,
|
||||
BookOpen,
|
||||
Camera,
|
||||
CheckCircle2,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Clock3,
|
||||
@@ -13,7 +11,6 @@ import {
|
||||
Gamepad2,
|
||||
GitFork,
|
||||
Heart,
|
||||
Loader2,
|
||||
LogIn,
|
||||
MessageCircle,
|
||||
Palette,
|
||||
@@ -28,7 +25,6 @@ import {
|
||||
ThumbsUp,
|
||||
Ticket,
|
||||
UserRound,
|
||||
XCircle,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
type ComponentType,
|
||||
@@ -90,6 +86,7 @@ import { PlatformIconBadge } from '../common/PlatformIconBadge';
|
||||
import { PlatformIconButton } from '../common/PlatformIconButton';
|
||||
import { PlatformModalCloseButton } from '../common/PlatformModalCloseButton';
|
||||
import { PlatformPillBadge } from '../common/PlatformPillBadge';
|
||||
import { PlatformStatusDialog } from '../common/PlatformStatusDialog';
|
||||
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
|
||||
import { PlatformSubpanel } from '../common/PlatformSubpanel';
|
||||
import { PlatformTextField } from '../common/PlatformTextField';
|
||||
@@ -122,6 +119,7 @@ import {
|
||||
} from '../platform-entry/PlatformProfilePrimitives';
|
||||
import { PlatformProfileModalShell } from '../platform-entry/PlatformProfileModalShell';
|
||||
import { PlatformProfilePlayedWorksModal } from '../platform-entry/PlatformProfilePlayedWorksModal';
|
||||
import { PlatformProfileQrScannerModal } from '../platform-entry/PlatformProfileQrScannerModal';
|
||||
import { PlatformProfileRechargeModal } from '../platform-entry/PlatformProfileRechargeModal';
|
||||
import { PlatformProfileReferralModal } from '../platform-entry/PlatformProfileReferralModal';
|
||||
import { PlatformProfileRewardCodeRedeemModal } from '../platform-entry/PlatformProfileRewardCodeRedeemModal';
|
||||
@@ -866,22 +864,6 @@ function readyRecommendRuntime(
|
||||
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 =
|
||||
| 'recommend'
|
||||
| 'today'
|
||||
@@ -2484,261 +2466,39 @@ function RechargePaymentResultModal({
|
||||
result: RechargePaymentResult;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const Icon =
|
||||
const status =
|
||||
result.kind === 'success'
|
||||
? CheckCircle2
|
||||
: result.kind === 'cancel'
|
||||
? XCircle
|
||||
: AlertCircle;
|
||||
const iconClass =
|
||||
result.kind === 'success'
|
||||
? 'text-[var(--platform-success-text)]'
|
||||
: result.kind === 'cancel'
|
||||
? 'text-[var(--platform-text-soft)]'
|
||||
: 'text-[var(--platform-button-danger-text)]';
|
||||
? 'success'
|
||||
: result.kind === 'pending'
|
||||
? 'loading'
|
||||
: result.kind === 'cancel'
|
||||
? 'cancel'
|
||||
: 'error';
|
||||
|
||||
return (
|
||||
<UnifiedModal
|
||||
open
|
||||
<PlatformStatusDialog
|
||||
status={status}
|
||||
title={result.title}
|
||||
description={result.message}
|
||||
onClose={onClose}
|
||||
showHeader={false}
|
||||
showCloseButton={false}
|
||||
closeOnBackdrop={false}
|
||||
closeOnEscape={false}
|
||||
portal={false}
|
||||
size="sm"
|
||||
action={{ label: '知道了', onClick: onClose }}
|
||||
zIndexClassName="z-[90]"
|
||||
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 }) {
|
||||
return (
|
||||
<UnifiedModal
|
||||
open
|
||||
<PlatformStatusDialog
|
||||
status="confirming"
|
||||
title="正在确认支付"
|
||||
description={`订单 ${orderId} 正在同步到账状态,请先停留在当前页面。`}
|
||||
onClose={() => undefined}
|
||||
showHeader={false}
|
||||
showCloseButton={false}
|
||||
closeOnBackdrop={false}
|
||||
closeOnEscape={false}
|
||||
portal={false}
|
||||
size="sm"
|
||||
closeDisabled
|
||||
zIndexClassName="z-[95]"
|
||||
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;
|
||||
const qrScannerModal: ReactNode = isQrScannerOpen ? (
|
||||
<ProfileQrScannerModal
|
||||
<PlatformProfileQrScannerModal
|
||||
error={qrScannerError}
|
||||
result={qrScannerResult}
|
||||
onClose={() => {
|
||||
|
||||
Reference in New Issue
Block a user