-
-
-
-
+ {mode === 'camera' ? (
+
+
+
+
+ ) : null}
{result ? (
{
+ 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';
diff --git a/src/components/rpg-entry/RpgEntryHomeView.tsx b/src/components/rpg-entry/RpgEntryHomeView.tsx
index 1c8e9c353..fbbc645ce 100644
--- a/src/components/rpg-entry/RpgEntryHomeView.tsx
+++ b/src/components/rpg-entry/RpgEntryHomeView.tsx
@@ -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(null);
+ const [qrScannerMode, setQrScannerMode] = useState<'camera' | 'resultOnly'>(
+ 'camera',
+ );
const [qrScannerResult, setQrScannerResult] = useState(null);
const [selectedCategoryTag, setSelectedCategoryTag] = useState(
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 ? (
{
setIsQrScannerOpen(false);
+ setQrScannerMode('camera');
setQrScannerError(null);
setQrScannerResult(null);
}}
diff --git a/src/services/host-bridge/hostBridge.test.ts b/src/services/host-bridge/hostBridge.test.ts
index 4a8c20bed..4cfba3d68 100644
--- a/src/services/host-bridge/hostBridge.test.ts
+++ b/src/services/host-bridge/hostBridge.test.ts
@@ -34,6 +34,7 @@ import {
requestWechatMiniProgramPhoneLogin,
resetHostRuntimeCacheForTest,
resolveHostRuntime,
+ scanHostQrCode,
setHostAppBadgeCount,
setHostAppTitle,
setHostShareTarget,
@@ -713,6 +714,11 @@ describe('hostBridge', () => {
? {
text: '作品号 PZ-1',
}
+ : request.method === 'scanner.scanQrCode'
+ ? {
+ value: ' https://app.genarrative.world/works/detail?work=PZ-1 ',
+ format: 'qr_code',
+ }
: request.method === 'file.importImage'
? {
action: 'selected',
@@ -770,6 +776,7 @@ describe('hostBridge', () => {
'file.exportImage',
'file.importImage',
'file.captureImage',
+ 'scanner.scanQrCode',
'file.importAudio',
'file.exportAudio',
'file.imageDropped',
@@ -848,6 +855,10 @@ describe('hostBridge', () => {
mimeType: 'image/jpeg',
bytes: 6,
});
+ await expect(scanHostQrCode()).resolves.toEqual({
+ value: 'https://app.genarrative.world/works/detail?work=PZ-1',
+ format: 'qr_code',
+ });
await expect(importHostTextFile()).resolves.toEqual({
action: 'selected',
fileName: '剧情.md',
@@ -983,6 +994,12 @@ describe('hostBridge', () => {
timeoutMs: 30000,
}),
});
+ expect(invoke).toHaveBeenCalledWith('host_bridge_request', {
+ request: expect.objectContaining({
+ method: 'scanner.scanQrCode',
+ timeoutMs: 60000,
+ }),
+ });
expect(invoke).toHaveBeenCalledWith('host_bridge_request', {
request: expect.objectContaining({
method: 'file.importText',
@@ -1072,6 +1089,7 @@ describe('hostBridge', () => {
}),
).resolves.toBe(false);
await expect(importHostImageFile()).resolves.toBe(false);
+ await expect(scanHostQrCode()).resolves.toBe(false);
await expect(importHostTextFile()).resolves.toBe(false);
await expect(importHostAudioFile()).resolves.toBe(false);
await expect(
@@ -1106,6 +1124,7 @@ describe('hostBridge', () => {
}),
).resolves.toBe(false);
await expect(importHostImageFile()).resolves.toBe(false);
+ await expect(scanHostQrCode()).resolves.toBe(false);
await expect(importHostTextFile()).resolves.toBe(false);
await expect(importHostAudioFile()).resolves.toBe(false);
await expect(
@@ -1599,6 +1618,66 @@ describe('hostBridge', () => {
await expect(importHostImageFile()).resolves.toBe(false);
});
+ test('原生 App 宿主取消扫码时不触发 H5 摄像头回退', async () => {
+ const invoke = vi.fn(
+ async (_command: string, args?: Record) => {
+ const request = (args as { request: { id: string } }).request;
+ return {
+ bridge: 'GenarrativeHostBridge',
+ version: 1,
+ id: request.id,
+ ok: false,
+ error: {
+ code: 'cancelled',
+ message: 'qr scan cancelled',
+ },
+ };
+ },
+ );
+ window.history.replaceState(
+ null,
+ '',
+ nativeAppPath(['scanner.scanQrCode']),
+ );
+ window.__TAURI__ = {
+ core: {
+ invoke: asTauriInvoke(invoke),
+ },
+ };
+
+ await expect(scanHostQrCode()).resolves.toBeNull();
+ });
+
+ test('原生 App 宿主返回非法扫码内容时回退为 false', async () => {
+ const invoke = vi.fn(
+ async (_command: string, args?: Record) => {
+ const request = (args as { request: { id: string } }).request;
+ return {
+ bridge: 'GenarrativeHostBridge',
+ version: 1,
+ id: request.id,
+ ok: true,
+ result: {
+ value: 'bad\nvalue',
+ format: 'qr_code',
+ },
+ };
+ },
+ );
+ window.history.replaceState(
+ null,
+ '',
+ nativeAppPath(['scanner.scanQrCode']),
+ );
+ window.__TAURI__ = {
+ core: {
+ invoke: asTauriInvoke(invoke),
+ },
+ };
+
+ await expect(scanHostQrCode()).resolves.toBe(false);
+ });
+
test('原生 App 宿主未声明拍摄图片能力时回退为 false', async () => {
window.history.replaceState(
null,
diff --git a/src/services/host-bridge/hostBridge.ts b/src/services/host-bridge/hostBridge.ts
index 37a8e91e2..18a72bc21 100644
--- a/src/services/host-bridge/hostBridge.ts
+++ b/src/services/host-bridge/hostBridge.ts
@@ -22,6 +22,7 @@ import type {
NavigationCanGoBackEventPayload,
NetworkStatusResult,
OpenExternalUrlPayload,
+ ScannerScanQrCodeResult,
SetBadgeCountPayload,
ShareOpenPayload,
} from '../../../packages/shared/src/contracts/hostBridge';
@@ -34,6 +35,7 @@ import {
normalizeHostBridgeExternalUrl,
normalizeHostBridgeLifecycleState,
normalizeHostBridgeLocalNotification,
+ normalizeHostBridgeQrCodeValue,
} from '../../../packages/shared/src/contracts/hostBridge';
import type {
WechatMiniProgramPayParams,
@@ -107,6 +109,8 @@ export type HostFileImportTextResult = FileImportTextResult;
export type HostFileImportAudioResult = FileImportAudioResult;
+export type HostScannerScanQrCodeResult = ScannerScanQrCodeResult;
+
export type HostClipboardWriteTextRequest = {
text: string;
};
@@ -901,6 +905,30 @@ export async function captureHostImageFile() {
}
}
+export async function scanHostQrCode() {
+ if (!canUseNativeHostCapability('scanner.scanQrCode')) {
+ return false;
+ }
+
+ try {
+ const result =
+ await requestNativeAppHostBridge(
+ 'scanner.scanQrCode',
+ undefined,
+ { timeoutMs: 60000 },
+ );
+ return normalizeHostBridgeQrCodeValue(result?.value) ?? false;
+ } catch (error) {
+ if (error instanceof Error && error.name === 'cancelled') {
+ return null;
+ }
+ if (isUnsupportedHostBridgeError(error)) {
+ return false;
+ }
+ throw error;
+ }
+}
+
const HOST_BRIDGE_TEXT_MIME_TYPES = new Set([
'text/plain',
'text/markdown',
diff --git a/src/services/host-bridge/nativeAppHostBridge.ts b/src/services/host-bridge/nativeAppHostBridge.ts
index 91d247736..1cb0a22f3 100644
--- a/src/services/host-bridge/nativeAppHostBridge.ts
+++ b/src/services/host-bridge/nativeAppHostBridge.ts
@@ -12,7 +12,7 @@ import {
} from '../../../packages/shared/src/contracts/hostBridge';
const DEFAULT_NATIVE_APP_BRIDGE_TIMEOUT_MS = 8000;
-const MAX_NATIVE_APP_BRIDGE_TIMEOUT_MS = 30000;
+const MAX_NATIVE_APP_BRIDGE_TIMEOUT_MS = 60000;
type NativeAppBridgeWindow = Window & {
ReactNativeWebView?: {