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

新增 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

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