收口个人中心状态弹层与扫码组件

新增 PlatformStatusDialog 统一支付结果与确认中状态弹层
新增 PlatformProfileQrScannerModal 统一个人中心扫码面板
RpgEntryHomeView 改用共享组件并删除内联支付与扫码实现
更新 PlatformUiKit 收口文档与团队决策记录
This commit is contained in:
2026-06-10 20:24:09 +08:00
parent 914b74ce8e
commit f54c3ee936
7 changed files with 583 additions and 260 deletions

View File

@@ -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`

View File

@@ -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"`

View 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();
});

View 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>
);
}

View File

@@ -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();
}

View 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>
);
}

View File

@@ -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={() => {