mirror of
https://github.com/kuaifan/dootask.git
synced 2026-06-27 09:42:18 +00:00
feat(ai-assistant): 页面操作穿透到当前微应用 iframe 内部
- 新增 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) <noreply@anthropic.com>
This commit is contained in:
parent
e74142e58d
commit
88fed0744c
@ -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 只走应用内路由)
|
||||
- 不能伪造非用户主动触发的事件(如自动提交表单审批通过)
|
||||
|
||||
|
||||
@ -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 都会先采集当前页面上下文再决定下一步。
|
||||
|
||||
@ -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 机制,需用户重新触发)
|
||||
|
||||
## 找不到元素怎么办
|
||||
|
||||
@ -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<Object>} 执行结果
|
||||
*/
|
||||
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) {
|
||||
// 选择器无效,忽略
|
||||
|
||||
145
resources/assets/js/components/AIAssistant/active-context.js
vendored
Normal file
145
resources/assets/js/components/AIAssistant/active-context.js
vendored
Normal file
@ -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;
|
||||
@ -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;
|
||||
|
||||
@ -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,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user