mirror of
https://github.com/kuaifan/dootask.git
synced 2026-01-22 09:48:11 +00:00
新增三个 MCP 工具的前端支持: - get_page_context: 基于 ARIA 角色收集页面元素,支持分页和区域筛选 - execute_action: 执行导航操作(打开任务/对话、切换项目/页面) - execute_element_action: 元素级操作(click/type/select/focus/scroll/hover) 新增文件: - operation-client.js: WebSocket 客户端,处理与 MCP Server 的通信 - page-context-collector.js: 页面上下文收集器,ref 系统和 cursor:pointer 扫描 - action-executor.js: 操作执行器,支持智能解析如 open_task_123 - operation-module.js: 模块编排,整合上述模块 修改文件: - float-button.vue: 集成 operation-module,AI 助手打开时启用 - index.vue: 发射关闭事件供 float-button 监听
738 lines
21 KiB
JavaScript
Vendored
738 lines
21 KiB
JavaScript
Vendored
/**
|
||
* 页面上下文收集器
|
||
*
|
||
* 借鉴 agent-browser 项目的设计思想,基于 ARIA 角色收集页面元素。
|
||
* 提供结构化的页面快照,包括可交互元素和内容元素。
|
||
*/
|
||
|
||
// ========== ARIA 角色分类 ==========
|
||
|
||
// 可交互角色 - 这些元素可以被点击、输入等
|
||
const INTERACTIVE_ROLES = new Set([
|
||
'button', 'link', 'textbox', 'checkbox', 'radio',
|
||
'combobox', 'listbox', 'menuitem', 'menuitemcheckbox',
|
||
'menuitemradio', 'option', 'searchbox', 'slider',
|
||
'spinbutton', 'switch', 'tab', 'treeitem', 'gridcell',
|
||
]);
|
||
|
||
// 内容角色 - 这些元素包含重要内容
|
||
const CONTENT_ROLES = new Set([
|
||
'heading', 'cell', 'columnheader', 'rowheader',
|
||
'listitem', 'article', 'region', 'main', 'navigation',
|
||
'img', 'figure',
|
||
]);
|
||
|
||
// HTML 元素到 ARIA 角色的映射
|
||
const ELEMENT_ROLE_MAP = {
|
||
'button': 'button',
|
||
'a': 'link',
|
||
'input': (el) => {
|
||
const type = el.type?.toLowerCase() || 'text';
|
||
switch (type) {
|
||
case 'checkbox': return 'checkbox';
|
||
case 'radio': return 'radio';
|
||
case 'submit':
|
||
case 'reset':
|
||
case 'button': return 'button';
|
||
case 'search': return 'searchbox';
|
||
case 'range': return 'slider';
|
||
case 'number': return 'spinbutton';
|
||
default: return 'textbox';
|
||
}
|
||
},
|
||
'textarea': 'textbox',
|
||
'select': 'combobox',
|
||
'option': 'option',
|
||
'h1': 'heading',
|
||
'h2': 'heading',
|
||
'h3': 'heading',
|
||
'h4': 'heading',
|
||
'h5': 'heading',
|
||
'h6': 'heading',
|
||
'img': 'img',
|
||
'nav': 'navigation',
|
||
'main': 'main',
|
||
'article': 'article',
|
||
'li': 'listitem',
|
||
'td': 'cell',
|
||
'th': 'columnheader',
|
||
};
|
||
|
||
// ========== 元素收集器 ==========
|
||
|
||
/**
|
||
* 收集当前页面上下文
|
||
* @param {Object} store - Vuex store 实例
|
||
* @param {Object} options - 收集选项
|
||
* @param {boolean} options.include_elements - 是否包含可交互元素
|
||
* @param {boolean} options.interactive_only - 仅返回可交互元素
|
||
* @param {number} options.max_elements - 每页最大元素数量,默认 50
|
||
* @param {number} options.offset - 跳过前 N 个元素(分页用),默认 0
|
||
* @param {string} options.container - 容器选择器,只扫描该容器内的元素
|
||
* @returns {Object} 页面上下文
|
||
*/
|
||
export function collectPageContext(store, options = {}) {
|
||
const routeName = store?.state?.routeName;
|
||
const includeElements = options.include_elements !== false;
|
||
const interactiveOnly = options.interactive_only || false;
|
||
const maxElements = options.max_elements || 50;
|
||
const offset = options.offset || 0;
|
||
const container = options.container || null;
|
||
|
||
// 基础上下文
|
||
const context = {
|
||
page_type: routeName || 'unknown',
|
||
page_url: window.location.href,
|
||
page_title: document.title,
|
||
timestamp: Date.now(),
|
||
elements: [],
|
||
element_count: 0,
|
||
total_count: 0,
|
||
offset: offset,
|
||
has_more: false,
|
||
available_actions: getAvailableActions(routeName, store),
|
||
};
|
||
|
||
// 收集可交互元素
|
||
if (includeElements) {
|
||
const result = collectElements({
|
||
interactiveOnly,
|
||
maxElements,
|
||
offset,
|
||
container,
|
||
});
|
||
context.elements = result.elements;
|
||
context.element_count = result.elements.length;
|
||
context.total_count = result.totalCount;
|
||
context.has_more = result.hasMore;
|
||
context.ref_map = result.refMap;
|
||
}
|
||
|
||
return context;
|
||
}
|
||
|
||
/**
|
||
* 根据页面类型获取可用的导航操作
|
||
* @param {string} routeName - 路由名称
|
||
* @param {Object} store - Vuex store 实例
|
||
* @returns {Array} 可用操作列表
|
||
*/
|
||
function getAvailableActions(routeName, store) {
|
||
// 通用导航操作 - 在所有页面都可用
|
||
const commonActions = [
|
||
{
|
||
name: 'navigate_to_dashboard',
|
||
description: '跳转到仪表盘',
|
||
},
|
||
{
|
||
name: 'navigate_to_messenger',
|
||
description: '跳转到消息页面',
|
||
},
|
||
{
|
||
name: 'navigate_to_calendar',
|
||
description: '跳转到日历页面',
|
||
},
|
||
{
|
||
name: 'navigate_to_files',
|
||
description: '跳转到文件管理页面',
|
||
},
|
||
];
|
||
|
||
// 根据页面类型添加特定操作
|
||
const pageSpecificActions = [];
|
||
|
||
switch (routeName) {
|
||
case 'manage-project':
|
||
// 项目页面:可以打开任务
|
||
pageSpecificActions.push({
|
||
name: 'open_task',
|
||
description: '打开任务详情',
|
||
params: { task_id: '任务ID' },
|
||
});
|
||
break;
|
||
|
||
case 'manage-messenger':
|
||
// 消息页面:可以打开特定对话
|
||
pageSpecificActions.push({
|
||
name: 'open_dialog',
|
||
description: '打开/切换对话',
|
||
params: { dialog_id: '对话ID', msg_id: '(可选)跳转到指定消息' },
|
||
});
|
||
break;
|
||
|
||
case 'manage-file':
|
||
// 文件页面:可以打开文件夹或文件
|
||
pageSpecificActions.push(
|
||
{
|
||
name: 'open_folder',
|
||
description: '打开文件夹',
|
||
params: { folder_id: '文件夹ID' },
|
||
},
|
||
{
|
||
name: 'open_file',
|
||
description: '打开文件预览',
|
||
params: { file_id: '文件ID' },
|
||
}
|
||
);
|
||
break;
|
||
|
||
case 'manage-dashboard':
|
||
// 仪表盘:可以快速跳转到项目或打开任务
|
||
pageSpecificActions.push(
|
||
{
|
||
name: 'open_project',
|
||
description: '打开/切换到项目',
|
||
params: { project_id: '项目ID' },
|
||
},
|
||
{
|
||
name: 'open_task',
|
||
description: '打开任务详情',
|
||
params: { task_id: '任务ID' },
|
||
}
|
||
);
|
||
break;
|
||
|
||
default:
|
||
// 其他页面:提供基础的打开操作
|
||
pageSpecificActions.push(
|
||
{
|
||
name: 'open_project',
|
||
description: '打开/切换到项目',
|
||
params: { project_id: '项目ID' },
|
||
},
|
||
{
|
||
name: 'open_task',
|
||
description: '打开任务详情',
|
||
params: { task_id: '任务ID' },
|
||
},
|
||
{
|
||
name: 'open_dialog',
|
||
description: '打开对话',
|
||
params: { dialog_id: '对话ID' },
|
||
}
|
||
);
|
||
}
|
||
|
||
return [...pageSpecificActions, ...commonActions];
|
||
}
|
||
|
||
/**
|
||
* 收集页面元素
|
||
* @param {Object} options
|
||
* @param {boolean} options.interactiveOnly - 仅返回可交互元素
|
||
* @param {number} options.maxElements - 每页最大元素数量
|
||
* @param {number} options.offset - 跳过前 N 个元素
|
||
* @param {string} options.container - 容器选择器
|
||
* @returns {Object} { elements, refMap, totalCount, hasMore }
|
||
*/
|
||
function collectElements(options = {}) {
|
||
const {
|
||
interactiveOnly = false,
|
||
maxElements = 50,
|
||
offset = 0,
|
||
container = null,
|
||
} = options;
|
||
|
||
// 确定查询的根元素
|
||
let rootElement = document;
|
||
if (container) {
|
||
rootElement = document.querySelector(container);
|
||
if (!rootElement) {
|
||
return { elements: [], refMap: {}, totalCount: 0, hasMore: false };
|
||
}
|
||
}
|
||
|
||
// 角色+名称计数器,用于处理重复元素
|
||
const roleNameCounter = new Map();
|
||
|
||
// 获取所有可能的交互元素选择器
|
||
const selectors = [
|
||
// 按钮类
|
||
'button',
|
||
'[role="button"]',
|
||
'input[type="submit"]',
|
||
'input[type="button"]',
|
||
'input[type="reset"]',
|
||
'.ivu-btn',
|
||
// 链接
|
||
'a[href]',
|
||
'[role="link"]',
|
||
// 输入框
|
||
'input:not([type="hidden"])',
|
||
'textarea',
|
||
'[role="textbox"]',
|
||
'[contenteditable="true"]',
|
||
// 选择器
|
||
'select',
|
||
'[role="combobox"]',
|
||
'[role="listbox"]',
|
||
'.ivu-select',
|
||
// 复选框和单选框
|
||
'input[type="checkbox"]',
|
||
'input[type="radio"]',
|
||
'[role="checkbox"]',
|
||
'[role="radio"]',
|
||
'.ivu-checkbox',
|
||
'.ivu-radio',
|
||
// 菜单项
|
||
'[role="menuitem"]',
|
||
'[role="tab"]',
|
||
'.ivu-menu-item',
|
||
'.ivu-tabs-tab',
|
||
// 可点击的图标和操作按钮
|
||
'[class*="click"]',
|
||
'[class*="btn"]',
|
||
'[class*="action"]',
|
||
'.taskfont[title]',
|
||
// 表格单元格(可能可点击)
|
||
'td[onclick]',
|
||
'tr[onclick]',
|
||
];
|
||
|
||
// 如果不仅限交互元素,添加内容元素选择器
|
||
if (!interactiveOnly) {
|
||
selectors.push(
|
||
'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
|
||
'[role="heading"]',
|
||
'img[alt]',
|
||
'nav',
|
||
'main',
|
||
);
|
||
}
|
||
|
||
// 第一阶段:收集所有有效元素(不限数量)
|
||
const allValidElements = [];
|
||
const processedElements = new Set();
|
||
|
||
// 查询所有匹配的元素
|
||
const candidateElements = rootElement.querySelectorAll(selectors.join(', '));
|
||
|
||
for (const el of candidateElements) {
|
||
processedElements.add(el);
|
||
|
||
// 跳过不可见元素
|
||
if (!isElementVisible(el)) continue;
|
||
|
||
// 跳过禁用元素
|
||
if (isElementDisabled(el)) continue;
|
||
|
||
allValidElements.push({ el, fromPointerScan: false });
|
||
}
|
||
|
||
// 第二遍扫描:查找具有 cursor: pointer 但未被选择器匹配的元素
|
||
const clickableSelectors = 'div, span, li, td, tr, section, article, aside, header, footer, label, i, svg';
|
||
const potentialClickables = rootElement.querySelectorAll(clickableSelectors);
|
||
|
||
for (const el of potentialClickables) {
|
||
if (processedElements.has(el)) continue;
|
||
|
||
// 检查是否有 cursor: pointer 样式
|
||
const computedStyle = window.getComputedStyle(el);
|
||
if (computedStyle.cursor !== 'pointer') continue;
|
||
|
||
// 跳过不可见或禁用元素
|
||
if (!isElementVisible(el)) continue;
|
||
if (isElementDisabled(el)) continue;
|
||
|
||
processedElements.add(el);
|
||
allValidElements.push({ el, fromPointerScan: true });
|
||
}
|
||
|
||
// 第二阶段:应用分页,生成最终结果
|
||
const totalCount = allValidElements.length;
|
||
const startIndex = offset;
|
||
const endIndex = Math.min(offset + maxElements, totalCount);
|
||
const hasMore = endIndex < totalCount;
|
||
|
||
const elements = [];
|
||
const refMap = {};
|
||
let refCounter = offset + 1; // ref 从 offset+1 开始,保持全局唯一
|
||
|
||
for (let i = startIndex; i < endIndex; i++) {
|
||
const { el, fromPointerScan } = allValidElements[i];
|
||
|
||
// 获取元素信息
|
||
const elementInfo = extractElementInfo(el, refCounter);
|
||
if (!elementInfo) continue;
|
||
|
||
// 如果是从 pointer 扫描来的,强制设为 button 角色
|
||
if (fromPointerScan && elementInfo.role === 'generic') {
|
||
elementInfo.role = 'button';
|
||
}
|
||
|
||
// 处理重复的角色+名称组合
|
||
const key = `${elementInfo.role}:${elementInfo.name || ''}`;
|
||
const count = roleNameCounter.get(key) || 0;
|
||
roleNameCounter.set(key, count + 1);
|
||
|
||
if (count > 0) {
|
||
elementInfo.nth = count;
|
||
}
|
||
|
||
// 生成 ref
|
||
const ref = `e${refCounter++}`;
|
||
elementInfo.ref = ref;
|
||
|
||
// 存储到 refMap
|
||
refMap[ref] = {
|
||
role: elementInfo.role,
|
||
name: elementInfo.name,
|
||
selector: elementInfo.selector,
|
||
nth: elementInfo.nth,
|
||
};
|
||
|
||
elements.push(elementInfo);
|
||
}
|
||
|
||
// 为重复元素添加 nth 标记
|
||
for (const element of elements) {
|
||
const key = `${element.role}:${element.name || ''}`;
|
||
const roleCount = roleNameCounter.get(key);
|
||
// 只有真正重复的才保留 nth
|
||
if (roleCount <= 1) {
|
||
delete element.nth;
|
||
if (refMap[element.ref]) {
|
||
delete refMap[element.ref].nth;
|
||
}
|
||
}
|
||
}
|
||
|
||
return { elements, refMap, totalCount, hasMore };
|
||
}
|
||
|
||
/**
|
||
* 提取元素信息
|
||
* @param {Element} el
|
||
* @param {number} index
|
||
* @returns {Object|null}
|
||
*/
|
||
function extractElementInfo(el, index) {
|
||
const tagName = el.tagName.toLowerCase();
|
||
const role = getElementRole(el);
|
||
|
||
// 获取元素名称/文本
|
||
const name = getElementName(el);
|
||
|
||
// 生成选择器
|
||
const selector = generateSelector(el);
|
||
|
||
// 基础信息
|
||
const info = {
|
||
role,
|
||
tag: tagName,
|
||
name: name || undefined,
|
||
selector,
|
||
};
|
||
|
||
// 添加特定属性
|
||
if (el.id) {
|
||
info.id = el.id;
|
||
}
|
||
|
||
if (el.type && (tagName === 'input' || tagName === 'button')) {
|
||
info.input_type = el.type;
|
||
}
|
||
|
||
if (el.placeholder) {
|
||
info.placeholder = el.placeholder;
|
||
}
|
||
|
||
if (el.value && (tagName === 'input' || tagName === 'textarea')) {
|
||
info.value = el.value.substring(0, 50);
|
||
}
|
||
|
||
if (el.href && tagName === 'a') {
|
||
info.href = el.href;
|
||
}
|
||
|
||
if (el.checked !== undefined) {
|
||
info.checked = el.checked;
|
||
}
|
||
|
||
if (el.title) {
|
||
info.title = el.title;
|
||
}
|
||
|
||
// 添加 aria 属性
|
||
const ariaLabel = el.getAttribute('aria-label');
|
||
if (ariaLabel) {
|
||
info.aria_label = ariaLabel;
|
||
}
|
||
|
||
return info;
|
||
}
|
||
|
||
/**
|
||
* 获取元素的 ARIA 角色
|
||
* @param {Element} el
|
||
* @returns {string}
|
||
*/
|
||
function getElementRole(el) {
|
||
// 首先检查显式的 role 属性
|
||
const explicitRole = el.getAttribute('role');
|
||
if (explicitRole) {
|
||
return explicitRole;
|
||
}
|
||
|
||
// 使用映射表
|
||
const tagName = el.tagName.toLowerCase();
|
||
const roleMapping = ELEMENT_ROLE_MAP[tagName];
|
||
|
||
if (typeof roleMapping === 'function') {
|
||
return roleMapping(el);
|
||
}
|
||
|
||
if (typeof roleMapping === 'string') {
|
||
return roleMapping;
|
||
}
|
||
|
||
// 检查是否可点击
|
||
if (el.onclick || el.hasAttribute('onclick') ||
|
||
el.style.cursor === 'pointer' ||
|
||
window.getComputedStyle(el).cursor === 'pointer') {
|
||
return 'button';
|
||
}
|
||
|
||
return 'generic';
|
||
}
|
||
|
||
/**
|
||
* 获取元素的可访问名称
|
||
* @param {Element} el
|
||
* @returns {string}
|
||
*/
|
||
function getElementName(el) {
|
||
// 优先级:aria-label > aria-labelledby > 内容文本 > title > placeholder > alt
|
||
|
||
const ariaLabel = el.getAttribute('aria-label');
|
||
if (ariaLabel) return ariaLabel.trim().substring(0, 100);
|
||
|
||
const ariaLabelledBy = el.getAttribute('aria-labelledby');
|
||
if (ariaLabelledBy) {
|
||
const labelEl = document.getElementById(ariaLabelledBy);
|
||
if (labelEl) {
|
||
return getTextContent(labelEl).substring(0, 100);
|
||
}
|
||
}
|
||
|
||
// 对于输入元素,查找关联的 label
|
||
if (el.id) {
|
||
const label = document.querySelector(`label[for="${el.id}"]`);
|
||
if (label) {
|
||
return getTextContent(label).substring(0, 100);
|
||
}
|
||
}
|
||
|
||
// 获取元素内的文本内容
|
||
const text = getTextContent(el);
|
||
if (text) return text.substring(0, 100);
|
||
|
||
// 其他属性
|
||
if (el.title) return el.title.substring(0, 100);
|
||
if (el.placeholder) return el.placeholder.substring(0, 100);
|
||
if (el.alt) return el.alt.substring(0, 100);
|
||
if (el.value && (el.tagName === 'INPUT' || el.tagName === 'BUTTON')) {
|
||
return el.value.substring(0, 100);
|
||
}
|
||
|
||
return '';
|
||
}
|
||
|
||
/**
|
||
* 获取元素的文本内容(排除子元素中隐藏的文本)
|
||
* @param {Element} el
|
||
* @returns {string}
|
||
*/
|
||
function getTextContent(el) {
|
||
// 克隆元素以避免修改原始 DOM
|
||
const clone = el.cloneNode(true);
|
||
|
||
// 移除脚本和样式
|
||
clone.querySelectorAll('script, style, [hidden], [aria-hidden="true"]').forEach(e => e.remove());
|
||
|
||
// 获取文本并清理
|
||
let text = clone.textContent || clone.innerText || '';
|
||
text = text.replace(/\s+/g, ' ').trim();
|
||
|
||
return text;
|
||
}
|
||
|
||
/**
|
||
* 检查元素是否可见
|
||
* @param {Element} el
|
||
* @returns {boolean}
|
||
*/
|
||
function isElementVisible(el) {
|
||
if (!el) return false;
|
||
|
||
// 检查元素本身
|
||
const style = window.getComputedStyle(el);
|
||
|
||
if (style.display === 'none') return false;
|
||
if (style.visibility === 'hidden') return false;
|
||
if (style.opacity === '0') return false;
|
||
|
||
// 检查边界框
|
||
const rect = el.getBoundingClientRect();
|
||
if (rect.width === 0 && rect.height === 0) return false;
|
||
|
||
// 检查是否在视口内或附近(允许稍微超出)
|
||
const viewportHeight = window.innerHeight;
|
||
const viewportWidth = window.innerWidth;
|
||
|
||
// 元素完全在视口外
|
||
if (rect.bottom < -100 || rect.top > viewportHeight + 100) return false;
|
||
if (rect.right < -100 || rect.left > viewportWidth + 100) return false;
|
||
|
||
// 检查父元素的可见性
|
||
let parent = el.parentElement;
|
||
while (parent) {
|
||
const parentStyle = window.getComputedStyle(parent);
|
||
if (parentStyle.display === 'none') return false;
|
||
if (parentStyle.visibility === 'hidden') return false;
|
||
parent = parent.parentElement;
|
||
}
|
||
|
||
return true;
|
||
}
|
||
|
||
/**
|
||
* 检查元素是否禁用
|
||
* @param {Element} el
|
||
* @returns {boolean}
|
||
*/
|
||
function isElementDisabled(el) {
|
||
if (el.disabled) return true;
|
||
if (el.getAttribute('aria-disabled') === 'true') return true;
|
||
|
||
// 检查是否在禁用的 fieldset 中
|
||
const fieldset = el.closest('fieldset');
|
||
if (fieldset && fieldset.disabled) {
|
||
// legend 中的元素不受影响
|
||
const legend = fieldset.querySelector('legend');
|
||
if (legend && legend.contains(el)) return false;
|
||
return true;
|
||
}
|
||
|
||
// 检查 iView 组件的禁用状态
|
||
if (el.classList.contains('ivu-btn-disabled')) return true;
|
||
if (el.classList.contains('ivu-input-disabled')) return true;
|
||
if (el.classList.contains('ivu-select-disabled')) return true;
|
||
|
||
return false;
|
||
}
|
||
|
||
/**
|
||
* 生成元素选择器
|
||
* @param {Element} el
|
||
* @returns {string}
|
||
*/
|
||
function generateSelector(el) {
|
||
// 如果有 ID,直接使用
|
||
if (el.id) {
|
||
return `#${el.id}`;
|
||
}
|
||
|
||
// 尝试生成唯一选择器
|
||
const parts = [];
|
||
let current = el;
|
||
let depth = 0;
|
||
|
||
while (current && current !== document.body && depth < 5) {
|
||
let selector = current.tagName.toLowerCase();
|
||
|
||
// 添加重要的类名(排除动态类)
|
||
if (current.className && typeof current.className === 'string') {
|
||
const classes = current.className
|
||
.split(' ')
|
||
.filter(c => c && !c.startsWith('ivu-') && !c.includes('--') && !c.includes('active') && !c.includes('hover'))
|
||
.slice(0, 2);
|
||
if (classes.length) {
|
||
selector += '.' + classes.join('.');
|
||
}
|
||
}
|
||
|
||
// 添加有用的属性
|
||
if (current.getAttribute('role')) {
|
||
selector += `[role="${current.getAttribute('role')}"]`;
|
||
} else if (current.getAttribute('data-id')) {
|
||
selector += `[data-id="${current.getAttribute('data-id')}"]`;
|
||
} else if (current.getAttribute('name')) {
|
||
selector += `[name="${current.getAttribute('name')}"]`;
|
||
}
|
||
|
||
parts.unshift(selector);
|
||
current = current.parentElement;
|
||
depth++;
|
||
}
|
||
|
||
return parts.join(' > ');
|
||
}
|
||
|
||
/**
|
||
* 根据 ref 查找元素
|
||
* @param {string} ref - 元素引用 (e1, e2, ...)
|
||
* @param {Object} refMap - 引用映射表
|
||
* @returns {Element|null}
|
||
*/
|
||
export function findElementByRef(ref, refMap) {
|
||
const refData = refMap[ref];
|
||
if (!refData) return null;
|
||
|
||
// 首先尝试使用选择器
|
||
if (refData.selector) {
|
||
const elements = document.querySelectorAll(refData.selector);
|
||
if (refData.nth !== undefined && elements.length > refData.nth) {
|
||
return elements[refData.nth];
|
||
}
|
||
if (elements.length > 0) {
|
||
return elements[0];
|
||
}
|
||
}
|
||
|
||
// 回退到角色+名称匹配
|
||
const roleSelector = `[role="${refData.role}"]`;
|
||
const candidates = document.querySelectorAll(roleSelector);
|
||
|
||
for (const candidate of candidates) {
|
||
if (refData.name) {
|
||
const candidateName = getElementName(candidate);
|
||
if (candidateName === refData.name) {
|
||
return candidate;
|
||
}
|
||
}
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
export default collectPageContext;
|
||
|
||
// 暴露到 window 供调试使用
|
||
if (typeof window !== 'undefined') {
|
||
window.__testPageContext = (options = {}) => {
|
||
// 简化版,不需要 store
|
||
const context = {
|
||
page_url: window.location.href,
|
||
page_title: document.title,
|
||
timestamp: Date.now(),
|
||
};
|
||
|
||
const result = collectElements({
|
||
interactiveOnly: options.interactive_only || false,
|
||
maxElements: options.max_elements || 50,
|
||
offset: options.offset || 0,
|
||
container: options.container || null,
|
||
});
|
||
|
||
context.elements = result.elements;
|
||
context.element_count = result.elements.length;
|
||
context.total_count = result.totalCount;
|
||
context.offset = options.offset || 0;
|
||
context.has_more = result.hasMore;
|
||
context.ref_map = result.refMap;
|
||
|
||
return context;
|
||
};
|
||
}
|