@@ -171,6 +188,9 @@ export function ProfileLegalSection({
href={ICP_RECORD_URL}
target="_blank"
rel="noreferrer"
+ onClick={(event) => {
+ void openIcpRecord(event);
+ }}
className="platform-profile-legal-strip__record"
>
{ICP_RECORD_NUMBER}
diff --git a/src/components/rpg-creation-result/RpgCreationAssetDebugPanel.test.tsx b/src/components/rpg-creation-result/RpgCreationAssetDebugPanel.test.tsx
index 54b3afb3..4c83dae2 100644
--- a/src/components/rpg-creation-result/RpgCreationAssetDebugPanel.test.tsx
+++ b/src/components/rpg-creation-result/RpgCreationAssetDebugPanel.test.tsx
@@ -1,7 +1,8 @@
/* @vitest-environment jsdom */
import { render, screen, within } from '@testing-library/react';
-import { expect, test, vi } from 'vitest';
+import userEvent from '@testing-library/user-event';
+import { beforeEach, expect, test, vi } from 'vitest';
import { type CustomWorldProfile } from '../../types';
import { RpgCreationAssetDebugPanel } from './RpgCreationAssetDebugPanel';
@@ -10,6 +11,37 @@ vi.mock('../../services/assetReadUrlService', () => ({
resolveAssetReadUrl: vi.fn(() => new Promise(() => {})),
}));
+vi.mock('../../services/host-bridge/hostBridge', () => ({
+ getHostRuntime: vi.fn(() => ({
+ kind: 'browser',
+ clientType: null,
+ clientRuntime: null,
+ hostShell: null,
+ hostPlatform: null,
+ hostVersion: null,
+ miniProgramEnv: null,
+ })),
+ openHostExternalUrl: vi.fn(async () => false),
+}));
+
+import {
+ getHostRuntime,
+ openHostExternalUrl,
+} from '../../services/host-bridge/hostBridge';
+
+beforeEach(() => {
+ vi.mocked(getHostRuntime).mockReturnValue({
+ kind: 'browser',
+ clientType: null,
+ clientRuntime: null,
+ hostShell: null,
+ hostPlatform: null,
+ hostVersion: null,
+ miniProgramEnv: null,
+ });
+ vi.mocked(openHostExternalUrl).mockResolvedValue(false);
+});
+
function createProfileWithAssets(): CustomWorldProfile {
return {
id: 'world-1',
@@ -173,6 +205,28 @@ test('RPG asset debug panel uses PlatformSubpanel shells for summary and entries
}
});
+test('RPG asset debug panel 在原生 App 中通过宿主打开原图外链', async () => {
+ const user = userEvent.setup();
+ vi.mocked(getHostRuntime).mockReturnValue({
+ kind: 'native_app',
+ clientType: 'native_app',
+ clientRuntime: 'native_app',
+ hostShell: 'tauri_desktop',
+ hostPlatform: 'linux',
+ hostVersion: '0.1.0',
+ miniProgramEnv: null,
+ });
+ vi.mocked(openHostExternalUrl).mockResolvedValue(true);
+
+ render();
+
+ await user.click(screen.getByRole('link', { name: '打开 沈砺主形象' }));
+
+ expect(openHostExternalUrl).toHaveBeenCalledWith({
+ url: '/generated/custom-world/playable.png',
+ });
+});
+
test('RPG asset debug panel uses PlatformEmptyState shell for empty state', () => {
const emptyProfile = {
...createProfileWithAssets(),
diff --git a/src/components/rpg-creation-result/RpgCreationAssetDebugPanel.tsx b/src/components/rpg-creation-result/RpgCreationAssetDebugPanel.tsx
index 645cd172..65a956e3 100644
--- a/src/components/rpg-creation-result/RpgCreationAssetDebugPanel.tsx
+++ b/src/components/rpg-creation-result/RpgCreationAssetDebugPanel.tsx
@@ -1,6 +1,11 @@
+import type { MouseEvent } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { resolveAssetReadUrl } from '../../services/assetReadUrlService';
+import {
+ getHostRuntime,
+ openHostExternalUrl,
+} from '../../services/host-bridge/hostBridge';
import type { CustomWorldProfile } from '../../types';
import { PlatformEmptyState } from '../common/PlatformEmptyState';
import { PlatformPillBadge } from '../common/PlatformPillBadge';
@@ -162,6 +167,22 @@ export function RpgCreationAssetDebugPanel({
Record
>({});
+ const openAssetReadUrl = (
+ event: MouseEvent,
+ url: string,
+ ) => {
+ if (getHostRuntime().kind !== 'native_app') {
+ return;
+ }
+
+ event.preventDefault();
+ void openHostExternalUrl({ url }).then((opened) => {
+ if (!opened) {
+ window.open(url, '_blank', 'noreferrer');
+ }
+ });
+ };
+
useEffect(() => {
if (assetDebugEntries.length === 0) {
setAssetDebugStatusMap({});
@@ -295,6 +316,9 @@ export function RpgCreationAssetDebugPanel({
target="_blank"
rel="noreferrer"
aria-label={`打开 ${entry.label}`}
+ onClick={(event) => {
+ void openAssetReadUrl(event, readUrl);
+ }}
className="text-xs font-semibold text-amber-200 underline decoration-white/20 underline-offset-2"
>
打开原图
diff --git a/src/services/host-bridge/hostBridge.test.ts b/src/services/host-bridge/hostBridge.test.ts
index 5f81ee41..3daa1b45 100644
--- a/src/services/host-bridge/hostBridge.test.ts
+++ b/src/services/host-bridge/hostBridge.test.ts
@@ -9,6 +9,7 @@ import {
getNativeAppHostRuntime,
isWechatMiniProgramWebViewRuntime,
navigateHostNativePage,
+ openHostExternalUrl,
openHostShare,
openHostShareGrid,
openWechatMiniProgramShareGridPage,
@@ -317,6 +318,9 @@ describe('hostBridge', () => {
await expect(requestHostHapticsImpact({ style: 'medium' })).resolves.toBe(
true,
);
+ await expect(
+ openHostExternalUrl({ url: '/works/detail?work=PZ-1' }),
+ ).resolves.toBe(true);
await expect(setHostAppTitle({ title: ' 拼图 - 陶泥儿 ' })).resolves.toBe(
true,
);
@@ -364,6 +368,14 @@ describe('hostBridge', () => {
},
}),
});
+ expect(invoke).toHaveBeenCalledWith('host_bridge_request', {
+ request: expect.objectContaining({
+ method: 'app.openExternalUrl',
+ payload: {
+ url: `${window.location.origin}/works/detail?work=PZ-1`,
+ },
+ }),
+ });
expect(invoke).toHaveBeenCalledWith('host_bridge_request', {
request: expect.objectContaining({
method: 'app.setTitle',
@@ -418,6 +430,12 @@ describe('hostBridge', () => {
await expect(requestHostHapticsImpact({ style: 'light' })).resolves.toBe(
false,
);
+ await expect(
+ openHostExternalUrl({ url: 'https://beian.miit.gov.cn/' }),
+ ).resolves.toBe(false);
+ await expect(
+ openHostExternalUrl({ url: 'javascript:alert(1)' }),
+ ).resolves.toBe(false);
await expect(setHostAppTitle({ title: '拼图 - 陶泥儿' })).resolves.toBe(
false,
);
diff --git a/src/services/host-bridge/hostBridge.ts b/src/services/host-bridge/hostBridge.ts
index 74dd1026..04f5ed6e 100644
--- a/src/services/host-bridge/hostBridge.ts
+++ b/src/services/host-bridge/hostBridge.ts
@@ -4,8 +4,12 @@ import type {
HapticsImpactPayload,
HostBridgeMethod,
HostBridgeRuntimeResult,
+ OpenExternalUrlPayload,
ShareOpenPayload,
} from '../../../packages/shared/src/contracts/hostBridge';
+import {
+ normalizeHostBridgeExternalUrl,
+} from '../../../packages/shared/src/contracts/hostBridge';
import type {
WechatMiniProgramPayParams,
WechatMiniProgramVirtualPayParams,
@@ -75,6 +79,8 @@ export type HostAppTitleRequest = {
export type HostShareOpenRequest = ShareOpenPayload;
+export type HostExternalUrlRequest = OpenExternalUrlPayload;
+
function isUnsupportedHostBridgeError(error: unknown) {
return (
error instanceof Error &&
@@ -387,6 +393,25 @@ function buildAbsoluteUrl(value: string) {
return new URL(value, window.location.origin).href;
}
+function normalizeHostExternalUrl(url: string) {
+ const trimmedUrl = url.trim();
+ if (!trimmedUrl) {
+ return null;
+ }
+
+ if (typeof window === 'undefined') {
+ return normalizeHostBridgeExternalUrl(trimmedUrl);
+ }
+
+ try {
+ return normalizeHostBridgeExternalUrl(
+ new URL(trimmedUrl, window.location.origin).toString(),
+ );
+ } catch {
+ return null;
+ }
+}
+
export function canUseHostShareGrid(context: HostRuntimeContext = {}) {
return getHostRuntime(context).kind === 'wechat_mini_program';
}
@@ -461,6 +486,25 @@ export async function openHostShare(params: HostShareOpenRequest) {
}
}
+export async function openHostExternalUrl({ url }: HostExternalUrlRequest) {
+ if (getHostRuntime().kind !== 'native_app') {
+ return false;
+ }
+
+ const normalizedUrl = normalizeHostExternalUrl(url);
+ if (!normalizedUrl) {
+ return false;
+ }
+
+ try {
+ return await requestNativeHostBoolean('app.openExternalUrl', {
+ url: normalizedUrl,
+ });
+ } catch {
+ return false;
+ }
+}
+
export function postWechatMiniProgramMessage(message: unknown) {
return setHostShareTarget(message);
}