接入移动端原生扫码能力

新增 scanner.scanQrCode HostBridge 契约与二维码结果归一。

接入 Expo Camera 扫码 overlay 并保留 H5 浏览器扫码回退。

让 Tauri 白名单识别扫码 method 但不声明桌面扫码能力。

同步原生壳门禁、Expo 权限检查、方案文档和共享决策记录。
This commit is contained in:
2026-06-19 05:13:21 +08:00
parent a471e69455
commit e4b9cbf09d
26 changed files with 758 additions and 28 deletions
@@ -3175,6 +3175,40 @@ test('profile scan action opens camera scanner instead of recharge panel', async
expect(stopTrack).toHaveBeenCalledTimes(1);
});
test('profile scan action uses native QR scanner before browser camera', async () => {
const user = userEvent.setup();
const getUserMedia = vi.fn();
const scanHostQrCodeSpy = vi
.spyOn(hostBridgeServices, 'scanHostQrCode')
.mockResolvedValue({
value: 'https://app.genarrative.world/works/detail?work=PZ-1',
format: 'qr_code',
});
mockNarrowMobileLayout();
Object.defineProperty(navigator, 'mediaDevices', {
configurable: true,
value: { getUserMedia },
});
renderProfileView();
const topbar = document.querySelector('.platform-mobile-topbar');
expect(topbar).toBeTruthy();
await user.click(
within(topbar as HTMLElement).getByRole('button', { name: '扫码' }),
);
const qrScannerDialog = await screen.findByRole('dialog', { name: '扫码' });
expect(scanHostQrCodeSpy).toHaveBeenCalledTimes(1);
expect(qrScannerDialog.textContent).toContain(
'已识别:https://app.genarrative.world/works/detail?work=PZ-1',
);
expect(getUserMedia).not.toHaveBeenCalled();
expect(mockGetRpgProfileRechargeCenter).not.toHaveBeenCalled();
});
test('desktop account entry uses saved avatar image when available', async () => {
mockDesktopLayout();
const avatarUrl = 'data:image/png;base64,AAAA';
+32 -1
View File
@@ -73,6 +73,7 @@ import {
canUseNativeHostCapability,
type HostFileImportImageResult,
importHostImageFile,
scanHostQrCode,
} from '../../services/host-bridge/hostBridge';
import { shouldShowRechargeEntry } from '../../services/payment/paymentPlatform';
import type { CustomWorldProfile } from '../../types';
@@ -2595,6 +2596,9 @@ export function RpgEntryHomeView({
const [activeWorkSearchKeyword, setActiveWorkSearchKeyword] = useState('');
const [isQrScannerOpen, setIsQrScannerOpen] = useState(false);
const [qrScannerError, setQrScannerError] = useState<string | null>(null);
const [qrScannerMode, setQrScannerMode] = useState<'camera' | 'resultOnly'>(
'camera',
);
const [qrScannerResult, setQrScannerResult] = useState<string | null>(null);
const [selectedCategoryTag, setSelectedCategoryTag] = useState<string | null>(
null,
@@ -3160,7 +3164,32 @@ export function RpgEntryHomeView({
setQrScannerError(null);
setQrScannerResult(null);
setIsQrScannerOpen(true);
setQrScannerMode('camera');
void scanHostQrCode()
.then((result) => {
if (result === null) {
return;
}
if (result) {
setQrScannerMode('resultOnly');
setQrScannerResult(result.value);
setIsQrScannerOpen(true);
return;
}
setQrScannerMode('camera');
setIsQrScannerOpen(true);
})
.catch((error: unknown) => {
setQrScannerMode('resultOnly');
setQrScannerResult(null);
setQrScannerError(
error instanceof Error ? error.message : '扫码失败,请稍后重试',
);
setIsQrScannerOpen(true);
});
};
const clearWorkSearch = () => {
setActiveWorkSearchKeyword('');
@@ -4873,9 +4902,11 @@ export function RpgEntryHomeView({
const qrScannerModal: ReactNode = isQrScannerOpen ? (
<PlatformProfileQrScannerModal
error={qrScannerError}
mode={qrScannerMode}
result={qrScannerResult}
onClose={() => {
setIsQrScannerOpen(false);
setQrScannerMode('camera');
setQrScannerError(null);
setQrScannerResult(null);
}}