feat(iframe-bridge): implement iframe communication bridge and language sync

This commit is contained in:
2025-11-14 20:07:08 +08:00
parent 4356813820
commit 034c190373
15 changed files with 469 additions and 160 deletions

View File

@@ -0,0 +1,141 @@
# @teres/iframe-bridge
一个基于 [Penpal](https://github.com/Aaronius/penpal) 的轻量 iframe 通信桥帮助宿主页面与子应用iframe安全、稳定地进行双向方法调用与状态同步。
## 为什么需要它
- 将跨项目的交互(如语言切换、导航、关闭、就绪通知)抽象为清晰的 API。
- 在 Monorepo 或多项目场景下,提供“宿主 ↔ 子应用”的统一通信约定。
- 屏蔽 Penpal 细节,专注业务方法与类型定义。
## 安装与版本
- 已在工作空间内作为包使用(`packages/iframe-bridge`)。
- 依赖:`penpal@^6.2.1`
## 目录结构
```
packages/iframe-bridge/
├── package.json
├── README.md
└── src/
├── index.ts # 入口聚合与导出(不含业务逻辑)
├── types.ts # 对外类型HostApi、ChildApi
├── path.ts # 路径转换与是否嵌入判断
├── client.ts # 子端到父端的连接与客户端方法
├── host.ts # 宿主侧创建与管理与子端的连接
└── logger.ts # 统一日志工具
```
## 核心概念
- Host宿主包含 `iframe` 的父页面。
- Child子端被嵌入在 `iframe` 中的子应用。子端暴露方法给宿主调用。
- Penpal在宿主与子端之间建立安全的双向 RPC方法调用连接。
## 快速上手
### 宿主侧Host示例创建连接并缓存子端引用
```ts
import { createPenpalHostBridge } from '@teres/iframe-bridge';
// iframeRef 指向你页面中的 <iframe>
const { child, destroy } = await createPenpalHostBridge({
iframe: iframeRef.current!,
methods: {
// 暴露给“子端”可调用的宿主方法
navigate: (path: string) => {
// 宿主导航逻辑
},
close: () => {
// 关闭或返回逻辑
},
agentReady: (agentId?: string) => {
// 子端就绪通知处理
},
},
});
// child 是一个 Promise解析后可拿到子端暴露的方法例如 changeLanguage
await child; // 也可以缓存后续使用
// 组件卸载或需要重建连接时
await destroy();
```
在你的项目中(如 `src/pages/ragflow/iframe.tsx`)通常会把 `child` 引用存到一个管理器,供语言切换等场景调用。
### 子端侧Child示例暴露方法并与宿主交互
```ts
import Bridge from '@teres/iframe-bridge';
// 在子应用初始化时调用(例如根组件 useEffect 中)
Bridge.initChildBridge({
// 暴露给“宿主”可调用的子端方法
methods: {
changeLanguage: async (lang: string) => {
// 你的 i18n 切换逻辑,例如:
// await i18n.changeLanguage(lang);
},
},
});
// 主动与宿主交互(可选)
Bridge.clientReady();
Bridge.clientNavigate('/some/path');
Bridge.clientClose();
```
## API 参考
### 类型types.ts
- `HostApi`
- `navigate(path: string): void`
- `close(): void`
- `agentReady(agentId?: string): void`
- `ChildApi`
- `changeLanguage(lang: string): Promise<void>`
### 路径与嵌入path.ts
- `isEmbedded(): boolean` 判断当前窗口是否运行在 iframe 中(且需要通信)。
- `toHostPath(childPath: string): string` 将子端路径转换为宿主侧路由前缀路径。
- `toChildPath(hostPath: string): string` 将宿主路径映射回子端路径。
### 子端客户端方法client.ts
- `getClientHostApi(): Promise<HostApi>` 建立并缓存与宿主的连接。
- `clientNavigate(path: string): Promise<void>` 请求宿主导航。
- `clientClose(): Promise<void>` 请求宿主关闭或返回。
- `clientReady(agentId?: string): Promise<void>` 通知宿主子端就绪。
- `initChildBridge(options: { methods: Partial<ChildApi> }): Promise<void>` 在子端暴露方法供宿主调用(如 `changeLanguage`)。
### 宿主桥接host.ts
- `createPenpalHostBridge({ iframe, methods })`
- `iframe: HTMLIFrameElement`
- `methods: Partial<HostApi>` 暴露给子端可调用的宿主方法。
- 返回:`{ child: Promise<ChildExposed>, destroy: () => Promise<void> }`
- `child`:解析后可获得子端暴露的方法(如 `changeLanguage`)。
- `destroy`:销毁连接与事件监听。
## 语言联动最佳实践
1. 子端在初始化中通过 `initChildBridge` 暴露 `changeLanguage`
2. 宿主在创建连接后缓存 `child` 引用。
3. 宿主语言切换组件在变更时调用:
```ts
const child = await childPromise; // 取到缓存的子端引用
await child.changeLanguage(nextLang);
```
## 调试与排错
- 确保 iframe 与宿主同源或允许跨源通信Penpal 支持跨源,但需正确 URL
- 连接建立需要子端加载完成;在子端 `initChildBridge` 之前调用子端方法会报错或超时。
- 若需要观察连接过程,可在 `logger.ts` 中启用调试日志或在业务代码中打点。
- 组件卸载时务必调用 `destroy()`,避免内存泄漏或事件残留。
## 设计说明
- 入口 `index.ts` 仅做导出聚合,具体逻辑按职责拆分到 `types/logger/path/client/host`
- 子端侧会缓存一次宿主连接,避免重复握手,提高性能与稳定性。
## 常见问题
- 调用时机过早:请在子端完成初始化(`initChildBridge`)后再由宿主调用子端方法。
- 路径映射:`toHostPath`/`toChildPath` 默认适配当前项目前缀,如需自定义可扩展配置模块。
- 异步方法:所有跨端调用均为异步,注意 `await` 与错误处理。
---
若你需要把路径前缀改为可配置项或扩展更多方法(如主题切换、会话同步),欢迎提 issue 或继续迭代。

View File

@@ -0,0 +1,98 @@
/**
* 子端iframe 内)到宿主的客户端方法集合。
* - 负责与父窗口建立并缓存 Penpal 连接
* - 提供便捷的客户端调用navigate/close/ready
*/
import { connectToParent } from 'penpal';
import type { HostApi, ChildApi } from './types';
import { isEmbedded } from './path';
import { log } from './logger';
// Cached Penpal connections
// 缓存与宿主的连接,以避免重复握手
let clientHostApiPromise: Promise<HostApi> | null = null;
/** Create a Penpal connection from child to parent and cache the Host API. */
/**
* 建立从子端到父端的 Penpal 连接,并返回宿主 HostApi带缓存
* @param options.parentOrigin 可选,宿主的 origin默认使用当前 window.location.origin
*/
export function getClientHostApi(options?: { parentOrigin?: string }): Promise<HostApi> {
if (!isEmbedded()) {
log('getClientHostApi() aborted: not embedded');
return Promise.reject(new Error('Not embedded'));
}
if (!clientHostApiPromise) {
const parentOrigin = options?.parentOrigin ?? window.location.origin;
log('getClientHostApi() connecting to parent', { parentOrigin });
clientHostApiPromise = connectToParent<HostApi>({ parentOrigin }).promise.then((host) => {
log('getClientHostApi() connected', {
hasNavigate: typeof host.navigate === 'function',
hasClose: typeof host.close === 'function',
});
return host;
});
}
return clientHostApiPromise;
}
/**
* 请求宿主进行导航。
* @param to 目标路径(会由宿主自行处理前缀与实际路由规则)
*/
export async function clientNavigate(to: string): Promise<void> {
log('clientNavigate() begin', { to });
const host = await getClientHostApi();
await host.navigate(to);
log('clientNavigate() done', { to });
}
/**
* 请求宿主执行关闭或返回操作。
*/
export async function clientClose(): Promise<void> {
log('clientClose() begin');
const host = await getClientHostApi();
log('clientClose() host api', {
hasClose: typeof host.close === 'function',
});
await host.close();
log('clientClose() done');
}
/**
* 通知宿主:子端已就绪(可携带 agentId 等信息)。
*/
export async function clientReady(payload?: any): Promise<void> {
log('clientReady() begin', { payload });
const host = await getClientHostApi();
const agentReady = host.agentReady;
const hasAgentReady = typeof agentReady === 'function';
log('clientReady() host agentReady', { hasAgentReady });
if (hasAgentReady) await agentReady!(payload);
log('clientReady() done');
}
/**
* 子应用初始化:暴露 ChildApi 给宿主,并获取宿主 HostApi用于后续 clientNavigate、clientClose 等)。
* 如果已调用过 getClientHostApi会复用同一连接。
*/
/**
* 子应用初始化:
* - 暴露 ChildApi 给宿主(如 changeLanguage
* - 并获取宿主 HostApi复用同一连接
*/
export function initChildBridge(params: {
methods?: ChildApi;
parentOrigin?: string;
}) {
const parentOrigin = params.parentOrigin ?? window.location.origin;
log('initChildBridge() start', { parentOrigin, hasMethods: !!params.methods });
const connection = connectToParent<HostApi>({ parentOrigin, methods: params.methods as any });
// 设置并复用 clientHostApiPromise
clientHostApiPromise = connection.promise;
connection.promise
.then(() => log('initChildBridge() parent connected'))
.catch((err) => log('initChildBridge() connect error', err));
return connection.promise;
}

View File

@@ -0,0 +1,38 @@
/**
* 宿主父窗口与子端iframe之间的桥接创建。
* - 绑定 iframe 元素,暴露宿主方法给子端调用
* - 返回 child Promise解析为子端暴露的方法与 destroy 销毁函数
*/
import { connectToChild } from 'penpal';
import type { HostApi, ChildApi } from './types';
import { log } from './logger';
/** Establish Penpal host bridge bound to an iframe and expose Host API to child. */
/**
* 建立宿主侧 Penpal 桥接并暴露 HostApi。
* @param params.iframe 目标 iframe 元素
* @param params.origin 可选,子端 origin默认当前 window.location.origin
* @param params.methods 宿主暴露给子端的方法集合
* @returns `{ child, destroy }`
* - `child`: Promise<ChildApiResolved>,解析后可直接调用子端暴露的方法(如 changeLanguage
* - `destroy`: 断开连接与清理监听器
*/
export function createPenpalHostBridge(params: {
iframe: HTMLIFrameElement;
origin?: string;
methods: HostApi;
}) {
const childOrigin = params.origin ?? window.location.origin;
log('createPenpalHostBridge() start', { childOrigin });
const connection = connectToChild<ChildApi>({ iframe: params.iframe, childOrigin, methods: params.methods as any });
connection.promise
.then(() => log('createPenpalHostBridge() child connected'))
.catch((err) => log('createPenpalHostBridge() connect error', err));
return {
child: connection.promise,
destroy: () => {
log('createPenpalHostBridge() destroy');
return connection.destroy?.();
},
};
}

View File

@@ -1,149 +1,31 @@
// 入口文件:仅聚合与导出 API不承载具体逻辑
export type { HostApi, ChildApi } from './types';
export { isEmbedded, toHostPath, toChildPath } from './path';
export {
getClientHostApi,
clientNavigate,
clientClose,
clientReady,
initChildBridge,
} from './client';
export { createPenpalHostBridge } from './host';
// 默认导出一个聚合对象:
// - 兼容某些只识别 default 的打包方案或联邦场景
// - 同时保留命名导出,建议优先使用命名导出
import { isEmbedded, toHostPath, toChildPath } from './path';
import {
getClientHostApi,
clientNavigate,
clientClose,
clientReady,
initChildBridge,
} from './client';
import { createPenpalHostBridge } from './host';
/** /**
* @teres/iframe-bridge * 默认聚合导出对象:包含全部对外 API。
* Penpal-based utilities for host ↔ iframe communication,
* plus minimal helpers for embed detection and path conversion.
*/ */
import { connectToChild, connectToParent } from 'penpal';
// Penpal RPC API definitions
export interface HostApi {
navigate: (to: string) => void;
close: () => void;
agentReady?: (payload?: any) => void;
}
export interface ChildApi {
navigate?: (to: string) => void;
close?: () => void;
ready?: (payload?: any) => void;
}
// Unified logger for debugging host/child interactions
const LOG_PREFIX = '[@teres/iframe-bridge]';
function log(...args: any[]) {
try {
// eslint-disable-next-line no-console
console.log(LOG_PREFIX, ...args);
} catch {}
}
function safePathname(): string {
try {
const p = window.location?.pathname || '';
log('safePathname()', p);
return p;
} catch {
log('safePathname() error, return empty pathname');
return '';
}
}
function isInIframe(): boolean {
try {
const inIframe = window.self !== window.top;
log('isInIframe()', inIframe);
return inIframe;
} catch {
// Cross-origin access throws, treat as embedded
log('isInIframe() cross-origin error, treat as embedded');
return true;
}
}
export function isEmbedded(): boolean {
const path = safePathname();
const inIframe = isInIframe();
const embedded = path.includes('/route-ragflow') || inIframe;
log('isEmbedded()', { path, inIframe, embedded });
return embedded;
}
/** Add "/ragflow" prefix when targeting host routes. */
export function toHostPath(to: string): string {
const normalized = to.startsWith('/') ? to : `/${to}`;
const target = normalized.startsWith('/route-ragflow') ? normalized : `/ragflow${normalized}`;
log('toHostPath()', { input: to, normalized, target });
return target;
}
/** Remove leading "/route-ragflow" prefix when targeting child routes. */
export function toChildPath(to: string): string {
const target = to.replace(/^\/route-ragflow/, '') || '/';
log('toChildPath()', { input: to, target });
return target;
}
// Cached Penpal connections
let clientHostApiPromise: Promise<HostApi> | null = null;
/** Create a Penpal connection from child to parent and cache the Host API. */
export function getClientHostApi(options?: { parentOrigin?: string }): Promise<HostApi> {
if (!isEmbedded()) {
log('getClientHostApi() aborted: not embedded');
return Promise.reject(new Error('Not embedded'));
}
if (!clientHostApiPromise) {
const parentOrigin = options?.parentOrigin ?? window.location.origin;
log('getClientHostApi() connecting to parent', { parentOrigin });
clientHostApiPromise = connectToParent<HostApi>({ parentOrigin }).promise.then((host) => {
log('getClientHostApi() connected', {
hasNavigate: typeof host.navigate === 'function',
hasClose: typeof host.close === 'function',
});
return host;
});
}
return clientHostApiPromise;
}
export async function clientNavigate(to: string): Promise<void> {
log('clientNavigate() begin', { to });
const host = await getClientHostApi();
await host.navigate(to);
log('clientNavigate() done', { to });
}
export async function clientClose(): Promise<void> {
log('clientClose() begin');
const host = await getClientHostApi();
log('clientClose() host api', {
hasClose: typeof host.close === 'function',
});
await host.close();
log('clientClose() done');
}
export async function clientReady(payload?: any): Promise<void> {
log('clientReady() begin', { payload });
const host = await getClientHostApi();
const agentReady = host.agentReady;
const hasAgentReady = typeof agentReady === 'function';
log('clientReady() host agentReady', { hasAgentReady });
if (hasAgentReady) await agentReady!(payload);
log('clientReady() done');
}
/** Establish Penpal host bridge bound to an iframe and expose Host API to child. */
export function createPenpalHostBridge(params: {
iframe: HTMLIFrameElement;
origin?: string;
methods: HostApi;
}) {
const childOrigin = params.origin ?? window.location.origin;
log('createPenpalHostBridge() start', { childOrigin });
const connection = connectToChild<ChildApi>({ iframe: params.iframe, childOrigin, methods: params.methods as any });
connection.promise.then(() => log('createPenpalHostBridge() child connected')).catch((err) => log('createPenpalHostBridge() connect error', err));
return {
child: connection.promise,
destroy: () => {
log('createPenpalHostBridge() destroy');
return connection.destroy?.();
},
};
}
// Provide a default export with all APIs to maximize compatibility with bundlers
// that prefer default-only federation or mis-handle named ESM exports in dev.
const bridge = { const bridge = {
isEmbedded, isEmbedded,
toHostPath, toHostPath,
@@ -153,6 +35,7 @@ const bridge = {
clientClose, clientClose,
clientReady, clientReady,
createPenpalHostBridge, createPenpalHostBridge,
initChildBridge,
}; };
export default bridge; export default bridge;

View File

@@ -0,0 +1,15 @@
/**
* 统一日志前缀,便于在控制台筛选相关输出。
*/
export const LOG_PREFIX = '[@teres/iframe-bridge]';
/**
* 简单的日志输出封装:
* - 在某些环境下 console 可能被拦截,故使用 try/catch。
*/
export function log(...args: any[]) {
try {
// eslint-disable-next-line no-console
console.log(LOG_PREFIX, ...args);
} catch {}
}

View File

@@ -0,0 +1,69 @@
/**
* 路径与嵌入状态相关的工具函数:
* - 判断是否处于 iframe 场景(需进行跨窗口通信)
* - 提供宿主/子端路径的前缀转换
*/
import { log } from './logger';
/**
* 安全获取当前窗口的 pathname避免跨域等异常导致报错。
*/
function safePathname(): string {
try {
const p = window.location?.pathname || '';
log('safePathname()', p);
return p;
} catch {
log('safePathname() error, return empty pathname');
return '';
}
}
/**
* 判断是否运行在 iframe 中。
* 如因跨域访问 window.top 抛错,则视为在 iframe 中embedded
*/
function isInIframe(): boolean {
try {
const inIframe = window.self !== window.top;
log('isInIframe()', inIframe);
return inIframe;
} catch {
// Cross-origin access throws, treat as embedded
log('isInIframe() cross-origin error, treat as embedded');
return true;
}
}
/**
* 是否处于“嵌入模式”:
* - 存在 '/route-ragflow' 路径标识 或
* - 运行在 iframe 中
*/
export function isEmbedded(): boolean {
const path = safePathname();
const inIframe = isInIframe();
const embedded = path.includes('/route-ragflow') || inIframe;
log('isEmbedded()', { path, inIframe, embedded });
return embedded;
}
/**
* 将子端路由转换为宿主路由(添加宿主前缀)。
* 默认会为路径添加 '/ragflow' 前缀。
*/
export function toHostPath(to: string): string {
const normalized = to.startsWith('/') ? to : `/${to}`;
const target = normalized.startsWith('/route-ragflow') ? normalized : `/ragflow${normalized}`;
log('toHostPath()', { input: to, normalized, target });
return target;
}
/**
* 将宿主路由转换回子端路由(去除 '/route-ragflow' 前缀)。
*/
export function toChildPath(to: string): string {
const target = to.replace(/^\/route-ragflow/, '') || '/';
log('toChildPath()', { input: to, target });
return target;
}

View File

@@ -0,0 +1,22 @@
/**
* Penpal RPC API 定义(宿主与子端共享的类型)。
*/
export interface HostApi {
/** 宿主导航到指定路径 */
navigate: (to: string) => void;
/** 宿主执行关闭或返回动作 */
close: () => void;
/** 接收子端就绪通知(可携带 agentId 等信息) */
agentReady?: (payload?: any) => void;
}
export interface ChildApi {
/** 子端主动请求宿主导航(若需要) */
navigate?: (to: string) => void;
/** 子端主动请求宿主关闭(若需要) */
close?: () => void;
/** 子端向宿主发送就绪事件(若需要) */
ready?: (payload?: any) => void;
/** 子应用语言切换(例如 'zh' | 'en' | 'zh-TRADITIONAL' */
changeLanguage?: (lng: string) => Promise<void> | void;
}

11
pnpm-lock.yaml generated
View File

@@ -536,9 +536,6 @@ importers:
'@types/react-syntax-highlighter': '@types/react-syntax-highlighter':
specifier: ^15.5.11 specifier: ^15.5.11
version: 15.5.13 version: 15.5.13
'@types/testing-library__jest-dom':
specifier: ^6.0.0
version: 6.0.0
'@types/uuid': '@types/uuid':
specifier: ^9.0.8 specifier: ^9.0.8
version: 9.0.8 version: 9.0.8
@@ -4253,10 +4250,6 @@ packages:
'@types/stylis@4.2.7': '@types/stylis@4.2.7':
resolution: {integrity: sha512-VgDNokpBoKF+wrdvhAAfS55OMQpL6QRglwTwNC3kIgBrzZxA4WsFj+2eLfEA/uMUDzBcEhYmjSbwQakn/i3ajA==} resolution: {integrity: sha512-VgDNokpBoKF+wrdvhAAfS55OMQpL6QRglwTwNC3kIgBrzZxA4WsFj+2eLfEA/uMUDzBcEhYmjSbwQakn/i3ajA==}
'@types/testing-library__jest-dom@6.0.0':
resolution: {integrity: sha512-bnreXCgus6IIadyHNlN/oI5FfX4dWgvGhOPvpr7zzCYDGAPIfvyIoAozMBINmhmsVuqV0cncejF2y5KC7ScqOg==}
deprecated: This is a stub types definition. @testing-library/jest-dom provides its own type definitions, so you do not need this installed.
'@types/tough-cookie@4.0.5': '@types/tough-cookie@4.0.5':
resolution: {integrity: sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==} resolution: {integrity: sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==}
@@ -16122,10 +16115,6 @@ snapshots:
'@types/stylis@4.2.7': {} '@types/stylis@4.2.7': {}
'@types/testing-library__jest-dom@6.0.0':
dependencies:
'@testing-library/jest-dom': 6.9.1
'@types/tough-cookie@4.0.5': {} '@types/tough-cookie@4.0.5': {}
'@types/trusted-types@2.0.7': '@types/trusted-types@2.0.7':

View File

@@ -24,6 +24,8 @@ import { SidebarProvider } from './components/ui/sidebar';
import { TooltipProvider } from './components/ui/tooltip'; import { TooltipProvider } from './components/ui/tooltip';
import { ThemeEnum } from './constants/common'; import { ThemeEnum } from './constants/common';
import storage from './utils/authorization-util'; import storage from './utils/authorization-util';
import Bridge from '@teres/iframe-bridge';
import { LanguageAbbreviationMap, LanguageTranslationMap } from './constants/common';
dayjs.extend(customParseFormat); dayjs.extend(customParseFormat);
dayjs.extend(advancedFormat); dayjs.extend(advancedFormat);
@@ -99,6 +101,21 @@ const RootProvider = ({ children }: React.PropsWithChildren) => {
if (lng) { if (lng) {
i18n.changeLanguage(lng); i18n.changeLanguage(lng);
} }
// 在嵌入模式下,向父应用暴露 changeLanguage 方法以支持跨项目语言同步
if (Bridge.isEmbedded()) {
Bridge.initChildBridge({
methods: {
changeLanguage: (nextLng: string) => {
// 兼容传入缩写en/zh/...或显示名English/Chinese/...
const isAbbr = !!LanguageAbbreviationMap[nextLng as keyof typeof LanguageAbbreviationMap];
const resolved = isAbbr
? nextLng
: LanguageTranslationMap[nextLng as keyof typeof LanguageTranslationMap] || nextLng;
i18n.changeLanguage(resolved);
},
},
});
}
}, []); }, []);
return ( return (

View File

@@ -21,7 +21,7 @@ const ThemeProviderContext = createContext<ThemeProviderState>(initialState);
export function ThemeProvider({ export function ThemeProvider({
children, children,
defaultTheme = ThemeEnum.Dark, defaultTheme = ThemeEnum.Light,
storageKey = 'vite-ui-theme', storageKey = 'vite-ui-theme',
...props ...props
}: ThemeProviderProps) { }: ThemeProviderProps) {

View File

@@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next';
import { Button, Menu, MenuItem } from '@mui/material'; import { Button, Menu, MenuItem } from '@mui/material';
import { useLanguageSetting } from '@/hooks/setting-hooks'; import { useLanguageSetting } from '@/hooks/setting-hooks';
import logger from '@/utils/logger'; import logger from '@/utils/logger';
import { changeChildLanguage } from '@/iframe/bridgeManager';
import { LanguageAbbreviation, LanguageAbbreviationMap } from '@/constants/common'; import { LanguageAbbreviation, LanguageAbbreviationMap } from '@/constants/common';
interface LanguageSwitcherProps { interface LanguageSwitcherProps {
@@ -27,6 +28,8 @@ const LanguageSwitcher: React.FC<LanguageSwitcherProps> = ({ textColor = '#fff'
try { try {
await changeLanguage(language); await changeLanguage(language);
i18n.changeLanguage(language); i18n.changeLanguage(language);
// 同步通知 iframe 子应用切换语言(如果存在)
changeChildLanguage(language);
handleClose(); handleClose();
} catch (error) { } catch (error) {
logger.error('更新语言设置失败:', error); logger.error('更新语言设置失败:', error);

1
src/hooks/logic-hooks.ts Normal file
View File

@@ -0,0 +1 @@
// logic-hooks 逻辑钩子

View File

@@ -0,0 +1,23 @@
import type { ChildApi } from '@teres/iframe-bridge';
import logger from '@/utils/logger';
let childPromise: Promise<ChildApi> | null = null;
export function setChildPromise(promise: Promise<ChildApi>) {
childPromise = promise;
}
export function clearChildPromise() {
childPromise = null;
}
export async function changeChildLanguage(lng: string) {
if (!childPromise) return;
try {
const child = await childPromise;
// @ts-ignore
await child?.changeLanguage?.(lng);
} catch (err) {
logger.warn('changeChildLanguage failed', err);
}
}

View File

@@ -1,6 +1,7 @@
import { useEffect, useMemo, useRef } from 'react'; import { useEffect, useMemo, useRef } from 'react';
import { useNavigate, useParams } from 'react-router-dom'; import { useNavigate, useParams } from 'react-router-dom';
import { createPenpalHostBridge } from '@teres/iframe-bridge'; import { createPenpalHostBridge } from '@teres/iframe-bridge';
import { setChildPromise, clearChildPromise } from '@/iframe/bridgeManager';
import logger from '@/utils/logger'; import logger from '@/utils/logger';
export default function RagflowAgentPage() { export default function RagflowAgentPage() {
@@ -14,7 +15,7 @@ export default function RagflowAgentPage() {
useEffect(() => { useEffect(() => {
const el = iframeRef.current; const el = iframeRef.current;
if (!el) return; if (!el) return;
const { destroy } = createPenpalHostBridge({ const { destroy, child } = createPenpalHostBridge({
iframe: el, iframe: el,
methods: { methods: {
navigate: (to: string) => navigate(to), navigate: (to: string) => navigate(to),
@@ -24,7 +25,11 @@ export default function RagflowAgentPage() {
}, },
}, },
}); });
return () => destroy(); setChildPromise(child);
return () => {
clearChildPromise();
destroy();
};
}, [navigate]); }, [navigate]);
// 如需兼容旧的 postMessage 事件,可保留以下监听;为了纯 Penpal此处移除 // 如需兼容旧的 postMessage 事件,可保留以下监听;为了纯 Penpal此处移除

View File

@@ -1,6 +1,7 @@
import { useEffect, useRef } from 'react'; import { useEffect, useRef } from 'react';
import { useLocation, useNavigate } from 'react-router-dom'; import { useLocation, useNavigate } from 'react-router-dom';
import { createPenpalHostBridge, toChildPath } from '@teres/iframe-bridge'; import { createPenpalHostBridge, toChildPath } from '@teres/iframe-bridge';
import { setChildPromise, clearChildPromise } from '@/iframe/bridgeManager';
export default function RagflowIframePage() { export default function RagflowIframePage() {
const location = useLocation(); const location = useLocation();
@@ -13,7 +14,7 @@ export default function RagflowIframePage() {
useEffect(() => { useEffect(() => {
const el = iframeRef.current; const el = iframeRef.current;
if (!el) return; if (!el) return;
const { destroy } = createPenpalHostBridge({ const { destroy, child } = createPenpalHostBridge({
iframe: el, iframe: el,
methods: { methods: {
navigate: (to: string) => navigate(to), navigate: (to: string) => navigate(to),
@@ -23,7 +24,11 @@ export default function RagflowIframePage() {
}, },
}, },
}); });
return () => destroy(); setChildPromise(child);
return () => {
clearChildPromise();
destroy();
};
}, [navigate, src]); }, [navigate, src]);
return ( return (