// ==UserScript== // @name 酷家乐编辑器:动作录制 // @namespace tm.kujiale.actions // @version 1.0.0 // @description 在酷家乐参数化编辑器里录制/回放页面操作:DOM 点击、输入、以及「结构导航」选中项变化(画布拖拽不录)。 // @match https://www.kujiale.com/vc/modeleditor/* // @grant none // @run-at document-idle // ==/UserScript== /** * 设计要点(详见文件末尾「实现备忘」): * - 录制粒度:只记 点击 / 输入 / 结构导航选中项 三类,画布拖拽与鼠标移动一律不记。 * - 画布点击不记坐标:它改变的是「结构导航」里的选中项,所以只记选中项本身(element id + 名称)。 * - 回放选中项:走站点组件自己的 props.onSelect(id) —— 树行对合成鼠标事件不响应,直接调回调才可靠。 * - 回放点击:派发 pointerdown/mousedown/pointerup/mouseup + el.click();值为 React 受控输入则用原生 setter。 * - 存储:localStorage(键 kjlActionScripts.v1),并支持导出/导入 JSON。 */ (function () { 'use strict'; var VERSION = '1.0.0'; var LS_KEY = 'kjlActionScripts.v1'; var LS_SPEED = 'kjlActionSpeed'; var LS_POS = 'kjlArPanelPos'; // 面板被拖动后的位置 var MARK = 'data-kjl-ar'; // 本脚本所有 UI 的标记,录制时按它排除 /* ============================================================ 存储 */ function loadDB() { try { var raw = localStorage.getItem(LS_KEY); if (raw) { var db = JSON.parse(raw); if (db && Array.isArray(db.scripts)) { if (typeof db.seq !== 'number') db.seq = db.scripts.length + 1; return db; } } } catch (e) { /* 忽略损坏数据 */ } return { scripts: [], seq: 1 }; } function saveDB() { try { localStorage.setItem(LS_KEY, JSON.stringify(DB)); } catch (e) { console.warn('[动作录制] 保存失败', e); } } var DB = loadDB(); function newId() { DB.seq = (DB.seq || 1) + 1; return 'a' + DB.seq + '_' + Date.now().toString(36); } /* ============================================================ 小工具 */ function el(tag, cls, txt) { var e = document.createElement(tag); if (cls) e.className = cls; if (txt != null) e.textContent = txt; e.setAttribute(MARK, '1'); return e; } function sleep(ms) { return new Promise(function (r) { setTimeout(r, ms); }); } function isVisible(e) { if (!e || !e.getBoundingClientRect) return false; var r = e.getBoundingClientRect(); if (r.width <= 0 || r.height <= 0) return false; var cs = getComputedStyle(e); return cs.visibility !== 'hidden' && cs.display !== 'none' && cs.opacity !== '0'; } function now() { return Date.now(); } function esc(s) { return String(s == null ? '' : s).replace(/&/g, '&').replace(//g, '>'); } /* ---- 选择器:id > 稳定 class > nth-of-type 路径(思路借 Selenium IDE locatorBuilders) ---- */ var KEEP_CLS = /^(tui-btn--|tui-select|tui-input|tui-tree|tui-tabs|mode-label|auto-update-button)/; function stableCls(node) { var list = String(node.className || '').split(/\s+/).filter(function (c) { if (!c || c.length > 40) return false; if (/^kjl-ar/.test(c)) return false; if (/^(tui-|ant-|css-|mola-)/.test(c) && !KEEP_CLS.test(c)) return false; // 组件库通用类太多,只保留带语义的 return true; }); return list.slice(0, 2).join('.'); } function cssPath(node) { if (!node || node.nodeType !== 1) return ''; if (node.id) return '#' + (window.CSS && CSS.escape ? CSS.escape(node.id) : node.id); var parts = [], el0 = node, depth = 0; while (el0 && el0.nodeType === 1 && depth++ < 7) { var seg = el0.tagName.toLowerCase(); var sc = stableCls(el0); if (sc) seg += '.' + sc; var p = el0.parentElement; if (p) { var same = [].slice.call(p.children).filter(function (c) { return c.tagName === el0.tagName; }); if (same.length > 1) seg += ':nth-of-type(' + (same.indexOf(el0) + 1) + ')'; } parts.unshift(seg); if (el0.parentElement === document.body || el0 === document.body) break; el0 = el0.parentElement; } return parts.join(' > '); } function byPath(path) { if (!path) return null; try { return document.querySelector(path); } catch (e) { return null; } } // 选择器失效时,按「标签 + 文本」兜底找可见元素 function byText(tag, text) { if (!text) return null; var list = [].slice.call(document.querySelectorAll(tag || '*')); for (var i = 0; i < list.length; i++) { var e = list[i]; if (!isVisible(e)) continue; var t = (e.innerText || e.textContent || '').trim().replace(/\s+/g, ' '); if (t === text) return e; } return null; } // 选择器候选:原件 → 去掉 nth-of-type → 逐级砍掉开头段(类名改了也能命中尾部) function selVariants(sel) { var out = [], seen = {}; function add(x) { if (x && !seen[x]) { seen[x] = 1; out.push(x); } } if (!sel) return out; add(sel); add(sel.replace(/:nth-of-type\(\d+\)/g, '')); var segs = sel.split(' > '); if (segs.length > 1) { for (var k = 1; k < segs.length; k++) { var tail = segs.slice(k).join(' > '); add(tail); add(tail.replace(/:nth-of-type\(\d+\)/g, '')); } } return out; } // 找一个「全页可见元素里恰好只有一个」的匹配,多点命中宁可失败也不乱点 function uniqueVisible(sel) { var list; try { list = document.querySelectorAll(sel); } catch (e) { return null; } var vis = []; for (var i = 0; i < list.length; i++) if (isVisible(list[i])) vis.push(list[i]); return vis.length === 1 ? vis[0] : null; } // 「稳定祖先 + 后代搜索」候选:把 `a > b > c` 拆成 scope=`a`、desc=`b c`, // 这样中间被插了一层包装节点也还能找到(逐级缩短 scope,从长到短) function scopedVariants(sel) { var out = []; if (!sel) return out; var segs = sel.split(' > '); for (var i = 1; i < segs.length; i++) { var scope = segs.slice(0, i).join(' > '); var desc = segs.slice(i).join(' '); out.push({ scope: scope, desc: desc }); } return out; } function locate(step) { var all = selVariants(step.sel); var bases = all.filter(function (v) { return v.split(' > ').length >= 2; }); var weak = all.filter(function (v) { return v.split(' > ').length < 2; }); var hidden = null; // 1) 对每个裁剪变体:先整条命中,再「稳定祖先 + 后代搜索」(结构插了包装层也能救) for (var b = 0; b < bases.length; b++) { var base = bases[b]; var direct = byPath(base); if (direct) { if (isVisible(direct)) return direct; if (!hidden) hidden = direct; } var scopes = scopedVariants(base); for (var si = 0; si < scopes.length; si++) { var root = byPath(scopes[si].scope); if (!root || !isVisible(root)) continue; var inner; try { inner = root.querySelectorAll(scopes[si].desc); } catch (e) { continue; } for (var ii = 0; ii < inner.length; ii++) { if (isVisible(inner[ii])) return inner[ii]; } } } // 2) 输入框 placeholder if (step.kind === 'input' && step.ph) { var inp = document.querySelector(step.tag + '[placeholder="' + step.ph.replace(/"/g, '\\"') + '"]'); if (inp && isVisible(inp)) return inp; } // 3) 祖先文本锚点(缩略图/图标类元素自身没文字,靠卡片标题定位) if (step.anc && step.anc.length) { for (var a = 0; a < step.anc.length; a++) { var hit = byText(step.anc[a].tag, step.anc[a].text); if (hit) { var clickable = hit.closest('li, a, button, [role="button"]'); return clickable && isVisible(clickable) ? clickable : hit; } } } // 4) 自身文本 var self = byText(step.tag, step.text); if (self) return self; // 5) 弱候选(只有 1 段):必须全页唯一可见才用,否则宁可失败也不乱点 for (var w = 0; w < weak.length; w++) { var uniq = uniqueVisible(weak[w]); if (uniq) return uniq; } return hidden; } // 在 fiber 链上找满足条件的 props(右栏属性行要的是「有 onChange 且有 eventKey」那种) function propsWhere(node, test) { if (!node) return null; var keys = Object.getOwnPropertyNames(node).filter(function (k) { return k.indexOf('__reactInternalInstance') === 0; }); if (!keys.length) return null; var f = node[keys[0]], d = 0; while (f && d++ < 26) { var cands = [f.memoizedProps, f.alternate && f.alternate.memoizedProps]; for (var i = 0; i < cands.length; i++) { var p = cands[i]; if (p && test(p)) return p; } f = f.return; } return null; } /* ---- 从 React fiber 上取指定名字的回调(拿不到就返回 null) ---- */ function propsWith(node, fnName) { if (!node) return null; var keys = Object.getOwnPropertyNames(node).filter(function (k) { return k.indexOf('__reactInternalInstance') === 0; }); if (!keys.length) return null; var f = node[keys[0]], d = 0; while (f && d++ < 24) { var cands = [f.memoizedProps, f.alternate && f.alternate.memoizedProps]; for (var i = 0; i < cands.length; i++) { var p = cands[i]; if (p && typeof p[fnName] === 'function') return p; } f = f.return; } return null; } /* ============================================================ 结构导航:读选中项 */ function elementListPanel() { var ps = [].slice.call(document.querySelectorAll('.mola-floating-panel')); for (var i = 0; i < ps.length; i++) { if (ps[i].querySelector('.element-list-panel')) return ps[i].querySelector('.element-list-panel'); } return null; } function nodeRowEl(eid) { return document.getElementById('element-tree-row-' + eid); } function selectedNode() { var row = document.querySelector('.element-list-panel .element-tree-row.selected'); if (!row) return null; var li = row.closest('li.tui-tree-new-node'); var name = li ? ((li.querySelector('.tui-tree-new-node-title') || {}).textContent || '').trim() : ''; return { eid: String(row.id).replace('element-tree-row-', ''), name: name }; } // 结构导航里某个节点所在的层级路径(「平板柜门 / 通用开门门铰」这种),仅用于展示 function nodePath(li) { var out = [], cur = li; while (cur) { var t = cur.querySelector(':scope > .tui-tree-new-node-content .tui-tree-new-node-title'); if (t && t.textContent.trim()) out.unshift(t.textContent.trim()); var ul = cur.parentElement; cur = ul && ul.closest ? ul.closest('li.tui-tree-new-node') : null; } return out.join(' / '); } /* ============================================================ 右栏属性行(直接改值,不点击) */ function attrRowName(li) { var t = li.querySelector('.item-title'); if (t && (t.textContent || '').trim()) return (t.textContent || '').trim(); var p = li.querySelector('.input-n-content p'); if (p && (p.textContent || '').trim()) return (p.textContent || '').trim(); return ''; } // 取某一行的写入入口:优先用下拉控件节点,拿不到再用输入框 / 行本身 function attrPropsOf(li) { if (!li) return null; var hosts = [li.querySelector('.attr-select-input'), li.querySelector('.item-content-container input'), li]; for (var i = 0; i < hosts.length; i++) { if (!hosts[i]) continue; var p = propsWhere(hosts[i], function (q) { return typeof q.onChange === 'function' && q.eventKey !== undefined; }); if (p) return p; } return null; } // 当前右栏所有可写属性的快照:key = eventKey|行名,value = 原始值 function attrSnapshot() { var map = {}; var rows = [].slice.call(document.querySelectorAll('.attr-panel li.attr-list-item')); rows.forEach(function (li) { var p = attrPropsOf(li); if (!p) return; map[p.eventKey + '|' + attrRowName(li)] = String(p.value == null ? '' : p.value); }); return map; } // 把「值变了」这件事记成一步 var ATTR_BASE = null; var ATTR_DEBOUNCE = 0; var ATTR_SKIP_ONCE = false; // 选中项刚变时整片属性都会变,要跳过这一轮 function attrCheck() { if (!REC) return; var now = attrSnapshot(); if (!ATTR_BASE || ATTR_SKIP_ONCE) { ATTR_BASE = now; ATTR_SKIP_ONCE = false; return; } Object.keys(now).forEach(function (key) { if (ATTR_BASE[key] === undefined) return; // 新增的行不算「改了值」 if (ATTR_BASE[key] === now[key]) return; var parts = key.split('|'); var eventKey = parts[0], name = parts.slice(1).join('|'); pushStep({ kind: 'setattr', eventKey: eventKey, name: name, value: now[key], label: (name || eventKey) + ' → ' + (now[key] === '' ? '(空)' : now[key]) }); }); ATTR_BASE = now; } function startAttrWatch() { ATTR_BASE = attrSnapshot(); ATTR_SKIP_ONCE = false; var panel = document.querySelector('.attr-panel'); if (!panel) { setTimeout(startAttrWatch, 800); return; } var obs = new MutationObserver(function () { clearTimeout(ATTR_DEBOUNCE); ATTR_DEBOUNCE = setTimeout(attrCheck, 160); }); obs.observe(panel, { subtree: true, childList: true, attributes: true, characterData: true }); REC.attrObs = obs; } function stopAttrWatch() { if (REC && REC.attrObs) { REC.attrObs.disconnect(); REC.attrObs = null; } clearTimeout(ATTR_DEBOUNCE); ATTR_BASE = null; } /* ============================================================ 录制器 */ var REC = null; // { id, steps, t0, lastEid, panelObs, timer } function recordingScript() { return REC ? DB.scripts.filter(function (s) { return s.id === REC.id; })[0] || null : null; } function pushStep(step) { if (!REC) return; step.t = now() - REC.t0; REC.steps.push(step); var sc = recordingScript(); if (sc) { sc.steps = REC.steps; sc.updated = now(); saveDB(); } renderList(); } function clickKind(target) { // 归类:只在「修改」窗口里显示得好懂一点,回放行为一致 if (target.closest('.attr-select-input-dropdown, .tui-select-dropdown, [class*="dropdown"]')) return '选择'; if (target.closest('input[type=checkbox], .tui-switch, [class*="checkbox"]')) return '勾选'; if (target.closest('li.attr-list-item, .parameter-row-container')) return '点击'; return '点击'; } // 目标所在的标签页名(左侧栏那种切换面板,回放前要先切回去,否则元素被 display:none 藏住) function tabOf(t) { var bar = t.closest('.left-sidebar') ? t.closest('.left-sidebar') : null; var scope = bar || document; var act = scope.querySelector('.tui-tabs-bar-tab-active, .tui-tabs-bar-tab.active'); var name = act ? (act.textContent || '').trim() : ''; return name || ''; } function onRecClick(e) { if (!REC || e.button !== 0) return; var t = e.target; if (!t || t.closest('[' + MARK + ']')) return; // 本脚本自己的 UI if (t.closest('.element-list-panel')) return; // 结构导航树:由选中项监听负责记 if (t.closest('canvas') || t.closest('.three-view')) return; // 画布:不记坐标动作 if (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA') { // 输入框的点击由 input 步覆盖 if (['checkbox', 'radio', 'file', 'button', 'submit'].indexOf(t.type) < 0) return; } if (t.closest('.attr-select-input')) return; // 右栏下拉(含浮层):值变化由 setattr 步承载 if (t.closest('.attr-panel li.attr-list-item') && t.tagName === 'INPUT') return; var path = cssPath(t); var txt = (t.innerText || t.textContent || '').trim().replace(/\s+/g, ' '); // 祖先文本锚点:图标/缩略图这类自身没文字的元素,回放时靠父卡片标题定位 var anc = []; if (!txt) { var up = t.parentElement, hops = 0; while (up && hops++ < 5 && anc.length < 3) { var at = (up.innerText || up.textContent || '').trim().replace(/\s+/g, ' '); if (at && at.length <= 30 && (!anc.length || anc[anc.length - 1].text !== at)) { anc.push({ tag: up.tagName.toLowerCase(), text: at }); } up = up.parentElement; } } pushStep({ kind: 'click', sub: clickKind(t), sel: path, tag: t.tagName.toLowerCase(), text: txt.slice(0, 20), label: txt.slice(0, 30), tab: tabOf(t), anc: anc }); } // 文本输入:原生 change 只在 blur 时给,用户可能直接点别处甚至去选中模型。 // 所以 input 也记,但做 500ms 去抖 + 值去重,避免一个字一步。 function recordInput(t, immediate) { if (!REC || !t || t.closest('[' + MARK + ']')) return; if (t.closest('.attr-panel li.attr-list-item')) return; // 右栏属性行的值交给 setattr 步 var tag = t.tagName; if (tag !== 'INPUT' && tag !== 'TEXTAREA') return; if (t.type === 'checkbox' || t.type === 'radio' || t.type === 'file') return; var v = t.value; if (v === (t.dataset.kjlArLast || '')) return; var doPush = function () { if (!REC) return; var cur = t.value; if (cur === (t.dataset.kjlArLast || '')) return; t.dataset.kjlArLast = cur; pushStep({ kind: 'input', sel: cssPath(t), tag: tag.toLowerCase(), ph: t.placeholder || '', value: cur, label: (t.placeholder || t.name || tag.toLowerCase()) + ' = ' + String(cur).slice(0, 30) }); }; if (immediate) { clearTimeout(t.__kjlArT); doPush(); return; } clearTimeout(t.__kjlArT); t.__kjlArT = setTimeout(doPush, 500); } // 双击:站点很多地方(部件库文件夹等)只认 dblclick,必须单独记一步 function onRecDblClick(e) { if (!REC || e.button !== 0) return; var t = e.target; if (!t || t.closest('[' + MARK + ']')) return; if (t.closest('.element-list-panel') || t.closest('canvas') || t.closest('.three-view')) return; var path = cssPath(t); // 把前 700ms 内针对同一元素的点击步删掉(它们是这次双击的前半段) var cut = now() - 700; for (var i = REC.steps.length - 1; i >= 0; i--) { var st = REC.steps[i]; if (st.t < cut - REC.t0) break; if (st.kind === 'click' && st.sel === path) { REC.steps.splice(i, 1); } } var txt = (t.innerText || t.textContent || '').trim().replace(/\s+/g, ' '); pushStep({ kind: 'dblclick', sub: '双击', sel: path, tag: t.tagName.toLowerCase(), text: txt.slice(0, 20), label: txt.slice(0, 30), tab: tabOf(t) }); } // ===== 左栏参数拖动排序(HTML5 DnD + SortableJS)===== var DRAG_SNAP = null; function rowKey(li) { var idEl = li.querySelector('.param-row-item-id'); var nmEl = li.querySelector('.param-row-item-name'); return (idEl && (idEl.textContent || '').trim()) || (nmEl && (nmEl.textContent || '').trim()) || ''; } function listKeys(list) { return [].slice.call(list.children).map(rowKey); } function arDbg(o) { try { if (!window.__AR_DEBUG) return; (window.__arDbg = window.__arDbg || []).push(o); } catch (x) { } } function onRecDragStart(e) { if (!REC) return; var t = e.target; var row = t && t.closest ? t.closest('li.parameter-row-container') : null; if (!row || !row.parentElement) { DRAG_SNAP = null; return; } DRAG_SNAP = { list: row.parentElement, order: listKeys(row.parentElement), key: rowKey(row), name: (row.querySelector('.param-row-item-name') || {}).textContent || '' }; arDbg({ t: 'dragstart', key: DRAG_SNAP.key, order: DRAG_SNAP.order }); } function onRecDragEnd() { if (!REC || !DRAG_SNAP) { DRAG_SNAP = null; return; } var snap = DRAG_SNAP; DRAG_SNAP = null; // dragend 那一刻 React 往往还没按新顺序重排,所以要等一拍再取快照 setTimeout(function () { inspectDragResult(snap); }, 420); } function inspectDragResult(snap) { if (!REC) return; // 拖完 React 可能重建了列表节点,旧引用会失效 → 按 movedKey 重新找当前所在的列表 var list = (snap.list && snap.list.isConnected) ? snap.list : null; if (!list) { var all = [].slice.call(document.querySelectorAll('.parameter-list li.parameter-row-container')); for (var i = 0; i < all.length; i++) { if (rowKey(all[i]) === snap.key) { list = all[i].parentElement; break; } } } if (!list) return; var now = listKeys(list); var from = snap.order.indexOf(snap.key); var to = now.indexOf(snap.key); arDbg({ t: 'dragcheck', key: snap.key, before: snap.order, now: now, from: from, to: to }); if (from < 0 || to < 0 || from === to) return; var afterKey = to + 1 < now.length ? now[to + 1] : null; pushStep({ kind: 'sort', sub: '排序', movedKey: snap.key, name: (snap.name || '').trim(), from: from, to: to, afterKey: afterKey, label: (snap.name || snap.key) + ' → 第 ' + (to + 1) + ' 位' }); } function onRecInput(e) { var t = e.target; if (!t || (t.tagName !== 'INPUT' && t.tagName !== 'TEXTAREA')) return; if (t.closest('[' + MARK + ']')) return; recordInput(t, false); } function onRecChange(e) { var t = e.target; if (!t || t.closest('[' + MARK + ']')) return; if (t.tagName !== 'INPUT' && t.tagName !== 'TEXTAREA' && t.tagName !== 'SELECT') return; if (t.type === 'checkbox' || t.type === 'radio' || t.type === 'file') return; // 勾选用点击步表示 recordInput(t, true); } function startRecObserve() { var panel = elementListPanel(); if (!panel) { // 面板可能还没渲染,稍后再试 REC.timer = setTimeout(startRecObserve, 800); return; } var last = selectedNode(); REC.lastEid = last ? last.eid : null; REC.panelObs = new MutationObserver(function () { if (!REC) return; clearTimeout(REC.nodeDeb); REC.nodeDeb = setTimeout(function () { var cur = selectedNode(); if (!cur || cur.eid === REC.lastEid) return; REC.lastEid = cur.eid; ATTR_SKIP_ONCE = true; // 换选中项会整片属性重刷,不算"改了值" var li = nodeRowEl(cur.eid) && nodeRowEl(cur.eid).closest('li.tui-tree-new-node'); pushStep({ kind: 'node', eid: cur.eid, name: cur.name, path: li ? nodePath(li) : cur.name, label: cur.name }); }, 60); }); REC.panelObs.observe(panel, { subtree: true, attributes: true, attributeFilter: ['class'], childList: true }); } function startRecord(script) { if (REC) stopRecord(); REC = { id: script.id, steps: [], t0: now(), lastEid: null, panelObs: null, nodeDeb: 0, timer: 0 }; script.steps = REC.steps; if (script.fail) { delete script.fail; saveDB(); } document.addEventListener('click', onRecClick, true); document.addEventListener('change', onRecChange, true); document.addEventListener('input', onRecInput, true); document.addEventListener('dblclick', onRecDblClick, true); document.addEventListener('dragstart', onRecDragStart, true); document.addEventListener('dragend', onRecDragEnd, true); startRecObserve(); startAttrWatch(); document.body.classList.add('kjl-ar-recording'); renderList(); hidePanel(); // 录制时把面板收起来,免得挡住要操作的地方 updateBtnState(); toast('开始录制:' + script.name + '(点左上角方块停止)'); } function stopRecord() { if (!REC) return null; document.removeEventListener('click', onRecClick, true); document.removeEventListener('change', onRecChange, true); document.removeEventListener('input', onRecInput, true); document.removeEventListener('dblclick', onRecDblClick, true); document.removeEventListener('dragstart', onRecDragStart, true); document.removeEventListener('dragend', onRecDragEnd, true); DRAG_SNAP = null; if (REC.panelObs) { REC.panelObs.disconnect(); REC.panelObs = null; } stopAttrWatch(); clearTimeout(REC.nodeDeb); clearTimeout(REC.timer); var sc = recordingScript(); var n = REC.steps.length; REC = null; document.body.classList.remove('kjl-ar-recording'); if (sc) { sc.updated = now(); saveDB(); } renderList(); updateBtnState(); toast('录制结束,共 ' + n + ' 步'); return sc; } /* ============================================================ 回放 */ var PLAY = null; // { stop:false, id } function fireClick(target) { var r = target.getBoundingClientRect(); var cx = r.left + r.width / 2, cy = r.top + r.height / 2; var base = { bubbles: true, cancelable: true, view: window, clientX: cx, clientY: cy, button: 0 }; try { target.dispatchEvent(new PointerEvent('pointerdown', Object.assign({}, base, { pointerId: 1, pointerType: 'mouse', buttons: 1 }))); } catch (e) { } try { target.dispatchEvent(new MouseEvent('mousedown', base)); } catch (e) { } try { target.dispatchEvent(new PointerEvent('pointerup', Object.assign({}, base, { pointerId: 1, pointerType: 'mouse', buttons: 0 }))); } catch (e) { } try { target.dispatchEvent(new MouseEvent('mouseup', base)); } catch (e) { } try { target.click(); } catch (e) { } } // 双击:两套按下/抬起/click,再补一个 dblclick(React 的 onDoubleClick 只认后者) function fireDblClick(target) { var r = target.getBoundingClientRect(); var cx = r.left + r.width / 2, cy = r.top + r.height / 2; var base = { bubbles: true, cancelable: true, view: window, clientX: cx, clientY: cy, button: 0 }; [1, 2].forEach(function (d) { var o = Object.assign({}, base, { detail: d }); try { target.dispatchEvent(new PointerEvent('pointerdown', Object.assign({}, o, { pointerId: 1, pointerType: 'mouse', buttons: 1 }))); } catch (e) { } try { target.dispatchEvent(new MouseEvent('mousedown', o)); } catch (e) { } try { target.dispatchEvent(new PointerEvent('pointerup', Object.assign({}, o, { pointerId: 1, pointerType: 'mouse', buttons: 0 }))); } catch (e) { } try { target.dispatchEvent(new MouseEvent('mouseup', o)); } catch (e) { } try { target.click(); } catch (e) { } }); try { target.dispatchEvent(new MouseEvent('dblclick', Object.assign({}, base, { detail: 2 }))); } catch (e) { } } // 回放前先切到录制时所在的标签页(否则目标被 display:none 藏住,永远找不到) async function ensureTab(name) { if (!name) return; var act = document.querySelector('.tui-tabs-bar-tab-active, .tui-tabs-bar-tab.active'); if (act && (act.textContent || '').trim() === name) return; var tabs = [].slice.call(document.querySelectorAll('.tui-tabs-bar-tab')); for (var i = 0; i < tabs.length; i++) { if ((tabs[i].textContent || '').trim() === name && isVisible(tabs[i])) { fireClick(tabs[i]); await sleep(350); return; } } } // 找到容器上的 SortableJS 实例(站点用 Sortable 存成 Sortable<时间戳> 属性) function sortableOf(list) { var key = Object.getOwnPropertyNames(list).filter(function (k) { return k.indexOf('Sortable') === 0; })[0]; return key ? list[key] : null; } // 右栏属性回放:不走点击,直接调站点组件的 onChange({ eventKey, value }) async function applySetAttr(step) { var rows = [].slice.call(document.querySelectorAll('.attr-panel li.attr-list-item')); var hit = null; for (var i = 0; i < rows.length; i++) { var p = attrPropsOf(rows[i]); if (!p) continue; if (String(p.eventKey) === String(step.eventKey) && (!step.name || attrRowName(rows[i]) === step.name)) { hit = { li: rows[i], p: p }; break; } } if (!hit && step.name) { // eventKey 可能随会话变化 → 退回按行名找 for (var j = 0; j < rows.length; j++) { if (attrRowName(rows[j]) === step.name) { var p2 = attrPropsOf(rows[j]); if (p2) { hit = { li: rows[j], p: p2 }; break; } } } } if (!hit) throw new Error('右栏找不到属性「' + (step.name || step.eventKey) + '」'); if (String(hit.p.value) === String(step.value)) return; // 已经是目标值 hit.p.onChange({ eventKey: hit.p.eventKey, value: step.value }); await sleep(300); } // 参数排序回放: // 站点用的是 HTML5 拖拽(SortableJS),合成 drag 事件浏览器不认; // 实测可行路径 = 先按目标位置移动 DOM,再调站点自己的 onEnd(oldIndex,newIndex) 让它写回模型。 async function applySort(step) { var rows = [].slice.call(document.querySelectorAll('.parameter-list li.parameter-row-container')); var row = null; for (var i = 0; i < rows.length; i++) { if (rowKey(rows[i]) === step.movedKey) { row = rows[i]; break; } } if (!row) throw new Error('左栏找不到参数「' + (step.name || step.movedKey) + '」'); var list = row.parentElement; var kids = [].slice.call(list.children); var cur = kids.indexOf(row); var ref = null; if (step.afterKey) { for (var j = 0; j < kids.length; j++) { if (kids[j] !== row && rowKey(kids[j]) === step.afterKey) { ref = kids[j]; break; } } } list.insertBefore(row, ref); var newIdx = [].slice.call(list.children).indexOf(row); var inst = sortableOf(list); if (inst && inst.options && typeof inst.options.onEnd === 'function') { inst.options.onEnd({ oldIndex: cur, newIndex: newIdx, item: row, from: list, to: list }); } await sleep(420); var nowIdx = [].slice.call(list.children).indexOf(row); if (nowIdx !== newIdx) throw new Error('站点没有接受这次参数排序'); } function setNativeValue(input, value) { var proto = input.tagName === 'TEXTAREA' ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype; var desc = Object.getOwnPropertyDescriptor(proto, 'value'); if (desc && desc.set) desc.set.call(input, value); else input.value = value; input.dispatchEvent(new Event('input', { bubbles: true })); input.dispatchEvent(new Event('change', { bubbles: true })); } async function execStep(step) { if (step.kind === 'setattr') { await applySetAttr(step); return; } if (step.kind === 'sort') { await applySort(step); return; } if (step.kind === 'node') { var row = nodeRowEl(step.eid); if (!row) { // id 可能因重新加载而变,按名称兜底 var rows = [].slice.call(document.querySelectorAll('.element-list-panel .element-tree-row')); row = rows.filter(function (r) { var li = r.closest('li.tui-tree-new-node'); var t = li ? (li.querySelector('.tui-tree-new-node-title') || {}).textContent : ''; return (t || '').trim() === step.name; })[0] || null; } if (!row) throw new Error('结构导航里找不到「' + (step.name || step.eid) + '」'); var p = propsWith(row, 'onSelect'); if (!p) throw new Error('选中项没有 onSelect 回调'); p.onSelect(p.id != null ? p.id : step.eid); await sleep(180); // 等站点把选中态/右栏刷完 return; } if (step.tab) await ensureTab(step.tab); var target = locate(step); if (!target) throw new Error('找不到元素:' + (step.label || step.sel || '')); try { target.scrollIntoView({ block: 'center' }); } catch (e) { } if (step.kind === 'input') { target.focus && target.focus(); setNativeValue(target, step.value); await sleep(80); try { target.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); } catch (e) { } try { target.blur && target.blur(); } catch (e) { } await sleep(120); } else if (step.kind === 'dblclick') { fireDblClick(target); await sleep(300); } else { fireClick(target); await sleep(160); } } async function runScript(script, opts) { if (PLAY || REC) return; var steps = (script.steps || []).slice(); if (!steps.length) { toast('这个动作还没有录制内容'); return; } PLAY = { stop: false, id: script.id, failed: null }; renderList(); hidePanel(); // 运行期间收起面板,避免挡住被操作的区域 updateBtnState(); var total = steps.length, pw = String(total).length; var lastShown = 0; var showStep = function (i) { lastShown = i; showProgress('正在执行 ' + padNum(i, pw) + '/' + total + ' 步'); }; showStep(0); var speed = opts.speed || 1; // 倍速 var idleCap = opts.idleCap || 800; // 静默段压缩上限 var okCount = 0; for (var i = 0; i < steps.length; i++) { if (PLAY.stop) break; var gap = i === 0 ? 80 : (steps[i].t - steps[i - 1].t); if (!(gap >= 0)) gap = 0; var wait = gap > 2000 ? idleCap : gap / speed; if (wait > 0) await sleep(wait); if (PLAY.stop) break; showStep(i + 1); try { await execStep(steps[i]); okCount++; highlightStep(i); } catch (err) { PLAY.failed = { index: i, msg: err && err.message }; break; } } var failed = PLAY.failed; var stopped = PLAY.stop; PLAY = null; updateBtnState(); renderList(); showPanel(); if (failed) { // 失败不再弹文字:直接把动作名称标红(详情在名称的 tooltip 里) hideProgress(); if (script) { script.fail = { i: failed.index + 1, msg: failed.msg || '', at: now() }; saveDB(); renderList(); } } else if (stopped) { showProgress('已终止 ' + padNum(lastShown, pw) + '/' + total + ' 步', true); } else { hideProgress(); if (script && script.fail) { // 跑通了就清掉失败标记 delete script.fail; saveDB(); renderList(); } } if (!failed) toast('回放完成(' + okCount + ' 步)'); } /* ============================================================ 面板 UI */ var UI = {}; // { btn, panel, list, ctx, modal } function injectCss() { if (document.getElementById('kjl-ar-style')) return; var st = document.createElement('style'); st.id = 'kjl-ar-style'; st.textContent = [ '.kjl-ar-btn{position:fixed;width:32px;height:32px;border-radius:50%;background:#fff;border:0;', ' box-shadow:0 2px 5px rgba(0,0,0,.1);display:flex;align-items:center;justify-content:center;cursor:pointer;', ' color:#4e5969;z-index:99990;transition:background .12s,color .12s,box-shadow .12s}', '.kjl-ar-btn:hover{color:#1a7af8;box-shadow:0 3px 10px rgba(26,122,248,.28)}', '.kjl-ar-btn.on{background:#1a7af8;color:#fff}', '.kjl-ar-btn.play{background:#ff7d00;border:0;color:#fff}', '.kjl-ar-prog{position:fixed;z-index:99991;height:24px;padding:0 10px;border-radius:12px;', ' background:rgba(29,33,41,.88);color:#fff;font-size:12px;line-height:24px;white-space:nowrap;', ' box-shadow:0 2px 8px rgba(0,0,0,.18);pointer-events:none;display:none;align-items:center;gap:6px}', '.kjl-ar-prog.sticky{pointer-events:auto;padding-right:6px;background:rgba(214,45,32,.93)}', '.kjl-ar-prog .x{border:0;background:rgba(255,255,255,.18);color:#fff;width:18px;height:18px;', ' border-radius:50%;cursor:pointer;font-size:11px;line-height:1;padding:0;display:flex;', ' align-items:center;justify-content:center;flex:none}', '.kjl-ar-prog .x:hover{background:rgba(255,255,255,.34)}', '.kjl-ar-btn.rec{background:#f53f3f;border-color:#f53f3f;color:#fff;animation:kjlArPulse 1.2s infinite}', '@keyframes kjlArPulse{0%,100%{box-shadow:0 0 0 0 rgba(245,63,63,.5)}50%{box-shadow:0 0 0 6px rgba(245,63,63,0)}}', '.kjl-ar-panel{position:fixed;z-index:99992;width:330px;max-height:min(520px,70vh);background:#fff;border-radius:8px;', ' box-shadow:0 8px 32px rgba(0,0,0,.16);display:flex;flex-direction:column;font-size:13px;color:#1d2129;overflow:hidden;', " font-family:'Sarasa Mono SC','Microsoft YaHei',sans-serif}", '.kjl-ar-head{height:36px;flex:none;display:flex;align-items:center;gap:6px;padding:0 10px;border-bottom:1px solid #f0f1f3;', ' background:#fafbfc;cursor:move;user-select:none}', '.kjl-ar-head b{font-weight:600;font-size:13px}', '.kjl-ar-ver{color:#c9cdd4;font-size:11px}', '.kjl-ar-head .sp{flex:1}', '.kjl-ar-ico{width:22px;height:22px;border:none;background:transparent;color:#86909c;border-radius:4px;cursor:pointer;', ' display:flex;align-items:center;justify-content:center;font-size:14px;line-height:1;padding:0}', '.kjl-ar-ico:hover{background:#eef3ff;color:#1a7af8}', '.kjl-ar-ico-svg svg{display:block;pointer-events:none}', '.kjl-ar-ico-svg{color:#86909c}', '.kjl-ar-ico-svg:hover{color:#1a7af8}', '.kjl-ar-del{display:none;color:#d81e06}', '.kjl-ar-del:hover{background:#ffece8;color:#d81e06}', '.kjl-ar-panel.dragging .kjl-ar-del{display:flex}', '.kjl-ar-del.over{background:#d81e06;color:#fff;transform:scale(1.12)}', '.kjl-ar-drop{position:fixed;z-index:1000004;pointer-events:none;white-space:nowrap;color:#d81e06;', ' font-size:13px;font-weight:700;background:#fff;padding:3px 9px;border-radius:5px;', ' box-shadow:0 3px 10px rgba(216,30,6,.28)}', '.kjl-ar-ico[disabled]{opacity:.4;cursor:default}', '.kjl-ar-tools{flex:none;display:flex;align-items:center;gap:6px;padding:6px 8px;border-bottom:1px solid #f0f1f3}', '.kjl-ar-add{height:26px;padding:0 10px;border:1px solid #1a7af8;background:#1a7af8;color:#fff;border-radius:4px;', ' font-size:12px;cursor:pointer;white-space:nowrap}', '.kjl-ar-add:hover{background:#3f8ef9}', '.kjl-ar-sel{height:26px;border:1px solid #e5e6eb;border-radius:4px;font-size:12px;color:#4e5969;background:#fff;padding:0 4px}', '.kjl-ar-tools .sp{flex:1}', '.kjl-ar-list{flex:1;overflow:auto;padding:4px 0;min-height:60px}', '.kjl-ar-row{display:flex;align-items:center;gap:6px;height:30px;padding:0 8px;cursor:default;user-select:none}', '.kjl-ar-row:hover{background:rgba(0,255,24,.48)}', '.kjl-ar-row.sel{background:rgba(251,234,0,.61)}', '.kjl-ar-row.sel .nm{color:#1d2129}', '.kjl-ar-row .nm{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}', '.kjl-ar-row .cnt{color:#c9cdd4;font-size:11px;flex:none}', '.kjl-ar-row .act{flex:none;display:flex;gap:2px;opacity:0;transition:opacity .1s}', '.kjl-ar-row:hover .act{opacity:1}', '.kjl-ar-row.busy{background:#fff7e8!important}', '.kjl-ar-row.rec{background:#fff1f0!important;box-shadow:inset 3px 0 0 #f53f3f}', '.kjl-ar-row .act .stop{color:#f53f3f!important}', '.kjl-ar-name-in{flex:1;height:22px;border:1px solid #1a7af8;border-radius:3px;padding:0 4px;font-size:13px;', " font-family:inherit;outline:none;min-width:0}", '.kjl-ar-empty{color:#c9cdd4;text-align:center;padding:18px 0;font-size:12px}', '.kjl-ar-row{cursor:grab}', '.kjl-ar-row-dragging{cursor:grabbing;opacity:.32;background:#eef3ff!important}', '.kjl-ar-ghost{position:fixed;z-index:1000002;pointer-events:none;background:#fff;border-radius:6px;', ' box-shadow:0 10px 28px rgba(0,0,0,.18);opacity:.55}', 'body.kjl-ar-dragging{user-select:none!important;cursor:grabbing!important}', '.kjl-ar-name-in{cursor:text}', '.kjl-ar-menu{position:fixed;z-index:99995;background:#fff;border:1px solid #e5e6eb;border-radius:6px;', ' box-shadow:0 6px 20px rgba(0,0,0,.14);padding:4px 0;font-size:13px;min-width:132px}', '.kjl-ar-mi{padding:6px 14px;cursor:pointer;display:flex;align-items:center;gap:8px}', '.kjl-ar-mi:hover{background:#f2f7ff;color:#1a7af8}', '.kjl-ar-mi.dis{color:#c9cdd4;cursor:default;pointer-events:none}', '.kjl-ar-mi.danger{color:#f53f3f}', '.kjl-ar-mi.danger:hover{background:#fff1f0}', '.kjl-ar-mi .k{margin-left:auto;color:#c9cdd4;font-size:11px}', '.kjl-ar-mask{position:fixed;inset:0;z-index:99996;background:rgba(0,0,0,.36);display:flex;align-items:center;justify-content:center}', '.kjl-ar-dlg{width:760px;max-width:92vw;max-height:76vh;background:#fff;border-radius:10px;display:flex;flex-direction:column;overflow:hidden}', '.kjl-ar-dlg .h{height:44px;flex:none;display:flex;align-items:center;gap:8px;padding:0 14px;border-bottom:1px solid #f0f1f3}', '.kjl-ar-dlg .h b{font-size:14px}', '.kjl-ar-dlg .h .sp{flex:1}', '.kjl-ar-dlg .bd{flex:1;overflow:auto;padding:6px 0}', '.kjl-ar-dlg .ft{flex:none;height:46px;display:flex;align-items:center;justify-content:flex-end;gap:8px;padding:0 14px;border-top:1px solid #f0f1f3}', '.kjl-ar-step{display:flex;align-items:center;gap:8px;padding:5px 14px;font-size:12px;border-bottom:1px dashed #f4f5f7}', '.kjl-ar-step:hover{background:#fafbfc}', '.kjl-ar-step .ix{width:28px;color:#c9cdd4;flex:none;text-align:right}', '.kjl-ar-step .kd{flex:none;padding:1px 6px;border-radius:3px;font-size:11px;background:#eef3ff;color:#1a7af8}', '.kjl-ar-step .kd.node{background:#e8ffea;color:#00a53f}', '.kjl-ar-step .kd.input{background:#fff7e8;color:#d27600}', '.kjl-ar-step .kd.sort{background:#f5edff;color:#7b3ff2}', '.kjl-ar-step .kd.setattr{background:#e8f7ff;color:#0a6cc4}', '.kjl-ar-step .tx{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}', '.kjl-ar-step .tt{flex:none;width:52px;color:#c9cdd4;text-align:right}', '.kjl-ar-step .del{flex:none;border:none;background:transparent;color:#c9cdd4;cursor:pointer;font-size:13px}', '.kjl-ar-step .del:hover{color:#f53f3f}', '.kjl-ar-step.hl{background:#fffbe6}', '.kjl-ar-btn2{height:28px;padding:0 14px;border-radius:4px;border:1px solid #e5e6eb;background:#fff;color:#4e5969;font-size:12px;cursor:pointer}', '.kjl-ar-btn2:hover{border-color:#1a7af8;color:#1a7af8}', '.kjl-ar-btn2.pri{background:#1a7af8;border-color:#1a7af8;color:#fff}', '.kjl-ar-btn2.pri:hover{background:#3f8ef9}', '.kjl-ar-toast{position:fixed;left:50%;bottom:64px;transform:translateX(-50%);z-index:99999;background:rgba(29,33,41,.9);', ' color:#fff;padding:8px 16px;border-radius:6px;font-size:12px;pointer-events:none;opacity:0;transition:opacity .18s}', '.kjl-ar-toast.on{opacity:1}', '.kjl-ar-toast.err{background:rgba(214,45,32,.94)}' ].join('\n'); document.head.appendChild(st); } var toastEl = null, toastT = 0; function toast(msg, isErr) { if (!toastEl) { toastEl = el('div', 'kjl-ar-toast'); document.body.appendChild(toastEl); } toastEl.textContent = msg; toastEl.className = 'kjl-ar-toast on' + (isErr ? ' err' : ''); clearTimeout(toastT); toastT = setTimeout(function () { toastEl.className = 'kjl-ar-toast' + (isErr ? ' err' : ''); }, 2200); } /* ---- 触发按钮:贴在「结构导航」图标左边 ---- */ // 按钮的两个图标:空闲=录制图标(同心圆),录制中=停止图标(方块) var ICON_IDLE = '' + ''; var ICON_STOP = '' + ''; // 导出 / 导入 图标(用户提供,改 15px 并用 currentColor 跟随按钮颜色) var ICON_EXPORT = ''; var ICON_DEL = ''; var ICON_IMPORT = ''; function updateBtnState() { if (!UI.btn) return; var rec = !!REC, play = !!PLAY; UI.btn.innerHTML = (rec || play) ? ICON_STOP : ICON_IDLE; UI.btn.title = rec ? '停止录制(点击结束并显示窗口)' : play ? '停止运行(点击终止并显示窗口)' : '动作录制'; UI.btn.classList.toggle('rec', rec); UI.btn.classList.toggle('play', play); if (rec || play) UI.btn.classList.remove('on'); } // 运行进度胶囊:贴在按钮右侧,显示「正在执行 09/25 步」 function padNum(n, w) { var t = String(n); while (t.length < w) t = '0' + t; return t; } function placeProgress() { if (!UI.prog || !UI.btn || UI.prog.style.display === 'none') return; var b = UI.btn.getBoundingClientRect(); UI.prog.style.left = Math.round(b.right + 8) + 'px'; UI.prog.style.top = Math.round(b.top + (b.height - 24) / 2) + 'px'; } // sticky = 终止/出错后保留在界面上,带一个手动关闭按钮 function showProgress(text, sticky) { if (!UI.prog) { UI.prog = el('div', 'kjl-ar-prog'); document.body.appendChild(UI.prog); } UI.prog.className = 'kjl-ar-prog' + (sticky ? ' sticky' : ''); UI.prog.innerHTML = ''; UI.prog.appendChild(el('span', 'txt', text)); if (sticky) { var x = el('button', 'x', '✕'); x.title = '关闭'; x.addEventListener('click', function (e) { e.stopPropagation(); hideProgress(); }); UI.prog.appendChild(x); } UI.prog.style.display = 'flex'; placeProgress(); } function hideProgress() { if (!UI.prog) return; UI.prog.style.display = 'none'; UI.prog.className = 'kjl-ar-prog'; UI.prog.innerHTML = ''; } function mountButton() { if (UI.btn && UI.btn.isConnected) return; var btn = el('div', 'kjl-ar-btn'); btn.title = '动作录制'; btn.innerHTML = ICON_IDLE; btn.addEventListener('click', function (e) { e.stopPropagation(); if (REC) { // 录制中:点一下 = 停止 + 把窗口叫回来 stopRecord(); showPanel(); return; } if (PLAY) { // 运行中:点一下 = 终止 + 把窗口叫回来 PLAY.stop = true; showPanel(); toast('已终止运行(进度条可用 ✕ 关闭)'); return; } togglePanel(); }); document.body.appendChild(btn); UI.btn = btn; placeButton(); } function placeButton() { if (!UI.btn) return; // 主锚点:3D 画布区左上角(贴左侧栏右缘,与画布留 12px 内边距) var cv = document.querySelector('.three-view') || document.querySelector('.param-editor canvas'); var done = false; if (cv) { var cr = cv.getBoundingClientRect(); if (cr.width > 100 && cr.height > 100) { UI.btn.style.left = Math.round(cr.left + 12) + 'px'; UI.btn.style.top = Math.round(cr.top + 12) + 'px'; done = true; } } if (done) return; // 回退:结构导航触发图标左边 34px var anchor = null; var icons = [].slice.call(document.querySelectorAll('.floating-panel-trigger i')); for (var i = 0; i < icons.length; i++) { if (String(icons[i].className).indexOf('param-jiegoudaohang') >= 0) { anchor = icons[i]; break; } } if (anchor) { var r2 = anchor.getBoundingClientRect(); UI.btn.style.left = Math.round(r2.left - 34) + 'px'; UI.btn.style.top = Math.round(r2.top) + 'px'; } else { UI.btn.style.left = (window.innerWidth - 96) + 'px'; UI.btn.style.top = '66px'; } } /* ---- 面板 ---- */ function panelHtml() { var wrap = el('div', 'kjl-ar-panel'); var head = el('div', 'kjl-ar-head'); var t = el('b', null, '动作录制与回放'); var ver = el('span', 'kjl-ar-ver', 'v' + VERSION); var sp = el('div', 'sp'); var btnX = el('button', 'kjl-ar-ico', '✕'); btnX.title = '关闭'; btnX.addEventListener('click', function () { hidePanel(); }); head.appendChild(t); head.appendChild(ver); head.appendChild(sp); head.appendChild(btnX); var tools = el('div', 'kjl-ar-tools'); var add = el('button', 'kjl-ar-add', '+ 新增'); add.title = '新建一个动作脚本'; add.addEventListener('click', function () { addScript(); }); var sel = el('select', 'kjl-ar-sel'); [['0.5', '0.5x'], ['1', '1x'], ['2', '2x'], ['4', '最快']].forEach(function (o) { var op = document.createElement('option'); op.value = o[0]; op.textContent = o[1]; sel.appendChild(op); }); sel.title = '回放速度'; sel.value = localStorage.getItem(LS_SPEED) || '1'; sel.addEventListener('change', function () { localStorage.setItem(LS_SPEED, sel.value); }); var sp2 = el('div', 'sp'); // 删除按钮:默认隐藏,拖动动作行时才出现;拖到它上面松手 = 直接删除 var del = el('button', 'kjl-ar-ico kjl-ar-ico-svg kjl-ar-del'); del.innerHTML = ICON_DEL; del.title = '拖到这里删除(测试动作才可删)'; UI.trash = del; var exp = el('button', 'kjl-ar-ico kjl-ar-ico-svg'); exp.innerHTML = ICON_EXPORT; exp.title = '导出全部动作(JSON)'; exp.addEventListener('click', exportAll); var imp = el('button', 'kjl-ar-ico kjl-ar-ico-svg'); imp.innerHTML = ICON_IMPORT; imp.title = '导入动作(JSON)'; imp.addEventListener('click', importAll); tools.appendChild(add); tools.appendChild(sel); tools.appendChild(sp2); tools.appendChild(del); tools.appendChild(exp); tools.appendChild(imp); var list = el('div', 'kjl-ar-list'); wrap.appendChild(head); wrap.appendChild(tools); wrap.appendChild(list); UI.panel = wrap; UI.list = list; UI.speed = sel; UI.head = head; makeDraggable(wrap, head); return wrap; } function showPanel() { injectCss(); if (!UI.panel) document.body.appendChild(panelHtml()); UI.panel.style.display = 'flex'; UI.btn.classList.add('on'); updateBtnState(); renderList(); placePanel(); } function hidePanel() { if (UI.panel) UI.panel.style.display = 'none'; if (UI.btn) UI.btn.classList.remove('on'); if (UI.btn) updateBtnState(); hideMenu(); } function togglePanel() { if (UI.panel && UI.panel.style.display !== 'none') hidePanel(); else showPanel(); } function savedPos() { try { var o = JSON.parse(localStorage.getItem(LS_POS) || 'null'); if (o && typeof o.l === 'number' && typeof o.t === 'number') return o; } catch (e) { } return null; } function placePanel() { if (!UI.panel || !UI.btn) return; var w = UI.panel.offsetWidth || 330, h = UI.panel.offsetHeight || 200; var sp = savedPos(); if (sp) { // 拖动过就守在原位(只做防出屏纠正) var l0 = Math.min(Math.max(0, sp.l), Math.max(0, window.innerWidth - w)); var t0 = Math.min(Math.max(0, sp.t), Math.max(0, window.innerHeight - 40)); UI.panel.style.left = Math.round(l0) + 'px'; UI.panel.style.top = Math.round(t0) + 'px'; return; } // 默认位置:贴按钮正下方、左边缘与按钮对齐(用户 2026-10-22 指定) var b = UI.btn.getBoundingClientRect(); var left = b.left; if (left + w > window.innerWidth - 8) left = Math.max(8, window.innerWidth - w - 8); var top = b.bottom + 8; if (top + h > window.innerHeight - 8) { // 下方放不开就翻到按钮上方 top = Math.max(8, b.top - h - 8); } UI.panel.style.left = Math.round(left) + 'px'; UI.panel.style.top = Math.round(top) + 'px'; } // 标题栏拖动(拖动过就记住位置;双击标题栏可复位到底部锚点) function makeDraggable(panel, handle) { var drag = null; handle.addEventListener('mousedown', function (e) { if (e.button !== 0 || (e.target.closest && e.target.closest('button'))) return; var r = panel.getBoundingClientRect(); drag = { dx: e.clientX - r.left, dy: e.clientY - r.top, w: r.width }; e.preventDefault(); document.addEventListener('mousemove', onMove, true); document.addEventListener('mouseup', onUp, true); }); function onMove(e) { if (!drag) return; var l = Math.min(Math.max(0, e.clientX - drag.dx), Math.max(0, window.innerWidth - drag.w)); var t = Math.min(Math.max(0, e.clientY - drag.dy), Math.max(0, window.innerHeight - 40)); panel.style.left = Math.round(l) + 'px'; panel.style.top = Math.round(t) + 'px'; } function onUp() { if (!drag) return; drag = null; document.removeEventListener('mousemove', onMove, true); document.removeEventListener('mouseup', onUp, true); try { localStorage.setItem(LS_POS, JSON.stringify({ l: parseInt(panel.style.left, 10) || 0, t: parseInt(panel.style.top, 10) || 0 })); } catch (e) { } } handle.addEventListener('dblclick', function (e) { if (e.target.closest && e.target.closest('button')) return; try { localStorage.removeItem(LS_POS); } catch (x) { } placePanel(); toast('面板位置已复位'); }); } /* ---- 列表 ---- */ function renderList() { if (!UI.list) return; var list = UI.list; list.innerHTML = ''; if (!DB.scripts.length) { list.appendChild(el('div', 'kjl-ar-empty', '还没有动作,点「+ 新增」开始')); return; } DB.scripts.forEach(function (sc) { var row = el('div', 'kjl-ar-row'); row.dataset.id = sc.id; if (REC && REC.id === sc.id) row.classList.add('rec'); if (PLAY && PLAY.id === sc.id) row.classList.add('busy'); if (UI.selId === sc.id) row.classList.add('sel'); var nm = el('span', 'nm', sc.name); nm.title = '双击重命名;右侧 ▶ 运行;右键更多'; if (sc.fail) { // 上次运行失败:名称标红,失败详情放 tooltip nm.style.color = '#f53f3f'; nm.title = '上次运行失败:第 ' + sc.fail.i + ' 步 · ' + (sc.fail.msg || '') + '\n' + nm.title; } row.appendChild(nm); var cnt = el('span', 'cnt', (sc.steps || []).length + ' 步'); row.appendChild(cnt); var act = el('div', 'act'); var runB = el('button', 'kjl-ar-ico', '▶'); runB.title = '运行'; runB.addEventListener('click', function (e) { e.stopPropagation(); doRun(sc); }); var isRec = !!(REC && REC.id === sc.id); var recB = el('button', 'kjl-ar-ico' + (isRec ? ' stop' : ''), isRec ? '⏹' : '●'); recB.title = isRec ? '结束录制' : '录制'; recB.addEventListener('click', function (e) { e.stopPropagation(); toggleRec(sc); }); act.appendChild(runB); act.appendChild(recB); row.appendChild(act); bindRow(row, nm, sc); enableRowDrag(row, sc); list.appendChild(row); }); } // 交互:单击不做事;双击 = 重命名(运行走右侧 ▶ 或右键菜单,避免误触直接把动作跑起来) function bindRow(row, nm, sc) { // 单击 = 选中(与左栏参数行一样有选中底色) row.addEventListener('click', function (e) { if (e.target.closest('button, input')) return; if (now() < SUPPRESS_ROW_CLICK) return; UI.selId = sc.id; var rows = UI.list ? UI.list.children : []; for (var i = 0; i < rows.length; i++) rows[i].classList.remove('sel'); row.classList.add('sel'); }); row.addEventListener('dblclick', function (e) { if (e.target.closest('button')) return; if (now() < SUPPRESS_ROW_CLICK) return; // 刚拖完别误触发改名 renameInline(row, nm, sc); }); row.addEventListener('contextmenu', function (e) { e.preventDefault(); e.stopPropagation(); showMenu(e.clientX, e.clientY, sc); }); } var SUPPRESS_ROW_CLICK = 0; // 刚拖完,短时间内别再触发双击改名 // FLIP:先量现有行位置,改完 DOM 再倒着补偿,产生"其他行让位"的动画 function flipMove(fn) { var list = UI.list; var kids = [].slice.call(list.children).filter(function (k) { return !k.classList.contains('kjl-ar-row-dragging'); }); var before = kids.map(function (k) { return k.getBoundingClientRect().top; }); fn(); kids.forEach(function (k, i) { var d = before[i] - k.getBoundingClientRect().top; if (!d) return; k.style.transition = 'none'; k.style.transform = 'translateY(' + d + 'px)'; void k.offsetHeight; // 强制重排,保证下一步能过渡 k.style.transition = 'transform .18s cubic-bezier(.2,.7,.3,1)'; k.style.transform = ''; k.addEventListener('transitionend', function clr() { k.style.transition = ''; k.removeEventListener('transitionend', clr); }); }); } // 指针落在哪个行之前(返回 null 表示放到末尾) function rowAfter(clientY, dragged) { var rows = [].slice.call(UI.list.children).filter(function (r) { return r !== dragged; }); for (var i = 0; i < rows.length; i++) { var rr = rows[i].getBoundingClientRect(); if (clientY < rr.top + rr.height / 2) return rows[i]; } return null; } function reorderFromDom() { var ids = [].slice.call(UI.list.children).map(function (r) { return r.dataset.id; }).filter(Boolean); if (!ids.length) return; var map = {}; DB.scripts.forEach(function (sc) { map[sc.id] = sc; }); var next = []; ids.forEach(function (id) { if (map[id]) { next.push(map[id]); delete map[id]; } }); DB.scripts.forEach(function (sc) { if (map[sc.id]) next.push(sc); }); // 兜底:没匹配上的保留 DB.scripts = next; saveDB(); } // 拖到垃圾桶上方时,指针旁边显示红色加粗提示 function showDropTip(x, y) { if (!UI.dropTip) { UI.dropTip = el('div', 'kjl-ar-drop'); document.body.appendChild(UI.dropTip); } UI.dropTip.textContent = '松开左键将直接删除'; UI.dropTip.style.display = 'block'; UI.dropTip.style.left = Math.round(x + 14) + 'px'; UI.dropTip.style.top = Math.round(y - 36) + 'px'; // 别顶出屏幕 var r = UI.dropTip.getBoundingClientRect(); if (r.right > window.innerWidth - 8) UI.dropTip.style.left = (window.innerWidth - r.width - 8) + 'px'; if (r.top < 8) UI.dropTip.style.top = (y + 22) + 'px'; } function hideDropTip() { if (UI.dropTip) UI.dropTip.style.display = 'none'; } // 行拖动排序:移动超过 5px 才进入拖拽(不影响双击改名) function enableRowDrag(row, sc) { row.addEventListener('pointerdown', function (e) { if (e.button !== 0) return; if (e.target.closest('button, input')) return; var startX = e.clientX, startY = e.clientY; var rect = row.getBoundingClientRect(); var offsetY = startY - rect.top; var dragging = false, ghost = null, moved = 0; function onMove(ev) { if (!dragging) { moved = Math.max(moved, Math.abs(ev.clientX - startX) + Math.abs(ev.clientY - startY)); if (moved < 5) return; dragging = true; ghost = row.cloneNode(true); ghost.className = 'kjl-ar-row kjl-ar-ghost'; ghost.style.left = rect.left + 'px'; ghost.style.top = rect.top + 'px'; ghost.style.width = rect.width + 'px'; document.body.appendChild(ghost); row.classList.add('kjl-ar-row-dragging'); document.body.classList.add('kjl-ar-dragging'); if (UI.panel) UI.panel.classList.add('dragging'); // 拖动中才显示删除按钮 } ghost.style.top = (ev.clientY - offsetY) + 'px'; // 指针是否落在删除按钮上 var overEl = document.elementFromPoint(ev.clientX, ev.clientY); var overTrash = !!(overEl && overEl.closest && overEl.closest('.kjl-ar-del')); if (UI.trash) UI.trash.classList.toggle('over', overTrash); if (overTrash) { showDropTip(ev.clientX, ev.clientY); return; // 悬在垃圾桶上时不参与排序 } hideDropTip(); var after = rowAfter(ev.clientY, row); if (after !== row.nextElementSibling) { flipMove(function () { if (after) UI.list.insertBefore(row, after); else UI.list.appendChild(row); }); } } function onUp(ev) { document.removeEventListener('pointermove', onMove, true); document.removeEventListener('pointerup', onUp, true); document.removeEventListener('pointercancel', onUp, true); if (!dragging) return; var overEl = ev ? document.elementFromPoint(ev.clientX, ev.clientY) : null; var overTrash = !!(overEl && overEl.closest && overEl.closest('.kjl-ar-del')); if (ghost && ghost.parentElement) ghost.parentElement.removeChild(ghost); row.classList.remove('kjl-ar-row-dragging'); document.body.classList.remove('kjl-ar-dragging'); if (UI.panel) UI.panel.classList.remove('dragging'); if (UI.trash) UI.trash.classList.remove('over'); hideDropTip(); SUPPRESS_ROW_CLICK = now() + 250; if (overTrash) { trashDelete(sc); return; } // 落在垃圾桶上:直接删,不排序 reorderFromDom(); } document.addEventListener('pointermove', onMove, true); document.addEventListener('pointerup', onUp, true); document.addEventListener('pointercancel', onUp, true); }); } function renameInline(row, nm, sc) { if (!row.isConnected || row.querySelector('.kjl-ar-name-in')) return; var inp = document.createElement('input'); inp.className = 'kjl-ar-name-in'; inp.value = sc.name; inp.setAttribute(MARK, '1'); row.replaceChild(inp, nm); inp.focus(); inp.select(); var done = function (ok) { var v = (inp.value || '').trim(); if (ok && v) { sc.name = v; sc.updated = now(); saveDB(); } renderList(); }; inp.addEventListener('keydown', function (e) { if (e.key === 'Enter') { e.preventDefault(); done(true); } else if (e.key === 'Escape') { e.preventDefault(); done(false); } }); inp.addEventListener('blur', function () { done(true); }); } /* ---- 右键菜单 ---- */ function hideMenu() { if (UI.ctx && UI.ctx.parentElement) UI.ctx.parentElement.removeChild(UI.ctx); UI.ctx = null; } function showMenu(x, y, sc) { hideMenu(); var m = el('div', 'kjl-ar-menu'); var items = [ ['运行', '▶', function () { doRun(sc); }, false, false], [(REC && REC.id === sc.id) ? '结束录制' : '录制', '●', function () { toggleRec(sc); }, false, false], ['复制', '⧉', function () { copyScript(sc); }, false, false], ['重命名', '✎', function () { var row = UI.list.querySelector('.kjl-ar-row[data-id="' + sc.id + '"]'); if (row) renameInline(row, row.querySelector('.nm'), sc); }, false, false], ['修改', '✱', function () { showSteps(sc); }, false, false], ['删除', '🗑', function () { delScript(sc); }, true, false] ]; items.forEach(function (it) { var dis = !!it[4]; var d = el('div', 'kjl-ar-mi' + (it[3] ? ' danger' : '') + (dis ? ' dis' : '')); d.innerHTML = '' + it[1] + '' + it[0] + ''; if (dis) d.title = '该动作已锁定,不能' + it[0]; d.addEventListener('click', function () { hideMenu(); it[2](); }); m.appendChild(d); }); document.body.appendChild(m); UI.ctx = m; var r = m.getBoundingClientRect(); m.style.left = Math.min(x, window.innerWidth - r.width - 8) + 'px'; m.style.top = Math.min(y, window.innerHeight - r.height - 8) + 'px'; } /* ---- 增删改 ---- */ // 命名规范:新建的动作统一以「测试」开头;非「测试」开头的视为正式动作,禁止改/删(可复制) function uniqueName(base) { var used = {}; DB.scripts.forEach(function (s) { used[s.name] = 1; }); if (!used[base]) return base; var i = 2; while (used[base + ' ' + i]) i++; return base + ' ' + i; } function nextName() { var n = 1; var used = {}; DB.scripts.forEach(function (s) { used[s.name] = 1; }); while (used['动作 ' + n]) n++; return '动作 ' + n; } function addScript() { var sc = { id: newId(), name: nextName(), steps: [], created: now(), updated: now() }; DB.scripts.push(sc); saveDB(); UI.selId = sc.id; // 新增的直接选中 renderList(); var row = UI.list.querySelector('.kjl-ar-row[data-id="' + sc.id + '"]'); if (row) { var nm = row.querySelector('.nm'); if (nm) renameInline(row, nm, sc); } return sc; } function delScript(sc) { if (REC && REC.id === sc.id) stopRecord(); if (!confirm('删除动作「' + sc.name + '」?')) return; DB.scripts = DB.scripts.filter(function (s) { return s.id !== sc.id; }); saveDB(); renderList(); toast('已删除'); } // 复制:任何动作都能复制;复制出来的名字保证以「测试」开头,才可再改 function copyScript(sc) { var base = sc.name + ' 副本'; var cp = { id: newId(), name: uniqueName(base), steps: JSON.parse(JSON.stringify(sc.steps || [])), created: now(), updated: now() }; DB.scripts.push(cp); saveDB(); renderList(); toast('已复制为「' + cp.name + '」'); return cp; } // 拖到垃圾桶松手:直接删除,不弹确认 function trashDelete(sc) { if (REC && REC.id === sc.id) stopRecord(); var nm = sc.name; DB.scripts = DB.scripts.filter(function (s) { return s.id !== sc.id; }); if (UI.selId === sc.id) UI.selId = null; saveDB(); renderList(); toast('已删除「' + nm + '」'); return true; } function doRun(sc) { if (REC) { toast('正在录制,先结束录制', true); return; } if (PLAY) { PLAY.stop = true; toast('已停止回放'); return; } runScript(sc, { speed: parseFloat(UI.speed ? UI.speed.value : 1) || 1, idleCap: 800 }); } function toggleRec(sc) { if (REC && REC.id === sc.id) stopRecord(); else if (REC) { toast('请先结束当前录制', true); } else if (PLAY) { toast('回放中不能录制', true); } else startRecord(sc); } /* ---- 修改窗口:列出记录的动作 ---- */ function showSteps(sc) { injectCss(); var mask = el('div', 'kjl-ar-mask'); var dlg = el('div', 'kjl-ar-dlg'); var h = el('div', 'h'); var b = el('b', null, '修改动作:' + sc.name); var sp = el('div', 'sp'); var x = el('button', 'kjl-ar-ico', '✕'); x.addEventListener('click', function () { mask.remove(); }); h.appendChild(b); h.appendChild(sp); h.appendChild(x); var bd = el('div', 'bd'); function paint() { bd.innerHTML = ''; if (!(sc.steps || []).length) { bd.appendChild(el('div', 'kjl-ar-empty', '还没有录制内容')); return; } sc.steps.forEach(function (s, i) { var row = el('div', 'kjl-ar-step'); row.dataset.ix = i; row.appendChild(el('span', 'ix', String(i + 1))); var kdCls = s.kind === 'node' ? ' node' : s.kind === 'input' ? ' input' : s.kind === 'sort' ? ' sort' : s.kind === 'setattr' ? ' setattr' : ''; var kdTxt = s.kind === 'node' ? '选中' : s.kind === 'input' ? '输入' : s.kind === 'sort' ? '排序' : s.kind === 'setattr' ? '改值' : (s.sub || '点击'); var kd = el('span', 'kd' + kdCls, kdTxt); row.appendChild(kd); var tx = el('span', 'tx', s.kind === 'node' ? (s.path || s.name) : s.kind === 'input' ? (s.label || s.value) : s.kind === 'setattr' ? (s.label || ((s.name || s.eventKey) + ' → ' + s.value)) : s.kind === 'sort' ? (s.label || ('参数排序:' + (s.movedKey || ''))) : (s.label || ('<' + (s.tag || '?') + '>'))); tx.title = (s.sel || '') + (s.kind === 'input' ? '\n值:' + s.value : ''); row.appendChild(tx); row.appendChild(el('span', 'tt', '+' + (s.t || 0) + 'ms')); var del = el('button', 'del', '✕'); del.title = '删除这一步'; del.addEventListener('click', function () { sc.steps.splice(i, 1); if (sc.fail) delete sc.fail; sc.updated = now(); saveDB(); paint(); renderList(); }); row.appendChild(del); bd.appendChild(row); }); } paint(); var ft = el('div', 'ft'); var clear = el('button', 'kjl-ar-btn2', '清空全部'); clear.addEventListener('click', function () { if (!confirm('清空「' + sc.name + '」的全部步骤?')) return; sc.steps = []; if (sc.fail) delete sc.fail; sc.updated = now(); saveDB(); paint(); renderList(); }); var ok = el('button', 'kjl-ar-btn2 pri', '关闭'); ok.addEventListener('click', function () { mask.remove(); }); ft.appendChild(clear); ft.appendChild(ok); dlg.appendChild(h); dlg.appendChild(bd); dlg.appendChild(ft); mask.appendChild(dlg); mask.addEventListener('click', function (e) { if (e.target === mask) mask.remove(); }); document.body.appendChild(mask); } function highlightStep(i) { var mask = document.querySelector('.kjl-ar-mask'); if (!mask) return; var row = mask.querySelector('.kjl-ar-step[data-ix="' + i + '"]'); if (!row) return; [].slice.call(mask.querySelectorAll('.kjl-ar-step.hl')).forEach(function (r) { r.classList.remove('hl'); }); row.classList.add('hl'); try { row.scrollIntoView({ block: 'nearest' }); } catch (e) { } } /* ---- 导入导出 ---- */ // 文件名时间戳:本地时区的 年-月-日_时-分-秒(避免只到日期,多次导出会重名) function fileStamp() { var d = new Date(); function p2(n) { return (n < 10 ? '0' : '') + n; } return d.getFullYear() + '-' + p2(d.getMonth() + 1) + '-' + p2(d.getDate()) + '_' + p2(d.getHours()) + '-' + p2(d.getMinutes()) + '-' + p2(d.getSeconds()); } function exportAll() { var data = JSON.stringify({ v: 1, exported: now(), scripts: DB.scripts }, null, 2); var blob = new Blob([data], { type: 'application/json' }); var a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = '动作录制-' + fileStamp() + '.json'; document.body.appendChild(a); a.click(); setTimeout(function () { URL.revokeObjectURL(a.href); a.remove(); }, 1000); } function importAll() { var inp = document.createElement('input'); inp.type = 'file'; inp.accept = '.json,application/json'; inp.setAttribute(MARK, '1'); inp.addEventListener('change', function () { var f = inp.files && inp.files[0]; if (!f) return; var fr = new FileReader(); fr.onload = function () { try { var obj = JSON.parse(String(fr.result)); var list = obj.scripts || (Array.isArray(obj) ? obj : []); if (!list.length) throw new Error('文件里没有动作'); var n = 0; list.forEach(function (s) { DB.scripts.push({ id: newId(), name: s.name || ('导入 ' + (++n)), steps: s.steps || [], created: now(), updated: now() }); }); saveDB(); renderList(); toast('已导入 ' + list.length + ' 个动作'); } catch (e) { toast('导入失败:' + (e.message || e), true); } }; fr.readAsText(f); }); inp.click(); } /* ============================================================ 启动 */ function boot() { injectCss(); mountButton(); // 站点重渲染会把按钮顶掉/位置变化,周期性校正(很轻,300ms 一次且只在必要时改样式) var lastL = -1, lastT = -1; setInterval(function () { if (!UI.btn || !UI.btn.isConnected) { mountButton(); return; } var before = UI.btn.style.left + '|' + UI.btn.style.top; placeButton(); var after = UI.btn.style.left + '|' + UI.btn.style.top; if (before !== after) { if (UI.panel && UI.panel.style.display !== 'none') placePanel(); placeProgress(); } }, 600); window.addEventListener('resize', function () { placeButton(); if (UI.panel && UI.panel.style.display !== 'none') placePanel(); }); document.addEventListener('mousedown', function (e) { if (UI.ctx && !e.target.closest('.kjl-ar-menu')) hideMenu(); }, true); document.addEventListener('keydown', function (e) { if (e.key === 'Escape') { hideMenu(); } }, true); console.log('[动作录制] v' + VERSION + ' 就绪'); } if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot); else boot(); })();