From 88fed0744ca643c7ce1be8bad9be258af2384ded Mon Sep 17 00:00:00 2001 From: kuaifan Date: Fri, 12 Jun 2026 07:02:35 +0000 Subject: [PATCH] =?UTF-8?q?feat(ai-assistant):=20=E9=A1=B5=E9=9D=A2?= =?UTF-8?q?=E6=93=8D=E4=BD=9C=E7=A9=BF=E9=80=8F=E5=88=B0=E5=BD=93=E5=89=8D?= =?UTF-8?q?=E5=BE=AE=E5=BA=94=E7=94=A8=20iframe=20=E5=86=85=E9=83=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 active-context.js:按 microApps 解析最前的同源微应用 iframe(src 匹配 + contentDocument 同源探测),给出 doc/frameKey - 采集器/执行器从只认主 document 泛化为按 el.ownerDocument 自适应,覆盖主文档与 iframe;视口与事件构造器取元素所在 window - operation-module 加 scope(auto/main/app)、跨源/未就绪降级、frame 标注,并存活动上下文供失效守卫 - ai-kb 同步 page-action / page-context-tool / element-action 三 chunk Co-Authored-By: Claude Opus 4.8 (1M context) --- .../zh/concept/ai-assistant/page-action.md | 6 +- .../concept/ai-assistant/page-context-tool.md | 3 + .../zh/howto/ai-assistant/element-action.md | 4 +- .../components/AIAssistant/action-executor.js | 49 ++++-- .../components/AIAssistant/active-context.js | 145 ++++++++++++++++++ .../AIAssistant/operation-module.js | 44 +++++- .../AIAssistant/page-context-collector.js | 52 ++++--- 7 files changed, 267 insertions(+), 36 deletions(-) create mode 100644 resources/assets/js/components/AIAssistant/active-context.js diff --git a/resources/ai-kb/zh/concept/ai-assistant/page-action.md b/resources/ai-kb/zh/concept/ai-assistant/page-action.md index 47045308b..596bfa951 100644 --- a/resources/ai-kb/zh/concept/ai-assistant/page-action.md +++ b/resources/ai-kb/zh/concept/ai-assistant/page-action.md @@ -21,6 +21,7 @@ negative: - AI 的页面操作仅在浏览器/桌面端会话窗口内生效,无法控制其他用户的页面 - 一次只能操作当前会话所在的页面,不能开新标签页 - 关闭浏览器或切到别的标签页时,页面操作会断连失败 + - 跨源(外部站点)微应用 iframe 的内部不可操作,仅同源微应用插件可 last_verified: v1.7.90 --- @@ -40,9 +41,12 @@ AI 助手通过高层导航和低层元素操作两类能力操作用户当前 ## 受支持的低层元素动作 - `click`、`type`、`select`、`focus`、`scroll`、`hover` +## 微应用内部 +当你打开了微应用插件(应用市场安装、反代到主站 `/apps/` 同源路径、以 iframe 呈现)并停在最前时,采集页面上下文与低层元素操作会**默认作用于该微应用内部**,可像操作主界面一样点按钮 / 填表 / 切菜单。多个微应用同时打开时只操作最前面那个;切换或关闭应用后,原先拿到的元素引用会失效,AI 会被提示重新获取。跨源(指向外部站点)的微应用读不到内部,AI 会提示改用数据命令或回到主界面。 + ## 不支持 - 不能模拟键盘组合键、不能拖拽 -- 不能操作 iframe 内的内容 +- 不能操作跨源(外部站点)微应用 iframe 的内部 - 不能跳转外部 URL(goForward 只走应用内路由) - 不能伪造非用户主动触发的事件(如自动提交表单审批通过) diff --git a/resources/ai-kb/zh/concept/ai-assistant/page-context-tool.md b/resources/ai-kb/zh/concept/ai-assistant/page-context-tool.md index b075c28a6..049712ed8 100644 --- a/resources/ai-kb/zh/concept/ai-assistant/page-context-tool.md +++ b/resources/ai-kb/zh/concept/ai-assistant/page-context-tool.md @@ -20,6 +20,7 @@ negative: - 仅当用户在 AI 浮窗当前会话所在的浏览器/桌面端窗口时才能采集 - 不能跨标签 / 跨设备同步采集,每个 socket 只对应一个页面 - 不读取密码框/被遮挡元素/隐藏元素 + - 跨源(外部站点)微应用 iframe 的内部无法采集,会提示改用数据命令 last_verified: v1.7.90 --- @@ -35,11 +36,13 @@ last_verified: v1.7.90 - `total_count` / `has_more` / `offset`:分页 - `available_actions`:该页可用的高层动作(如项目页可 `open_task`) - `ref_map`:ref → 定位信息 +- `frame`:本次采集所在上下文——`scope`(`main` 主界面 / `app` 微应用)、`app_name`(微应用时的应用名)、`operable`(是否可操作) ## 调用模式 - **轻量**:`interactive_only=true` + `max_elements=20` - **完整**:默认前 100 个(含内容) - **搜索**:传 `query` 先关键词后向量匹配 +- **采集范围**:默认采集"用户最前面看到的"页面——有同源微应用插件在最前打开时采集其 iframe 内部,否则采集主界面;也可指定只采主界面。跨源微应用读不到内部,会返回 `operable:false` 并提示降级。 ## 隐式触发 用户在浮窗里问"这个页面有什么操作"、"帮我点这页的某按钮"、"切到下一项目"时,AI 都会先采集当前页面上下文再决定下一步。 diff --git a/resources/ai-kb/zh/howto/ai-assistant/element-action.md b/resources/ai-kb/zh/howto/ai-assistant/element-action.md index cd13fb798..7b4e8274e 100644 --- a/resources/ai-kb/zh/howto/ai-assistant/element-action.md +++ b/resources/ai-kb/zh/howto/ai-assistant/element-action.md @@ -28,7 +28,7 @@ last_verified: v1.7.90 # 让 AI 操作页面元素 ## 这是什么 -让 AI 助手在你当前页面上直接操作具体元素,包括点击按钮、输入文本、选下拉项、聚焦、滚动、悬停。常用于完成详细表单或触发某个隐藏在多级菜单里的功能。操作由 AI 助手在你的浏览器/桌面端页面上执行。 +让 AI 助手在你当前页面上直接操作具体元素,包括点击按钮、输入文本、选下拉项、聚焦、滚动、悬停。常用于完成详细表单或触发某个隐藏在多级菜单里的功能。操作由 AI 助手在你的浏览器/桌面端页面上执行。打开了微应用插件(同源)时,这些操作会默认作用于最前那个微应用的内部。 ## 怎么问 - "点击『保存』按钮" @@ -55,7 +55,7 @@ last_verified: v1.7.90 ## 不支持的动作 - 不能模拟键盘按键、不能模拟组合键 - 不能拖拽元素(drag/drop) -- 不能操作 iframe 内的元素 +- 不能操作跨源(外部站点)微应用 iframe 的内部元素(同源微应用插件内部可操作) - 不能等到某个异步加载完成再点(无 wait 机制,需用户重新触发) ## 找不到元素怎么办 diff --git a/resources/assets/js/components/AIAssistant/action-executor.js b/resources/assets/js/components/AIAssistant/action-executor.js index c19e4ba80..37db9e2f7 100644 --- a/resources/assets/js/components/AIAssistant/action-executor.js +++ b/resources/assets/js/components/AIAssistant/action-executor.js @@ -10,6 +10,7 @@ */ import { findElementByRef } from './page-context-collector'; +import { resolveActiveContext } from './active-context'; /** * 创建操作执行器 @@ -216,10 +217,30 @@ class ActionExecutor { // ========== 元素级操作 ========== /** - * 设置当前的 refMap(由 operation-module 在获取上下文后调用) + * 设置当前的 refMap 与活动上下文(由 operation-module 在获取上下文后调用) */ - setRefMap(refMap) { + setRefMap(refMap, context = null) { this.currentRefMap = refMap; + this.currentContext = context; + } + + /** + * 解析当前应执行元素操作的文档,并做失效守卫校验。 + * 当上次采集发生在某个微应用 iframe 内时,执行前重新解析最前上下文, + * 若 frameKey 不一致(用户切了应用 / 重开过 / 刷新了页面)则拒绝,避免误操作。 + * @returns {Document} + */ + resolveContextDoc() { + const saved = this.currentContext; + // 无上下文信息或主文档:直接用主文档 + if (!saved || saved.kind === 'main' || saved.frameKey === 'main') { + return document; + } + const now = resolveActiveContext(this.store, saved.scope || 'auto'); + if (now.frameKey !== saved.frameKey || !now.reachable || !now.doc) { + throw new Error('页面上下文已变更(用户切换了应用或刷新了页面),请重新获取页面上下文后再操作'); + } + return now.doc; } /** @@ -230,11 +251,15 @@ class ActionExecutor { * @returns {Promise} 执行结果 */ async executeElementAction(elementUid, action, value) { - const element = this.findElement(elementUid); + const doc = this.resolveContextDoc(); + const element = this.findElement(elementUid, doc); if (!element) { throw new Error(`找不到元素: ${elementUid}`); } + // 元素所在 window(主文档或微应用 iframe),事件需用它的构造器才被框架信任 + const win = element.ownerDocument.defaultView || window; + switch (action) { case 'click': element.click(); @@ -248,8 +273,8 @@ class ActionExecutor { } else { element.value = value || ''; } - element.dispatchEvent(new Event('input', { bubbles: true })); - element.dispatchEvent(new Event('change', { bubbles: true })); + element.dispatchEvent(new win.Event('input', { bubbles: true })); + element.dispatchEvent(new win.Event('change', { bubbles: true })); return { success: true, action: 'type', value, element: elementUid }; } throw new Error('元素不支持输入操作'); @@ -257,13 +282,13 @@ class ActionExecutor { case 'select': if (element.tagName === 'SELECT') { element.value = value; - element.dispatchEvent(new Event('change', { bubbles: true })); + element.dispatchEvent(new win.Event('change', { bubbles: true })); return { success: true, action: 'select', value, element: elementUid }; } // iView Select 组件 - 先点击打开下拉 element.click(); await this.delay(200); - const options = document.querySelectorAll('.ivu-select-dropdown-list .ivu-select-item'); + const options = element.ownerDocument.querySelectorAll('.ivu-select-dropdown-list .ivu-select-item'); for (const option of options) { if (option.textContent.trim().includes(value)) { option.click(); @@ -281,8 +306,8 @@ class ActionExecutor { return { success: true, action: 'scroll', element: elementUid }; case 'hover': - element.dispatchEvent(new MouseEvent('mouseenter', { bubbles: true })); - element.dispatchEvent(new MouseEvent('mouseover', { bubbles: true })); + element.dispatchEvent(new win.MouseEvent('mouseenter', { bubbles: true })); + element.dispatchEvent(new win.MouseEvent('mouseover', { bubbles: true })); return { success: true, action: 'hover', element: elementUid }; default: @@ -294,7 +319,7 @@ class ActionExecutor { * 查找元素 * 支持多种格式:e1, @e1, ref=e1, CSS选择器 */ - findElement(identifier) { + findElement(identifier, doc = document) { let ref = null; if (identifier.startsWith('@')) { ref = identifier.slice(1); @@ -306,13 +331,13 @@ class ActionExecutor { // 如果是 ref 格式,使用 refMap 查找 if (ref && this.currentRefMap) { - const element = findElementByRef(ref, this.currentRefMap); + const element = findElementByRef(ref, this.currentRefMap, doc); if (element) return element; } // 尝试作为 CSS 选择器 try { - const element = document.querySelector(identifier); + const element = doc.querySelector(identifier); if (element) return element; } catch (e) { // 选择器无效,忽略 diff --git a/resources/assets/js/components/AIAssistant/active-context.js b/resources/assets/js/components/AIAssistant/active-context.js new file mode 100644 index 000000000..7fa39d631 --- /dev/null +++ b/resources/assets/js/components/AIAssistant/active-context.js @@ -0,0 +1,145 @@ +/** + * AI 助手活动上下文解析 + * + * 页面操作(采集 / 元素操作)默认作用于"用户当前最前面看到的文档": + * 当有微应用插件以 iframe 形式打开并在最前时,操作其 iframe 内部 DOM;否则操作主文档。 + * + * 仅支持同源 iframe(应用商店插件反代到主站同源路径,contentDocument 可达); + * 跨源或尚未就绪的应用返回 reachable=false,由上层(operation-module)优雅降级。 + * + * micro-app(@micro-zoe with 沙箱)类型的子应用 DOM 本就渲染在主文档里, + * 现采集器走主文档即可扫到,这里按主界面处理、不特殊穿透。 + */ + +// iframe 元素选择器(见 MicroApps/iframe.vue) +const APP_IFRAME_SELECTOR = 'iframe.micro-app-iframe-container'; + +/** + * 是否 iframe 类型微应用(与 MicroApps/index.vue::isIframe 对齐) + * @param {string} type + * @returns {boolean} + */ +function isIframeType(type) { + return typeof type === 'string' && /^iframe/i.test(type); +} + +/** + * 取最前打开的微应用(isOpen 且 lastOpenAt 最大) + * @param {Object} store - Vuex store + * @returns {Object|null} + */ +function frontmostApp(store) { + const apps = (store?.state?.microApps || []).filter(a => a && a.isOpen); + if (!apps.length) { + return null; + } + return apps.reduce((a, b) => ((b.lastOpenAt || 0) > (a.lastOpenAt || 0) ? b : a)); +} + +/** + * 定位某微应用对应的 iframe DOM 元素 + * @param {Object} app - microApps 中的 app 对象(含注入运行时变量后的 url) + * @returns {HTMLIFrameElement|null} + */ +function findAppIframe(app) { + if (!app || !app.url) { + return null; + } + const frames = [...document.querySelectorAll(APP_IFRAME_SELECTOR)]; + if (!frames.length) { + return null; + } + // 1. 精确匹配 src === app.url + let hit = frames.find(f => f.src === app.url); + if (hit) { + return hit; + } + // 2. URL 规范化匹配(容忍尾斜杠/编码差异) + try { + const target = new URL(app.url, location.href).href; + hit = frames.find(f => { + try { + return new URL(f.src, location.href).href === target; + } catch (e) { + return false; + } + }); + if (hit) { + return hit; + } + } catch (e) { + // ignore + } + // 3. 仅一个微应用 iframe 时直接用 + return frames.length === 1 ? frames[0] : null; +} + +/** + * 解析当前活动上下文 + * @param {Object} store - Vuex store 实例 + * @param {string} scope - 'auto'(默认,有同源微应用在最前就采它)| 'main'(强制主界面)| 'app'(强制最前微应用) + * @returns {Object} 上下文: + * { kind:'main', scope, doc:document, appName, label, reachable:true, frameKey:'main' } + * { kind:'app', scope:'app', doc:iframeDoc, appName, appTitle, label, reachable:true, frameKey } + * { kind:'app', scope:'app', appName, appTitle, reachable:false, reason:'cross_origin'|'not_ready'|'no_app', frameKey } + */ +export function resolveActiveContext(store, scope = 'auto') { + const mainCtx = { + kind: 'main', + scope: 'main', + doc: document, + appName: null, + label: '主界面', + reachable: true, + frameKey: 'main', + }; + + if (scope === 'main') { + return mainCtx; + } + + const app = frontmostApp(store); + + if (!app) { + // 显式要求 app 但没有打开任何微应用 + if (scope === 'app') { + return { + kind: 'app', scope: 'app', appName: null, appTitle: '', + reachable: false, reason: 'no_app', frameKey: null, + }; + } + // auto 下无微应用 → 主界面 + return mainCtx; + } + + // 非 iframe 类型(micro-app with 沙箱):DOM 在主文档,按主界面处理 + if (!isIframeType(app.type)) { + return { ...mainCtx, appName: app.name, appTitle: app.title || app.name }; + } + + const base = { + kind: 'app', + scope: 'app', + appName: app.name, + appTitle: app.title || app.name, + frameKey: `app:${app.name}@${app.lastOpenAt || 0}`, + }; + + const iframe = findAppIframe(app); + if (!iframe) { + return { ...base, reachable: false, reason: 'not_ready' }; + } + + try { + const doc = iframe.contentDocument; + if (doc && doc.body && doc.body.children.length > 0) { + return { ...base, reachable: true, doc, label: `微应用「${base.appTitle}」` }; + } + return { ...base, reachable: false, reason: 'not_ready' }; + } catch (e) { + // 跨源 iframe:contentDocument 抛异常 + return { ...base, reachable: false, reason: 'cross_origin' }; + } +} + +export default resolveActiveContext; diff --git a/resources/assets/js/components/AIAssistant/operation-module.js b/resources/assets/js/components/AIAssistant/operation-module.js index e6142b821..de5f58e3f 100644 --- a/resources/assets/js/components/AIAssistant/operation-module.js +++ b/resources/assets/js/components/AIAssistant/operation-module.js @@ -9,6 +9,7 @@ import { collectPageContext, searchByVector } from './page-context-collector'; import { createActionExecutor } from './action-executor'; +import { resolveActiveContext } from './active-context'; /** * 创建操作模块实例 @@ -71,8 +72,39 @@ class OperationModule { const query = payload?.query || ''; const offset = payload?.offset || 0; const container = payload?.container || null; + const scope = payload?.scope || 'auto'; + + // 解析当前活动上下文:主界面,或最前打开的同源微应用 iframe + const active = resolveActiveContext(this.store, scope); + + // 微应用打开但不可读(跨源 / 未就绪 / 指定 app 但无应用):优雅降级,不采集 + if (active.kind === 'app' && !active.reachable) { + const reasonText = active.reason === 'cross_origin' + ? '当前应用为跨源页面,无法读取其内部元素' + : active.reason === 'no_app' + ? '当前没有打开任何微应用' + : '当前应用尚未加载完成'; + const base = collectPageContext(this.store, { include_elements: false }); + base.elements = []; + base.element_count = 0; + base.total_count = 0; + base.has_more = false; + base.frame = { + scope: 'app', + app_name: active.appName || null, + operable: false, + reachable: false, + reason: active.reason, + }; + base.hint = `${reasonText}。可改用主界面(scope=main)操作,或改用数据命令完成。`; + this.executor.setRefMap({}, null); + return base; + } + + const doc = active.doc || document; let context = collectPageContext(this.store, { + doc, include_elements: includeElements, interactive_only: interactiveOnly, max_elements: maxElements, @@ -84,6 +116,7 @@ class OperationModule { // 如果有 query 且关键词匹配失败,尝试向量搜索 if (query && !context.keyword_matched) { const allContext = collectPageContext(this.store, { + doc, include_elements: true, interactive_only: interactiveOnly, max_elements: 200, @@ -114,9 +147,16 @@ class OperationModule { } } - // 将 refMap 存储到 executor,供后续元素操作使用 + // 标注本次采集所在的上下文(主界面 / 微应用),让模型清楚在操作谁 + context.frame = { + scope: active.scope, + app_name: active.appName || null, + operable: true, + }; + + // 将 refMap 与活动上下文一并存入 executor,供后续元素操作使用(含失效守卫) if (context.ref_map && this.executor) { - this.executor.setRefMap(context.ref_map); + this.executor.setRefMap(context.ref_map, active); } return context; diff --git a/resources/assets/js/components/AIAssistant/page-context-collector.js b/resources/assets/js/components/AIAssistant/page-context-collector.js index 4946a3666..db457c162 100644 --- a/resources/assets/js/components/AIAssistant/page-context-collector.js +++ b/resources/assets/js/components/AIAssistant/page-context-collector.js @@ -74,6 +74,7 @@ const ELEMENT_ROLE_MAP = { */ export function collectPageContext(store, options = {}) { const routeName = store?.state?.routeName; + const doc = options.doc || document; const includeElements = options.include_elements !== false; const interactiveOnly = options.interactive_only || false; const maxElements = options.max_elements || 50; @@ -84,8 +85,8 @@ export function collectPageContext(store, options = {}) { // 基础上下文 const context = { page_type: routeName || 'unknown', - page_url: window.location.href, - page_title: document.title, + page_url: (doc.location && doc.location.href) || window.location.href, + page_title: doc.title, timestamp: Date.now(), elements: [], element_count: 0, @@ -98,6 +99,7 @@ export function collectPageContext(store, options = {}) { // 收集可交互元素 if (includeElements) { const result = collectElements({ + doc, interactiveOnly, maxElements, offset, @@ -236,6 +238,7 @@ function getAvailableActions(routeName, store) { */ export function collectElements(options = {}) { const { + doc = document, interactiveOnly = false, maxElements = 50, offset = 0, @@ -244,9 +247,9 @@ export function collectElements(options = {}) { } = options; // 确定查询的根元素 - let rootElement = document; + let rootElement = doc; if (container) { - rootElement = document.querySelector(container); + rootElement = doc.querySelector(container); if (!rootElement) { return { elements: [], refMap: {}, totalCount: 0, hasMore: false }; } @@ -337,7 +340,7 @@ export function collectElements(options = {}) { if (processedElements.has(el)) continue; // 检查是否有 cursor: pointer 样式 - const computedStyle = window.getComputedStyle(el); + const computedStyle = (el.ownerDocument.defaultView || window).getComputedStyle(el); if (computedStyle.cursor !== 'pointer') continue; // 跳过不可见或禁用元素 @@ -524,7 +527,7 @@ function getElementRole(el) { // 检查是否可点击 if (el.onclick || el.hasAttribute('onclick') || el.style.cursor === 'pointer' || - window.getComputedStyle(el).cursor === 'pointer') { + (el.ownerDocument.defaultView || window).getComputedStyle(el).cursor === 'pointer') { return 'button'; } @@ -544,7 +547,7 @@ export function getElementName(el) { const ariaLabelledBy = el.getAttribute('aria-labelledby'); if (ariaLabelledBy) { - const labelEl = document.getElementById(ariaLabelledBy); + const labelEl = el.ownerDocument.getElementById(ariaLabelledBy); if (labelEl) { return getTextContent(labelEl).substring(0, 100); } @@ -552,7 +555,7 @@ export function getElementName(el) { // 对于输入元素,查找关联的 label if (el.id) { - const label = document.querySelector(`label[for="${el.id}"]`); + const label = el.ownerDocument.querySelector(`label[for="${el.id}"]`); if (label) { return getTextContent(label).substring(0, 100); } @@ -600,8 +603,11 @@ function getTextContent(el) { export function isElementVisible(el) { if (!el) return false; + // 元素所在的 window(主文档或微应用 iframe 文档),视口尺寸与样式都取它自己的 + const win = el.ownerDocument.defaultView || window; + // 检查元素本身 - const style = window.getComputedStyle(el); + const style = win.getComputedStyle(el); if (style.display === 'none') return false; if (style.visibility === 'hidden') return false; @@ -612,8 +618,8 @@ export function isElementVisible(el) { if (rect.width === 0 && rect.height === 0) return false; // 检查是否在视口内或附近(允许稍微超出) - const viewportHeight = window.innerHeight; - const viewportWidth = window.innerWidth; + const viewportHeight = win.innerHeight; + const viewportWidth = win.innerWidth; // 元素完全在视口外 if (rect.bottom < -100 || rect.top > viewportHeight + 100) return false; @@ -622,7 +628,7 @@ export function isElementVisible(el) { // 检查父元素的可见性 let parent = el.parentElement; while (parent) { - const parentStyle = window.getComputedStyle(parent); + const parentStyle = win.getComputedStyle(parent); if (parentStyle.display === 'none') return false; if (parentStyle.visibility === 'hidden') return false; parent = parent.parentElement; @@ -673,7 +679,7 @@ function generateSelector(el) { let current = el; let depth = 0; - while (current && current !== document.body && depth < 5) { + while (current && current !== el.ownerDocument.body && depth < 5) { let selector = current.tagName.toLowerCase(); // 添加重要的类名(排除动态类) @@ -710,7 +716,7 @@ function generateSelector(el) { * @param {Object} refMap - 引用映射表 * @returns {Element|null} */ -export function findElementByRef(ref, refMap) { +export function findElementByRef(ref, refMap, doc = document) { const refData = refMap[ref]; if (!refData) { return null; @@ -718,7 +724,7 @@ export function findElementByRef(ref, refMap) { // 首先尝试使用选择器 + name 双重匹配 if (refData.selector) { - const elements = document.querySelectorAll(refData.selector); + const elements = doc.querySelectorAll(refData.selector); if (elements.length === 1) { return elements[0]; @@ -746,7 +752,7 @@ export function findElementByRef(ref, refMap) { // 回退到角色+名称匹配 const roleSelector = `[role="${refData.role}"]`; - const candidates = document.querySelectorAll(roleSelector); + const candidates = doc.querySelectorAll(roleSelector); for (const candidate of candidates) { if (refData.name) { @@ -815,14 +821,22 @@ export default collectPageContext; // 暴露到 window 供调试使用 if (typeof window !== 'undefined') { window.__testPageContext = (options = {}) => { - // 简化版,不需要 store + // 简化版,不需要 store;传 frameSrc 可在匹配的微应用 iframe 内采集(验证用) + let doc = document; + if (options.frameSrc) { + const f = [...document.querySelectorAll('iframe')].find(x => (x.src || '').includes(options.frameSrc)); + if (f && f.contentDocument) { + doc = f.contentDocument; + } + } const context = { - page_url: window.location.href, - page_title: document.title, + page_url: (doc.location && doc.location.href) || window.location.href, + page_title: doc.title, timestamp: Date.now(), }; const result = collectElements({ + doc, interactiveOnly: options.interactive_only || false, maxElements: options.max_elements || 50, offset: options.offset || 0,