// ==UserScript==
// @name 酷家乐编辑器:动作录制
// @namespace tm.kujiale.actions
// @version 1.0.1
// @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;
// 连续改同一个值 → 合并成一步,以最后一次为准(中间没插别的动作才算连续)
if (step.kind === 'setattr' || step.kind === 'input') {
for (var i = REC.steps.length - 1; i >= 0; i--) {
var pv = REC.steps[i];
if (pv.kind !== step.kind) break;
var same = step.kind === 'setattr'
? (pv.eventKey === step.eventKey && pv.name === step.name)
: (pv.sel === step.sel);
if (same) {
pv.value = step.value;
pv.label = step.label;
pv.t = step.t; // 时间挪到最后一次
var scM = recordingScript();
if (scM) { scM.updated = now(); saveDB(); }
renderList();
return;
}
break;
}
}
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-step .val{font-weight:600;color:#0a6cc4;padding:0 4px;border-radius:3px;cursor:text;',
' border-bottom:1px dashed #a9d3f5}',
'.kjl-ar-step .val:hover{background:#e8f7ff;border-bottom-color:#1a7af8}',
'.kjl-ar-valin{width:110px;height:20px;border:1px solid #1a7af8;border-radius:3px;padding:0 4px;',
" font-size:12px;outline:none;font-family:inherit}",
'.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 editValue(step, valEl) {
var inp = document.createElement('input');
inp.className = 'kjl-ar-valin';
inp.value = step.value == null ? '' : String(step.value);
inp.setAttribute(MARK, '1');
inp.title = '回车确认,Esc 取消';
if (valEl.parentNode) valEl.parentNode.replaceChild(inp, valEl);
inp.focus();
inp.select();
var committed = false;
function done(ok) {
if (committed) return;
committed = true;
if (ok) {
step.value = inp.value;
step.label = (step.name || step.eventKey) + ' → ' + (inp.value === '' ? '(空)' : inp.value);
sc.updated = now();
saveDB();
}
paint();
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 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;
if (s.kind === 'setattr') {
tx = el('span', 'tx');
tx.appendChild(document.createTextNode((s.name || s.eventKey) + ' → '));
var valEl = el('b', 'val', (s.value === '' || s.value == null) ? '(空)' : String(s.value));
valEl.title = '双击修改这个值';
valEl.addEventListener('dblclick', function (ev) {
ev.stopPropagation();
editValue(s, valEl);
});
tx.appendChild(valEl);
} else {
var txText = s.kind === 'node' ? (s.path || s.name)
: s.kind === 'input' ? (s.label || s.value)
: s.kind === 'sort' ? (s.label || ('参数排序:' + (s.movedKey || '')))
: (s.label || ('<' + (s.tag || '?') + '>'));
tx = el('span', 'tx', txText);
}
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();
})();