// ==UserScript==
// @name 酷家乐编辑器:动作录制
// @namespace tm.kujiale.actions
// @version 1.1.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.1.1';
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;
if (!Array.isArray(db.folders)) db.folders = []; // 文件夹(旧数据没有 → 补空数组)
return db;
}
}
} catch (e) { /* 忽略损坏数据 */ }
return { scripts: [], folders: [], 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;
// 公式编辑器开着:基线冻在「打开前」,期间改出的最终值等关闭后一次性记成一步
if (FX.on) 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;
}
// 停止录制后补比对:属性值来自 React props,最后一次输入可能还没提交,
// 所以隔两拍再抓一次快照,把最后那一下补进步骤里(同属性则覆盖成最新值)
function finalizeAttrs(script, base, t0) {
if (!script || !base) return;
var cur = attrSnapshot();
var changed = 0;
Object.keys(cur).forEach(function (key) {
if (base[key] === undefined || base[key] === cur[key]) return;
var parts = key.split('|');
var ev = parts[0], nm = parts.slice(1).join('|'), val = cur[key];
var label = (nm || ev) + ' → ' + (val === '' ? '(空)' : val);
var last = script.steps[script.steps.length - 1];
if (last && last.kind === 'setattr' && last.eventKey === ev && last.name === nm) {
last.value = val;
last.label = label;
// 合并进已有改值步:时刻不动(改值步的时长固定 500ms)
} else {
script.steps.push({
t: afterLastTOf(script.steps, SETATTR_MS), // 改值步统一 100ms
kind: 'setattr',
eventKey: ev,
name: nm,
value: val,
label: label
});
}
changed++;
});
if (changed) {
script.updated = now();
saveDB();
renderList();
}
}
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;
}
/* ==================== 公式编辑器:录制时整段跳过 ==================== */
/* 点「计算器」图标 → 打开「公式编辑器」弹窗。这一整块属于「编辑一条公式」的
中间过程,不是要回放的业务动作,所以:
1) 触发器与弹窗内的一切点击/双击/输入都不记步骤;
2) 弹窗打开期间右栏属性值的变化不记 setattr(公式确认会改值);
3) 从「打开」到「关闭」这段时间不计入时间轴 —— 做法是把暂停时长累计起来,
之后每一步的 t 都减去它,于是回放时的间隔里不含这段。 */
var FX_SEL = '.formula-result-trigger-container, .formula-result-trigger, ' +
'.formula-editor-modal, .tmfe-root, .formula-editor-dialog-content-wrap, ' +
'.parse-ast-modal, [class*="tmfe-"]';
var FX = { on: false, start: 0, acc: 0, timer: 0 }; // acc = 累计暂停毫秒
function fxInZone(t) {
return !!(t && t.closest && t.closest(FX_SEL));
}
// 弹窗是否开着:关闭时站点会把整个 wrap 从 DOM 里摘掉,所以「存在且可见」即可判
function fxEditorOpen() {
var w = document.querySelector('.tui-dialog-wrap.formula-editor-modal') ||
document.querySelector('.formula-editor-dialog-content-wrap.new-formula-editor') ||
document.querySelector('.tmfe-root');
return !!(w && isVisible(w));
}
// 已录时长(扣掉公式编辑器占用的一段)
function fxElapsed(t0) {
var t = now() - t0 - FX.acc;
if (FX.on) t -= (now() - FX.start);
return t < 0 ? 0 : t;
}
function fxTick() {
if (!REC) return;
var open = fxEditorOpen();
if (open && !FX.on) { // 刚打开 → 进入暂停
FX.on = true; FX.start = now();
} else if (!open && FX.on) { // 刚关闭 → 累计这段暂停
FX.acc += now() - FX.start;
FX.on = false; FX.start = 0;
// ATTR_BASE 仍停在「打开前」→ 等站点把公式结果写回右栏,补一次比对,
// 把公式编辑器最终改出的值记成一步 setattr(里面的过程点击不记)。
// 写回有快有慢,所以两拍各查一次;attrCheck 内部比对后会把基线推到最新,重复调用安全。
clearTimeout(ATTR_DEBOUNCE);
ATTR_DEBOUNCE = setTimeout(attrCheck, 260);
setTimeout(attrCheck, 900);
}
}
function fxWatchStart() {
FX.on = false; FX.start = 0; FX.acc = 0;
clearInterval(FX.timer);
FX.timer = setInterval(fxTick, 200);
}
function fxWatchStop() {
clearInterval(FX.timer); FX.timer = 0;
if (FX.on) { FX.acc += now() - FX.start; FX.on = false; } // 停录时还开着 → 这段也扣掉
return FX.acc;
}
/* ============================================================ 录制器 */
// 「改值」步统一间隔:不管录到多久,一律排在上一步之后 100ms
// (时间列存的是绝对时刻 t,回放等的是相邻两步的差,所以定成 prev.t + 100 即「等待 100ms」)
var SETATTR_MS = 100;
// 每步「距上一步的等待」的上下限(录制超时只记到上限,手工改也限制在这个区间)
var T_GAP_MIN = 50;
var T_GAP_MAX = 10000;
function afterLastTOf(steps, dur) {
var last = steps && steps.length ? steps[steps.length - 1] : null;
return ((last && last.t) || 0) + dur;
}
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 = fxElapsed(REC.t0) + (REC.tBase || 0); // 接着录制时叠加已有步骤的时长
// 「改值」步统一间隔 100ms;其它步保证不早于上一步(改值把时间推后时不让轴倒流)
var prevT = REC.steps.length ? REC.steps[REC.steps.length - 1].t : 0;
if (step.kind === 'setattr') step.t = prevT + SETATTR_MS;
else if (step.t < prevT) step.t = prevT;
// ★ 单步等待的上下限:录制时也保证在 [T_GAP_MIN, T_GAP_MAX] 内
if (step.t - prevT < T_GAP_MIN) step.t = prevT + T_GAP_MIN;
if (step.t - prevT > T_GAP_MAX) step.t = prevT + T_GAP_MAX;
// 连续改同一个值 → 合并成一步,以最后一次为准(中间没插别的动作才算连续)
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;
if (step.kind !== 'setattr') pv.t = step.t; // 改值合并成一步,时长仍是那 500ms,不挪
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 (fxInZone(t) || FX.on) return; // 计算器图标 / 公式编辑器(含其模态期间):整块不记
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 去抖 + 值去重,避免一个字一步。
var PENDING_INPUT = []; // 还挂着去抖的输入框,停止录制时要强制冲刷
function recordInput(t, immediate) {
if (!REC || !t || t.closest('[' + MARK + ']')) return;
if (fxInZone(t) || FX.on) 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 () {
var pi = PENDING_INPUT.indexOf(t);
if (pi >= 0) PENDING_INPUT.splice(pi, 1);
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);
if (PENDING_INPUT.indexOf(t) < 0) PENDING_INPUT.push(t);
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 (fxInZone(t) || FX.on) return; // 计算器图标 / 公式编辑器(含其模态期间):整块不记
if (t.closest('.element-list-panel') || t.closest('canvas') || t.closest('.three-view')) return;
var path = cssPath(t);
// 把前 700ms 内针对同一元素的点击步删掉(它们是这次双击的前半段)
var cutT = fxElapsed(REC.t0) - 700;
for (var i = REC.steps.length - 1; i >= 0; i--) {
var st = REC.steps[i];
if (st.t < cutT) 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, opts) {
opts = opts || {};
if (REC) stopRecord();
var prev = (script.steps || []).slice();
var cont = !!(opts.append && prev.length); // 接着录制:保留已有步骤
REC = {
id: script.id,
steps: cont ? prev : [],
t0: now(),
// 接着录制时新步骤的时间从「上一段末尾」接着算,否则会从 0 开始把时序压扁
tBase: cont ? (prev[prev.length - 1].t || 0) : 0,
lastEid: null, panelObs: null, nodeDeb: 0, timer: 0
};
script.steps = REC.steps;
saveDB(); // 清空/保留都要立刻落盘,否则此刻刷新会让改动丢失
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();
fxWatchStart(); // 公式编辑器:整段跳过(含时长)
document.body.classList.add('kjl-ar-recording');
renderList();
hidePanel(); // 录制时把面板收起来,免得挡住要操作的地方
updateBtnState();
toast('开始录制:' + script.name + '(点左上角方块停止)');
}
function stopRecord() {
if (!REC) return null;
var rec = REC;
var sc = recordingScript();
var base = ATTR_BASE;
var t0 = rec.t0;
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; }
clearTimeout(rec.nodeDeb);
clearTimeout(rec.timer);
clearTimeout(ATTR_DEBOUNCE);
fxWatchStop(); // 停录时公式编辑器还开着 → 这段时长也扣掉
// ① 先把还挂着去抖的输入框冲刷掉(值直接读 DOM,没有异步延迟)
PENDING_INPUT.slice().forEach(function (el) { recordInput(el, true); });
PENDING_INPUT.length = 0;
var n = rec.steps.length;
REC = null;
stopAttrWatch();
document.body.classList.remove('kjl-ar-recording');
if (sc) { sc.updated = now(); saveDB(); }
renderList();
updateBtnState();
toast('录制结束,共 ' + n + ' 步');
// ② 属性值再补两次比对:立刻一次(冲击刷),260ms 后一次(等 React 提交最后一次输入)
if (sc && base) {
finalizeAttrs(sc, base, t0);
var mid = attrSnapshot();
setTimeout(function () { finalizeAttrs(sc, mid, t0); }, 260);
}
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) {
opts = opts || {};
var own = !opts.keepPlay; // 是否由自己建立/清理 PLAY(文件夹连跑时由上层负责)
if (own && (PLAY || REC)) return;
var steps = (script.steps || []).slice();
var nameTag = (script && script.name) ? '「' + script.name + '」' : '';
if (!steps.length) {
if (!opts.quiet) toast('这个动作还没有录制内容');
return { skipped: true, okCount: 0, failed: false, stopped: false };
}
if (own) {
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;
// ★ 进度提示带上动作名:正在运行「动作名称」09/25 步
showProgress('正在运行' + nameTag + 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);
// ★ 被「眼睛」关掉的步骤:跳过不执行,但仍按原时间轴等待(保持整体节奏)
if (steps[i].skip) continue;
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;
if (own) {
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('已终止' + nameTag + padNum(lastShown, pw) + '/' + total + ' 步', true);
} else if (!opts.keepPlay) { // 文件夹连跑时由上层统一收尾,避免动作间闪隐
hideProgress();
if (script && script.fail) { // 跑通了就清掉失败标记
delete script.fail;
saveDB();
renderList();
}
} else if (script && script.fail) {
delete script.fail;
saveDB();
}
if (!failed && !opts.quiet) toast('回放完成(' + okCount + ' 步)');
return { skipped: false, okCount: okCount, failed: !!failed, stopped: !!stopped };
}
// ★ 文件夹连跑:依次运行文件夹内的每个动作
async function runFolder(fd, opts) {
opts = opts || {};
if (REC) { toast('正在录制,先结束录制', true); return; }
if (PLAY) { PLAY.stop = true; toast('已停止回放'); return; }
var kids = DB.scripts.filter(function (s) { return s.folderId === fd.id; });
if (!kids.length) { toast('这个文件夹里还没有动作'); return; }
var runnable = kids.filter(function (s) { return (s.steps || []).length; });
if (!runnable.length) { toast('这个文件夹里的动作都还没有录制内容'); return; }
PLAY = { stop: false, id: 'folder:' + fd.id, folder: fd.id, failed: null };
hidePanel(); // 整段连跑只收起一次,避免每个动作都闪一下
updateBtnState();
renderList();
var done = 0, failedAt = null, stopped = false;
for (var i = 0; i < runnable.length; i++) {
if (PLAY.stop) { stopped = true; break; }
PLAY.failed = null;
PLAY.id = runnable[i].id; // 让当前执行的动作行高亮
renderList();
var r = await runScript(runnable[i], {
speed: opts.speed, idleCap: opts.idleCap, keepPlay: true, quiet: true
});
if (r && r.failed) { failedAt = runnable[i]; break; }
if (r && r.stopped) { stopped = true; break; }
if (PLAY.stop) { stopped = true; break; }
done++;
}
PLAY = null;
updateBtnState();
renderList();
showPanel();
if (failedAt) {
showProgress('运行失败:' + (failedAt.name || '动作') + ' 第 '
+ ((failedAt.fail && failedAt.fail.i) || '?') + ' 步出错', true);
} else if (stopped) {
showProgress('已终止(完成 ' + done + '/' + runnable.length + ' 个动作)', true);
} else {
hideProgress();
toast('文件夹回放完成(' + done + ' 个动作)');
}
}
function doRunFolder(fd) {
if (REC) { toast('正在录制,先结束录制', true); return; }
if (PLAY) { PLAY.stop = true; toast('已停止回放'); return; }
runFolder(fd, { speed: parseFloat(UI.speed ? UI.speed.value : 1) || 1, idleCap: 800 });
}
/* ============================================================ 面板 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-folder{display:flex;align-items:center;gap:6px;height:30px;padding:0 8px;cursor:default;user-select:none;font-weight:600;color:#1d2129}',
'.kjl-ar-folder:hover{background:rgba(0,255,24,.48)}',
/* 图标垂直方向比文字低约 0.6px(CJK 字形墨迹中心偏上),上移 0.8px 对齐;
0.8px 在 DPR=1.25 下正好是 1 个设备像素,不会让描边变糊。 */
'.kjl-ar-folder .fico{flex:none;width:18px;height:18px;display:flex;align-items:center;justify-content:center;color:#5C5C66;cursor:pointer;transform:translate(-2px,-0.8px)}',
/* ★ 图标 16px:14px 时右边缘落在半像素上,抗锯齿后被"吃掉",看着像右边缺一块 */
'.kjl-ar-folder .fico svg{display:block;pointer-events:none;width:16px;height:16px}',
'.kjl-ar-folder .nm{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
'.kjl-ar-folder .cnt{color:inherit;font-size:11px;flex:none;font-weight:400}',
/* 数量为 0 时显示灰色 */
'.kjl-ar-folder .cnt.zero,.kjl-ar-row .cnt.zero{color:#c9cdd4}',
'.kjl-ar-folder .act{flex:none;display:flex;gap:2px;opacity:0;transition:opacity .1s}',
'.kjl-ar-folder:hover .act{opacity:1}',
/* 文件夹行的运行按钮要和动作行的 ▶ 上下对齐:动作行是 [▶][●],文件夹只有 [▶],
所以补一个与 ● 等宽的占位,保证两者左边缘一致。 */
'.kjl-ar-folder .act .ph{width:22px;height:22px;flex:none}',
'.kjl-ar-folder.busy{background:#fff7e8!important}',
/* 文件夹内的动作行:左侧缩进,视觉上属于该文件夹 */
'.kjl-ar-row.in-folder{padding-left:32px}',
/* ★ 树形连接线(仿结构导航):竖线 + 横勾。最后一项的竖线只画到中线 */
/* 【可调参数】下面两个 left 控制树线的水平位置(越小越靠左);width 是线宽/勾长 */
'.kjl-ar-row.in-folder{position:relative}',
'.kjl-ar-row.in-folder::before{content:"";position:absolute;left:15px;top:0;bottom:0;width:1px;background:#d5dbe6;pointer-events:none}',
/* 横勾要与「文字字形」的垂直中心对齐(不是行盒中心):
行盒中心比 CJK 字形墨迹中心低约 1.5px,1px 线段自身中心又在 top+0.5,
所以整体上移 2px。实测与文字墨迹中心偏差 0.11 设备像素(≈0.09 CSS px)。
【可调】想让勾再上下微调,改这里的 translateY,并把下面 .kjl-ar-last 的 bottom 一起改。 */
'.kjl-ar-row.in-folder::after{content:"";position:absolute;left:15px;top:50%;width:8px;height:1px;background:#d5dbe6;pointer-events:none;transform:translateY(-2px)}',
/* 文件夹内最后一项:竖线只画上半段(形成 └ 形),收尾高度跟着横勾一起走 */
'.kjl-ar-row.in-folder.kjl-ar-last::before{bottom:calc(50% + 2px)}',
'.kjl-ar-row.drop-target{background:rgba(26,122,248,.18);outline:1px dashed #1a7af8;outline-offset:-1px}',
'.kjl-ar-folder.drop-into{background:rgba(26,122,248,.18);outline:1px dashed #1a7af8;outline-offset:-1px}',
'.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 .nm{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
'.kjl-ar-row .cnt{color:inherit;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:500px;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 input.kjl-ar-title-in{width:230px;height:26px;flex:none;border:1px solid #e5e6eb;border-radius:4px;',
' padding:0 6px;font-size:14px;font-family:inherit;color:#1d2129;background:#fff;outline:none}',
'.kjl-ar-dlg .h input.kjl-ar-title-in:focus{border-color:#1a7af8;box-shadow:0 0 0 2px rgba(26,122,248,.12)}',
'.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}',
/* 「修改动作」窗口:宽 600~900、高 300~900,可拖动(位置由 transform 控制) */
'.kjl-ar-dlg.kjl-ar-steps{width:600px;min-width:600px;max-width:min(900px,calc(100vw - 24px));',
' min-height:300px;max-height:min(900px,calc(100vh - 24px))}',
/* 行高固定 30px:双击改时间/改值出现 input 时行高不变,列表不会上下跳 */
'.kjl-ar-step{display:flex;align-items:center;gap:8px;height:30px;padding:0 14px;font-size:12px;border-bottom:1px dashed #f4f5f7;box-sizing:border-box}',
/* 悬停配色与左侧动作列表保持一致;选中不再给底色 */
'.kjl-ar-step:hover{background:rgba(0,255,24,.48)}',
'.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 .tx.setattr{display:flex;align-items:center;gap:6px}',
'.kjl-ar-step .tx.setattr .lbl{flex:none;width:104px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
'.kjl-ar-step .tx.setattr .arw{flex:none;color:#86909c}',
/* 时间列:输入框(原生上下调节箭头在 hover/focus 时自动出现),整列仍是 62px */
'.kjl-ar-step .ttw{flex:none;width:62px;display:flex;align-items:center}',
'.kjl-ar-step .ttw .ttin{flex:1;min-width:0;height:20px;border:1px solid #e5e6eb;border-radius:3px;',
' padding:0 1px 0 4px;font-size:12px;font-family:inherit;text-align:right;color:#86909c;background:#fff;outline:none}',
'.kjl-ar-step .ttw .ttin:hover{border-color:#a9d3f5}',
'.kjl-ar-step .ttw .ttin:focus{border-color:#1a7af8;color:#1a7af8;box-shadow:0 0 0 2px rgba(26,122,248,.12)}',
/* 眼睛按钮:显示 / 跳过(斜线 + 变灰) */
'.kjl-ar-step .eye{flex:none;width:18px;height:18px;padding:0;border:0;border-radius:3px;background:transparent;',
' color:#86909c;cursor:pointer;display:flex;align-items:center;justify-content:center}',
'.kjl-ar-step .eye:hover{background:#eef3ff;color:#1a7af8}',
'.kjl-ar-step .eye svg{display:block;pointer-events:none}',
'.kjl-ar-step.skip .eye{color:#c9cdd4}',
/* 被跳过的步骤整行弱化,一眼能看出来 */
'.kjl-ar-step.skip .ix,.kjl-ar-step.skip .ttw,.kjl-ar-step.skip .kd{opacity:.45}',
'.kjl-ar-step.skip .tx,.kjl-ar-step.skip .tx .lbl,.kjl-ar-step.skip .tx .arw{color:#c9cdd4}',
'.kjl-ar-step.skip .tx.setattr .valin{color:#c9cdd4;border-style:dashed}',
'.kjl-ar-step .ttw .ttu{flex:none;font-style:normal;font-size:10px;color:#c9cdd4}',
'.kjl-ar-ttin{width:66px;height:20px;border:1px solid #1a7af8;border-radius:3px;padding:0 4px;',
' font-size:12px;outline:none;text-align:right;font-family:inherit}',
'.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-step .tx.setattr .valin{flex:1;min-width:0;width:auto;height:20px;text-align:left;',
' color:#0a6cc4;font-weight:600;border-color:#e5e6eb}',
'.kjl-ar-step .tx.setattr .valin:hover{border-color:#a9d3f5}',
'.kjl-ar-step .tx.setattr .valin:focus{border-color:#1a7af8;box-shadow:0 0 0 2px rgba(26,122,248,.12)}',
'.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-btn2.warn{background:#f53f3f;border-color:#f53f3f;color:#fff}',
'.kjl-ar-btn2.warn:hover{background:#f76965}',
'.kjl-ar-ask{width:400px;max-width:90vw}',
'.kjl-ar-ask .h b{font-size:15px}',
'.kjl-ar-ask .bd{padding:18px 20px;font-size:13px;line-height:1.7;color:#1f2329}',
'.kjl-ar-ask .bd .em{color:#f53f3f;font-weight:600}',
'.kjl-ar-ask .bd .cnt{color:#86909c;font-size:12px;margin-top:6px}',
'.kjl-ar-ask .ft{height:56px}',
'.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 跟随按钮颜色)
// 文件夹图标(用户 2026-10-23 提供):闭合 = 不展开,打开 = 展开
var ICON_FOLDER_CLOSED = '';
var ICON_FOLDER_OPEN = '';
var ICON_EXPORT = '';
var ICON_DEL = '';
var ICON_IMPORT = '';
// 眼睛图标:显示 / 跳过(带斜线)
var ICON_EYE = '';
var ICON_EYE_OFF = '';
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 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);
var addFd = el('button', 'kjl-ar-add kjl-ar-addfd', '+ 文件夹');
addFd.title = '新建文件夹';
addFd.addEventListener('click', function () { addFolder(); });
tools.appendChild(addFd); 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('面板位置已复位');
});
}
/* ---- 列表 ---- */
// 一个动作行(被 renderList 复用)
function buildScriptRow(sc, inFolder) {
var row = el('div', 'kjl-ar-row' + (inFolder ? ' in-folder' : ''));
row.dataset.id = sc.id;
if (inFolder) row.dataset.folderId = sc.folderId || '';
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);
// (已按要求移除鼠标悬停时的操作提示 title)
if (sc.fail) { // 上次运行失败:名称标红,失败详情放 tooltip
nm.style.color = '#f53f3f';
nm.title = '上次运行失败:第 ' + sc.fail.i + ' 步 · ' + (sc.fail.msg || '');
}
row.appendChild(nm);
// 步数为 0 时用灰色(.zero),有内容才用默认前景色
var stepN = (sc.steps || []).length;
var cnt = el('span', 'cnt' + (stepN === 0 ? ' zero' : ''), stepN + ' 步');
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);
return row;
}
// 一个文件夹行(图标在名称左边;点击图标/名称 = 收起展开)
function buildFolderRow(fd, count) {
var row = el('div', 'kjl-ar-folder');
row.dataset.folderId = fd.id;
if (PLAY && PLAY.folder === fd.id) row.classList.add('busy'); // 本文件夹正在连跑
var ico = el('span', 'fico');
ico.innerHTML = fd.collapsed ? ICON_FOLDER_CLOSED : ICON_FOLDER_OPEN;
row.appendChild(ico);
var nm = el('span', 'nm', fd.name);
// (已按要求移除鼠标悬停时的操作提示 title)
row.appendChild(nm);
// 0 个时用灰色(.zero)
row.appendChild(el('span', 'cnt' + (count === 0 ? ' zero' : ''), count + ' 个'));
// (新增动作只能通过文件夹右键菜单 —— 已移除行内「+」按钮)
// ★ 运行按钮:与动作行的 ▶ 对齐;点它 = 依次运行本文件夹内的每个动作
var act = el('div', 'act');
var runB = el('button', 'kjl-ar-ico', '▶');
runB.title = '运行这个文件夹(依次运行内部每个动作)';
runB.addEventListener('click', function (e) { e.stopPropagation(); doRunFolder(fd); });
act.appendChild(runB);
act.appendChild(el('span', 'ph')); // 占位,和动作行的「● 录制」等宽
row.appendChild(act);
// 点击图标或名称 → ★ 立即折叠切换(原来要等 490ms 的定时器,太慢)
// 代价:不再支持「点两下名称改名」;改名请用右键菜单里的「重命名」。
// ★ 折叠时只增删这一夹的子项,不整表 renderList ——
// 整表重画会替换掉文件夹行节点,:hover 要等下一帧才恢复,
// 右边的运行图标因此会闪一下。
function toggle() {
fd.collapsed = !fd.collapsed;
saveDB();
ico.innerHTML = fd.collapsed ? ICON_FOLDER_CLOSED : ICON_FOLDER_OPEN;
var parent = row.parentNode;
if (!parent) return;
// 先摘掉紧跟在本夹后面的子项行
var sib = row.nextElementSibling;
while (sib && sib.classList.contains('kjl-ar-row')) {
var nx = sib.nextElementSibling;
parent.removeChild(sib);
sib = nx;
}
if (fd.collapsed) return;
// 再按当前顺序把子项插回去
var kids = [];
DB.scripts.forEach(function (s) { if (s.folderId === fd.id) kids.push(s); });
var frag = document.createDocumentFragment();
kids.forEach(function (sc, idx) {
var kr = buildScriptRow(sc, true);
if (idx === kids.length - 1) kr.classList.add('kjl-ar-last'); // 树线收尾
frag.appendChild(kr);
});
parent.insertBefore(frag, row.nextSibling);
}
ico.addEventListener('click', function (e) { e.stopPropagation(); if (now() < SUPPRESS_ROW_CLICK) return; toggle(); });
nm.addEventListener('click', function (e) {
e.stopPropagation();
if (now() < SUPPRESS_ROW_CLICK) return; // ★ 刚拖完,别误折叠
toggle();
});
row.addEventListener('contextmenu', function (e) {
e.preventDefault();
e.stopPropagation();
showFolderMenu(e.clientX, e.clientY, fd);
});
// ★ 文件夹可拖动排序;「默认」夹内部直接返回,不接拖动
enableFolderDrag(row, fd);
return row;
}
function renderList() {
if (!UI.list) return;
var list = UI.list;
list.innerHTML = '';
var folders = DB.folders || [];
if (!DB.scripts.length && !folders.length) {
list.appendChild(el('div', 'kjl-ar-empty', '还没有动作,右键文件夹「新建动作」开始'));
return;
}
// 按 folderId 分组(★ 顶层禁止动作:游离动作也归入默认文件夹,界面不出现顶层动作)
var defFd = null;
for (var z = 0; z < folders.length; z++) { if (folders[z].name === '默认') { defFd = folders[z]; break; } }
var byFolder = {};
folders.forEach(function (fd) { byFolder[fd.id] = []; });
var loose = [];
DB.scripts.forEach(function (sc) {
if (sc.folderId && byFolder[sc.folderId]) byFolder[sc.folderId].push(sc);
else loose.push(sc);
});
if (loose.length) {
if (!defFd) {
defFd = { id: newId(), name: '默认', collapsed: false };
DB.folders.push(defFd);
byFolder[defFd.id] = loose;
} else {
byFolder[defFd.id] = byFolder[defFd.id].concat(loose);
}
loose.forEach(function (sc) { sc.folderId = defFd.id; });
saveDB();
loose = [];
}
// ① 先渲染文件夹(连同其子项,子项缩进)
folders.forEach(function (fd) {
var kids = byFolder[fd.id] || [];
list.appendChild(buildFolderRow(fd, kids.length));
if (!fd.collapsed) {
kids.forEach(function (sc, idx) {
var kr = buildScriptRow(sc, true);
if (idx === kids.length - 1) kr.classList.add('kjl-ar-last'); // 树线收尾
list.appendChild(kr);
});
}
});
// ②(已移除顶层动作渲染 —— 动作只能在文件夹内)
}
// 交互:单击不做事;双击 = 重命名(运行走右侧 ▶ 或右键菜单,避免误触直接把动作跑起来)
// 单击 = 选中;双击(间隔 < 480ms)= 打开修改;两次点击(间隔 480~2500ms)= 重命名
var RENAME_GAP_MIN = 480;
var RENAME_GAP_MAX = 2500;
function selectRow(row, sc) {
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');
}
function bindRow(row, nm, sc) {
row.addEventListener('click', function (e) {
if (e.target.closest('button, input')) return;
if (now() < SUPPRESS_ROW_CLICK) return;
selectRow(row, sc);
var t = now();
var gap = row.__lastClick ? t - row.__lastClick : 0;
arDbg({ t: 'rowclick', gap: gap, connected: row.isConnected, hasInput: !!row.querySelector('.kjl-ar-name-in') });
if (gap > RENAME_GAP_MIN && gap < RENAME_GAP_MAX) { // 点两下(较慢)→ 改名
row.__lastClick = 0;
renameInline(row, nm, sc);
} else {
row.__lastClick = t;
}
});
row.addEventListener('dblclick', function (e) {
if (e.target.closest('button')) return;
if (now() < SUPPRESS_ROW_CLICK) return; // 刚拖完别误触发
row.__lastClick = 0;
showSteps(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);
});
});
}
// 「默认」文件夹:位置固定、不许重命名、不许删除
function isDefaultFolder(fd) { return !!fd && fd.name === '默认'; }
// 指针在「列表内容坐标系」里的 Y(不受 CSS transform / 动画影响)
// ★ 拖动时 flipMove 会给别的行加 translateY 动画,getBoundingClientRect() 拿到的是
// 动画中间态,鼠标一快就会算错插入点 —— 所以一律用 offsetHeight 累加来算位置。
function pointerContentY(clientY) {
var list = UI.list;
var lr = list.getBoundingClientRect();
// 要扣掉「边框 + padding-top」:eachRowY 的 y 从内容区顶部算起,
// 漏掉 padding 会让所有落点整体偏移(列表有 padding:4px 0)。
var cs = getComputedStyle(list);
var padT = parseFloat(cs.paddingTop) || 0;
return clientY - lr.top - (list.clientTop || 0) - padT + list.scrollTop;
}
function eachRowY(cb) {
var nodes = [].slice.call(UI.list.children);
var y = 0;
for (var i = 0; i < nodes.length; i++) {
var h = nodes[i].offsetHeight;
if (cb(nodes[i], y, h) === false) return;
y += h;
}
}
// 指针是否落在列表的内容区内(上下越界就不给落点)
// ★ 之前不判断越界:把动作往上拖到工具条区域时,
// dropSlot 仍会算出"第一行之前"的落点,行被挪到列表外/顶部,
// 看起来就是"能拖到『默认』文件夹上面"。
function pointerInList(clientY) {
var list = UI.list;
var lr = list.getBoundingClientRect();
return clientY >= lr.top && clientY <= lr.bottom;
}
// 指针落点:{ node, half }
// half='top' → 插到该节点之前
// half='bottom' → 插到该节点之后
// ★ 按「指针在哪一行的上半/下半」判定,而不是"第一个中点在指针下方的行" ——
// 后者无法表达「放到上一个文件夹末尾、下一个文件夹标题之前」这种位置(用户画红线的位置)。
function dropSlot(clientY, dragged) {
if (!pointerInList(clientY)) return null; // ★ 超出列表范围 → 没有落点
var cur = pointerContentY(clientY);
var res = null, onSelf = false;
eachRowY(function (n, y, h) {
if (h <= 0) return;
if (cur < y + h) { // 指针落在这一行里
if (n === dragged) { onSelf = true; return false; } // 落在自己身上 → 不动(防抖)
res = { node: n, half: (cur < y + h / 2) ? 'top' : 'bottom' };
return false; // ★ 找到就停,别被后面的行覆盖
}
if (n !== dragged) res = { node: n, half: 'bottom' }; // 记作"已越过该行"
});
return onSelf ? null : res;
}
// 把 row 挪到落点位置;返回是否真的挪了
function insertRowAt(row, slot) {
if (!slot || !slot.node) return false;
var n = slot.node;
if (n === row) return false;
var isFolder = n.classList.contains('kjl-ar-folder');
if (isFolder && slot.half === 'top') {
// 文件夹标题之前 → 归属「上一个文件夹」的末尾(红线要的就是这个位置)
// ★ 但「第一个文件夹之前」是非法位置:动作必须在某个文件夹内,
// 那里没有"上一个夹",插进去只会把第一个夹整体推下去(动画上看着像能放)。
var firstFolder = UI.list.querySelector('.kjl-ar-folder');
if (!firstFolder || n === firstFolder) return false;
if (row.nextElementSibling === n) return false;
UI.list.insertBefore(row, n);
return true;
}
if (isFolder) {
// 文件夹标题之后 → 该夹末尾
var last = null, s = n.nextElementSibling;
while (s && !s.classList.contains('kjl-ar-folder')) {
if (s.classList.contains('kjl-ar-row') && s !== row) last = s;
s = s.nextElementSibling;
}
if (last) {
if (row.previousElementSibling === last) return false;
UI.list.insertBefore(row, last.nextElementSibling);
return true;
}
if (n.nextElementSibling === row) return false;
UI.list.insertBefore(row, n.nextElementSibling);
return true;
}
// 动作行
if (slot.half === 'top') {
if (row.nextElementSibling === n) return false;
UI.list.insertBefore(row, n);
return true;
}
var ref = n.nextElementSibling;
if (ref === row) return false;
UI.list.insertBefore(row, ref); // ref 为 null 时等于追加到末尾
return true;
}
// 取消所有挂起的「单击折叠」定时器(拖动开始/结束时调用,避免误折叠)
function cancelFolderFold() {
document.querySelectorAll('.kjl-ar-folder').forEach(function (f) {
if (f.__foldT) { clearTimeout(f.__foldT); f.__foldT = 0; }
f.__lastClick = 0;
});
}
// 拖动时指针停在哪个「文件夹行」上(文件夹拖动排序用)
// 用内容坐标判定(而不是 elementFromPoint),避免动画期间命中错行。
function folderUnder(clientX, clientY) {
var list = UI.list;
var lr = list.getBoundingClientRect();
if (clientX < lr.left || clientX > lr.right) return null;
var cur = pointerContentY(clientY);
if (cur < 0 || cur > list.scrollHeight) return null;
var hit = null;
eachRowY(function (n, y, h) {
if (!n.classList.contains('kjl-ar-folder')) return;
if (h <= 0) return;
if (cur >= y && cur < y + h) { hit = n; return false; }
});
return hit;
}
// ★ 文件夹拖动排序:只改 DB.folders 的顺序(子动作随文件夹整体移动)
// 「默认」夹不参与 —— 它始终停在原来的槽位。
// 拖动开始时给所有文件夹标题拍一份几何快照(内容坐标)
function captureFolderBands() {
var out = [];
eachRowY(function (n, y, h) {
if (n.classList.contains('kjl-ar-folder') && h > 0) out.push({ id: n.dataset.folderId, y: y, h: h });
});
return out;
}
// ★ 与动作拖动同一套语义:按指针在「某个文件夹那一行」的上半/下半,
// 决定插到该文件夹之前 / 之后。
// ★ 关键:用**拖动开始时拍的快照**算,而不是当前 DOM。
// 因为拖动会实时重排、整表重画,光标下的行一直在变,
// 用当前 DOM 会形成反馈循环(每动一格判定就翻一次,表现为"拖不到位")。
function folderOrderByPointer(bands0, draggedId, clientY) {
if (!bands0 || !bands0.length) return null;
var cur = pointerContentY(clientY);
// 虚拟布局:把被拖的文件夹从快照里摘掉,后面的整体上移
var virt = [], shift = 0;
bands0.forEach(function (b) {
if (b.id === draggedId) { shift += b.h; return; }
virt.push({ id: b.id, y: b.y - shift, h: b.h });
});
if (!virt.length) return null;
var at = virt.length;
for (var i = 0; i < virt.length; i++) {
// 上半 → 插到它之前;下半 → 继续往后找(= 插到它之后)
if (cur < virt[i].y + virt[i].h / 2) { at = i; break; }
}
var ids = virt.map(function (b) { return b.id; });
ids.splice(at, 0, draggedId);
// 「默认」夹钉在原槽位
var origIds = (DB.folders || []).map(function (f) { return f.id; });
var defId = null;
(DB.folders || []).forEach(function (f) { if (isDefaultFolder(f)) defId = f.id; });
if (defId) {
var defOrigIdx = origIds.indexOf(defId), defCurIdx = ids.indexOf(defId);
if (defCurIdx >= 0 && defOrigIdx >= 0 && defCurIdx !== defOrigIdx) {
ids.splice(defCurIdx, 1);
ids.splice(Math.min(defOrigIdx, ids.length), 0, defId);
}
}
return ids;
}
function applyFolderOrder(ids) {
if (!ids) return false;
var byId = {};
(DB.folders || []).forEach(function (f) { byId[f.id] = f; });
var next = [];
ids.forEach(function (id) { if (byId[id]) { next.push(byId[id]); delete byId[id]; } });
Object.keys(byId).forEach(function (k) { next.push(byId[k]); }); // 兜底
if (next.length !== (DB.folders || []).length) return false;
var same = next.every(function (f, i) { return f.id === DB.folders[i].id; });
DB.folders = next;
return !same;
}
function enableFolderDrag(row, fd) {
if (isDefaultFolder(fd)) return; // ★ 默认夹禁止移动
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, lastKey = '', bands0 = null;
row.style.cursor = 'grab';
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;
bands0 = captureFolderBands(); // ★ 拖动开始先拍几何快照,之后都按它算
cancelFolderFold();
ghost = row.cloneNode(true);
ghost.className = 'kjl-ar-folder 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);
document.body.classList.add('kjl-ar-dragging');
if (UI.panel) UI.panel.classList.add('dragging');
}
ghost.style.top = (ev.clientY - offsetY) + 'px';
var order = folderOrderByPointer(bands0, fd.id, ev.clientY);
var key = (order || []).join(',');
if (order && key !== lastKey) {
lastKey = key;
if (applyFolderOrder(order)) renderList(); // 实时预览:整表重画,子项跟着夹子走
}
}
function onUp() {
document.removeEventListener('pointermove', onMove, true);
document.removeEventListener('pointerup', onUp, true);
document.removeEventListener('pointercancel', onUp, true);
row.style.cursor = '';
if (!dragging) return;
if (ghost && ghost.parentElement) ghost.parentElement.removeChild(ghost);
document.body.classList.remove('kjl-ar-dragging');
if (UI.panel) UI.panel.classList.remove('dragging');
try { row.classList.remove('kjl-ar-row-dragging'); } catch (x) { }
SUPPRESS_ROW_CLICK = now() + 250;
cancelFolderFold();
saveDB();
renderList();
toast('文件夹顺序已调整');
}
document.addEventListener('pointermove', onMove, true);
document.addEventListener('pointerup', onUp, true);
document.addEventListener('pointercancel', onUp, true);
});
}
// ★ 松手时按「当前 DOM 布局」重排动作:
// 每个动作行归属「它前面最近的那个文件夹标题」,同一标题下按 DOM 先后排序。
// 这样同时覆盖:① 文件夹内部上下排序 ② 拖到另一个文件夹的兄弟行之间 = 跨夹移动。
// 收起文件夹里的动作没有 DOM 节点 → 按原相对顺序补回各自夹末尾。
function applyDropLayout(draggedId) {
var nodes = [].slice.call(UI.list.children);
var curFid = null;
var order = []; // [{id, fid}] 按 DOM 顺序
nodes.forEach(function (n) {
if (n.classList.contains('kjl-ar-folder')) {
curFid = n.dataset.folderId || null;
return;
}
if (!n.classList.contains('kjl-ar-row') || !n.dataset.id) return;
if (!curFid) return; // 落在所有文件夹标题之前:忽略,走兜底
order.push({ id: n.dataset.id, fid: curFid });
});
if (!order.length) return false;
var byId = {};
DB.scripts.forEach(function (s) { byId[s.id] = s; });
var placed = {};
var byFid = {};
(DB.folders || []).forEach(function (f) { byFid[f.id] = []; });
order.forEach(function (o) {
var s = byId[o.id];
if (!s) return;
if (!byFid[o.fid]) byFid[o.fid] = [];
s.folderId = o.fid; // ★ 归属以 DOM 位置为准(跨夹移动靠这句生效)
byFid[o.fid].push(s);
placed[s.id] = 1;
});
// 没出现在 DOM 里的(收起的夹子里的动作)按原顺序补回各自夹
DB.scripts.forEach(function (s) {
if (placed[s.id]) return;
var fid = s.folderId;
if (!fid || !byFid[fid]) {
// 没有夹子归属 → 放进默认夹
var def = null;
(DB.folders || []).forEach(function (f) { if (isDefaultFolder(f)) def = f; });
if (!def) return;
fid = def.id;
s.folderId = fid;
if (!byFid[fid]) byFid[fid] = [];
}
byFid[fid].push(s);
});
// 按文件夹顺序重排成扁平数组(renderList 分组读的就是这个顺序)
var next = [];
(DB.folders || []).forEach(function (f) {
(byFid[f.id] || []).forEach(function (s) { next.push(s); });
});
if (next.length !== DB.scripts.length) return false; // 数量对不上就放弃,避免丢数据
DB.scripts = next;
saveDB();
return true;
}
// 拖到垃圾桶上方时,指针旁边显示红色加粗提示
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;
cancelFolderFold(); // ★ 别让单击折叠在拖动期间生效
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();
// ★ 按落点(行 + 上半/下半)实时移动,整表重画由松手时统一做
// 指针在列表外时 dropSlot 返回 null → 不移动(禁止拖到列表之上/之下)
var slot = dropSlot(ev.clientY, row);
flipMove(function () { insertRowAt(row, slot); });
}
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;
cancelFolderFold(); // ★ 兜底:拖完别再折叠
if (overTrash) { trashDelete(sc); return; } // 落在垃圾桶上:直接删,不排序
// ★ 落点已经在拖动过程中实时摆好了(含夹内排序 / 跨夹移动 / 放到夹末尾)
// 这里只按 DOM 布局把归属和顺序写回数据
if (!applyDropLayout(sc.id)) {
// 布局算不出来时的兜底:至少别留下没有夹子的动作
if (!sc.folderId) {
var defFd2 = null;
for (var q2 = 0; q2 < (DB.folders || []).length; q2++) {
if (DB.folders[q2].name === '默认') { defFd2 = DB.folders[q2]; break; }
}
if (!defFd2) {
defFd2 = { id: newId(), name: '默认', collapsed: false };
DB.folders = DB.folders || [];
DB.folders.push(defFd2);
}
sc.folderId = defFd2.id;
saveDB();
}
}
// 落到收起的夹子里 → 展开,否则看不到它去哪了
var landed = (DB.folders || []).filter(function (f) { return f.id === sc.folderId; })[0];
if (landed && landed.collapsed) { landed.collapsed = false; saveDB(); }
renderList();
}
document.addEventListener('pointermove', onMove, true);
document.addEventListener('pointerup', onUp, true);
document.addEventListener('pointercancel', onUp, true);
});
}
function renameInline(row, nm, sc) {
arDbg({ t: 'renameTry', connected: row.isConnected, hasInput: !!row.querySelector('.kjl-ar-name-in'), nmParent: !!(nm && nm.parentNode === row) });
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 () { exportOneScript(sc); }, false, false],
['修改', '✱', function () { showSteps(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 () { 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 ensureDefaultFolder() {
DB.folders = DB.folders || [];
var def = null;
for (var i = 0; i < DB.folders.length; i++) {
if (/^默认/.test(DB.folders[i].name)) { def = DB.folders[i]; break; }
}
if (!def) {
def = { id: newId(), name: '默认', collapsed: false };
DB.folders.push(def);
}
// 顶层动作 → 归入默认文件夹
var changed = false;
DB.scripts.forEach(function (s) {
if (!s.folderId) { s.folderId = def.id; changed = true; }
});
if (changed) saveDB();
}
function nextFolderName() {
var used = {};
(DB.folders || []).forEach(function (f) { used[f.name] = 1; });
var n = 1;
while (used['文件夹 ' + n]) n++;
return '文件夹 ' + n;
}
function addFolder() {
var fd = { id: newId(), name: nextFolderName(), collapsed: false };
DB.folders = DB.folders || [];
DB.folders.push(fd);
saveDB();
renderList();
var row = UI.list.querySelector('.kjl-ar-folder[data-folder-id="' + fd.id + '"]');
if (row) {
var nm = row.querySelector('.nm');
if (nm) renameFolderInline(row, nm, fd);
}
return fd;
}
function renameFolderInline(row, nm, fd) {
if (isDefaultFolder(fd)) { toast('「默认」文件夹不能重命名', true); return; } // ★ 兜底
if (!row.isConnected || row.querySelector('.kjl-ar-name-in')) return;
var inp = document.createElement('input');
inp.className = 'kjl-ar-name-in';
inp.value = fd.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) { fd.name = v; 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 delFolder(fd) {
if (isDefaultFolder(fd)) { toast('「默认」文件夹不能删除', true); return; } // ★ 兜底
var kids = DB.scripts.filter(function (s) { return s.folderId === fd.id; });
// ★ 删除文件夹 = 连里面的动作一起删(用户明确要求),确认框用面板自己的样式
var names = kids.map(function (s) { return s.name; });
var shown = names.slice(0, 5).join('、') + (names.length > 5 ? ' 等' : '');
askConfirm({
title: '删除文件夹',
lines: kids.length
? ['文件夹「' + esc(fd.name) + '」里有 ' + kids.length + ' 个动作,'
+ '删除文件夹会同时删除这些动作,且无法恢复。',
'包含:' + esc(shown)]
: ['确定删除文件夹「' + esc(fd.name) + '」?'],
okText: '删除',
danger: true
}).then(function (yes) {
if (!yes) return;
// 里面有正在录制/运行的动作 → 先停掉,避免状态悬挂
kids.forEach(function (s) {
if (REC && REC.id === s.id) stopRecord();
if (PLAY && PLAY.id === s.id) { PLAY.stop = true; }
});
DB.scripts = DB.scripts.filter(function (s) { return s.folderId !== fd.id; });
DB.folders = (DB.folders || []).filter(function (f) { return f.id !== fd.id; });
saveDB();
renderList();
toast(kids.length
? '已删除文件夹「' + fd.name + '」及其 ' + kids.length + ' 个动作'
: '已删除文件夹「' + fd.name + '」');
});
}
function showFolderMenu(x, y, fd) {
hideMenu();
var m = el('div', 'kjl-ar-menu');
// ★ 已移除「收起/展开」(点图标或名称即可);「默认」夹禁止重命名与删除
// 每项第 4 个值是「是否危险项(红色)」,不能再用"最后一项"判断 ——
// 默认夹只有一项时会被误标红。
var items = [
['在此新建动作', '+', function () { addScript(fd.id); }, false],
// ★ 导出该文件夹(含内部所有动作)—— 默认夹也允许导出
['导出动作', '⤓', function () { exportOneFolder(fd); }, false]
];
if (!isDefaultFolder(fd)) {
items.push(['重命名', '✎', function () {
var row = UI.list.querySelector('.kjl-ar-folder[data-folder-id="' + fd.id + '"]');
if (row) renameFolderInline(row, row.querySelector('.nm'), fd);
}, false]);
items.push(['删除文件夹', '🗑', function () { delFolder(fd); }, true]);
}
items.forEach(function (it) {
var danger = !!it[3];
var d = el('div', 'kjl-ar-mi' + (danger ? ' danger' : ''));
d.innerHTML = '' + it[1] + '' + 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 addScript(folderId) {
// ★ 新需求:动作只能在文件夹内新建。folderId 缺失时兜底落到「默认」文件夹。
if (!folderId) {
for (var k0 = 0; k0 < (DB.folders || []).length; k0++) {
if (DB.folders[k0].name === '默认') { folderId = DB.folders[k0].id; break; }
}
}
var sc = { id: newId(), name: nextName(), steps: [], created: now(), updated: now() };
if (folderId) sc.folderId = folderId; // 在文件夹内新建
DB.scripts.push(sc);
saveDB();
UI.selId = sc.id; // 新增的直接选中
// 新建在收起的文件夹里 → 自动展开,否则新行看不见
if (folderId) {
var fd = (DB.folders || []).filter(function (f) { return f.id === folderId; })[0];
if (fd && fd.collapsed) { fd.collapsed = false; saveDB(); }
}
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();
var n = (sc.steps || []).length;
askConfirm({
title: '删除动作',
lines: ['动作「' + esc(sc.name) + '」删除后无法恢复。',
n ? ('当前共 ' + n + ' 步。') : '当前没有步骤。'],
okText: '删除',
danger: true
}).then(function (yes) {
if (!yes) return;
DB.scripts = DB.scripts.filter(function (s) { return s.id !== sc.id; });
if (UI.selId === sc.id) UI.selId = null;
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 });
}
// 重新录制会清空旧步骤 → 先问一句(复用面板的 mask/dlg 风格)
// ★ 通用确认弹窗(与「重新录制」同一套遮罩/对话框样式),返回 Promise
// opts: { title, lines:[html...], okText, cancelText, danger }
function askConfirm(opts) {
opts = opts || {};
injectCss();
return new Promise(function (resolve) {
var mask = el('div', 'kjl-ar-mask');
var dlg = el('div', 'kjl-ar-dlg kjl-ar-ask');
var settled = false;
function done(v) {
if (settled) return;
settled = true;
try { mask.remove(); } catch (e) { }
resolve(v);
}
var h = el('div', 'h');
h.appendChild(el('b', null, opts.title || '确认'));
h.appendChild(el('div', 'sp'));
var x = el('button', 'kjl-ar-ico', '✕');
x.addEventListener('click', function () { done(false); });
h.appendChild(x);
var bd = el('div', 'bd');
(opts.lines || []).forEach(function (html, i) {
var d = el('div', i === 0 ? null : 'cnt');
d.innerHTML = html;
bd.appendChild(d);
});
var ft = el('div', 'ft');
var cancel = el('button', 'kjl-ar-btn2', opts.cancelText || '取消');
cancel.addEventListener('click', function () { done(false); });
var ok = el('button', 'kjl-ar-btn2' + (opts.danger ? ' warn' : ' pri'), opts.okText || '确定');
ok.addEventListener('click', function () { done(true); });
ft.appendChild(cancel); ft.appendChild(ok);
dlg.appendChild(h); dlg.appendChild(bd); dlg.appendChild(ft);
mask.appendChild(dlg);
mask.addEventListener('click', function (e) { if (e.target === mask) done(false); });
document.body.appendChild(mask);
setTimeout(function () { try { (opts.danger ? cancel : ok).focus(); } catch (e) { } }, 30);
});
}
function askOverwrite(sc, onYes, onContinue) {
injectCss();
var n = (sc.steps && sc.steps.length) || 0;
var mask = el('div', 'kjl-ar-mask');
var dlg = el('div', 'kjl-ar-dlg kjl-ar-ask');
var h = el('div', 'h');
h.appendChild(el('b', null, '录制'));
var sp = el('div', 'sp'); h.appendChild(sp);
var x = el('button', 'kjl-ar-ico', '✕');
x.addEventListener('click', function () { mask.remove(); });
h.appendChild(x);
var bd = el('div', 'bd');
var line1 = el('div');
line1.innerHTML = '动作「' + esc(sc.name) + '」已有 ' + n + ' 步。';
bd.appendChild(line1);
bd.appendChild(el('div', 'cnt', '点「接着录制」保留这些步骤,从后面继续录;点「重新录制」会清空重来,且无法恢复。'));
var ft = el('div', 'ft');
var cancel = el('button', 'kjl-ar-btn2', '取消');
cancel.addEventListener('click', function () { mask.remove(); });
// ★ 接着录制:保留现有步骤,新录的追加在后面
var cont = el('button', 'kjl-ar-btn2 pri', '接着录制');
cont.addEventListener('click', function () { mask.remove(); onContinue && onContinue(); });
var yes = el('button', 'kjl-ar-btn2 warn', '重新录制');
yes.addEventListener('click', function () { mask.remove(); onYes(); });
ft.appendChild(cancel); ft.appendChild(cont); ft.appendChild(yes);
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);
setTimeout(function () { try { cont.focus(); } catch (e) { } }, 30);
}
function toggleRec(sc) {
if (REC && REC.id === sc.id) stopRecord();
else if (REC) { toast('请先结束当前录制', true); }
else if (PLAY) { toast('回放中不能录制', true); }
else if (sc.steps && sc.steps.length) { // 已有步骤 → 问「接着录」还是「重新录」
askOverwrite(sc,
function () { startRecord(sc); }, // 重新录制(清空)
function () { startRecord(sc, { append: true }); }); // 接着录制(保留)
}
else startRecord(sc);
}
/* ---- 修改窗口:列出记录的动作 ---- */
function showSteps(sc) {
injectCss();
var mask = el('div', 'kjl-ar-mask');
var dlg = el('div', 'kjl-ar-dlg kjl-ar-steps');
var h = el('div', 'h');
h.appendChild(el('b', null, '修改动作:'));
// ★ 动作名直接在标题里改
var nameIn = document.createElement('input');
nameIn.className = 'kjl-ar-title-in';
nameIn.value = sc.name;
nameIn.setAttribute(MARK, '1');
nameIn.title = '回车确认,Esc 取消';
var commitName = function () {
var v = (nameIn.value || '').trim();
if (!v) { nameIn.value = sc.name; return; } // 空名字不接受
if (v === sc.name) return;
sc.name = v;
sc.updated = now();
saveDB();
renderList();
};
nameIn.addEventListener('keydown', function (e) {
if (e.key === 'Enter') { e.preventDefault(); commitName(); nameIn.blur(); }
else if (e.key === 'Escape') { e.preventDefault(); nameIn.value = sc.name; nameIn.blur(); }
});
nameIn.addEventListener('blur', function () { commitName(); });
h.appendChild(nameIn);
var sp = el('div', 'sp');
var x = el('button', 'kjl-ar-ico', '✕');
x.addEventListener('click', function () { mask.remove(); });
h.appendChild(sp); h.appendChild(x);
var bd = el('div', 'bd');
var selIx = -1; // 当前选中的步骤行
// ★「改值」行的值直接就是一个输入框(不用双击),提交在 blur / 回车时发生
function makeValueInput(step) {
var inp = document.createElement('input');
inp.className = 'kjl-ar-valin valin';
inp.setAttribute(MARK, '1');
inp.value = (step.value === '' || step.value == null) ? '' : String(step.value);
inp.placeholder = '(空)';
inp.addEventListener('focus', function () { inp.select(); });
// ★ 常驻输入框不能用一次性标记:那样失焦一次后就再也改不动了。
// 改成跟"上次提交的值"比较,每次 blur/回车都能重新提交。
var last = inp.value;
function commit(ok) {
if (!ok) { inp.value = last; return; }
var nv = inp.value;
if (nv === last) return; // 没改动就不写盘
last = nv;
step.value = nv;
step.label = (step.name || step.eventKey) + ' → ' + (nv === '' ? '(空)' : nv);
sc.updated = now();
saveDB();
renderList();
}
inp.addEventListener('keydown', function (e) {
if (e.key === 'Enter') { e.preventDefault(); commit(true); inp.blur(); }
else if (e.key === 'Escape') { e.preventDefault(); commit(false); inp.blur(); }
});
inp.addEventListener('blur', function () { commit(true); });
return inp;
}
// ★ 时间列 = 「距上一步的等待时间」(不是累加时刻)
// 显示/编辑都用相对值;改某一步只影响该步及其后的绝对时刻(后面的相对值不变)。
// ↑↓ 默认 ±10、Ctrl ±100,滚轮同样;范围 0 ~ T_GAP_MAX
function makeTimeInput(step, index) {
var wrap = el('span', 'ttw');
var inp = document.createElement('input');
inp.className = 'ttin';
inp.type = 'number';
inp.min = String(T_GAP_MIN);
inp.max = String(T_GAP_MAX);
inp.step = '10';
inp.setAttribute(MARK, '1');
// 本步相对上一步的等待
function curRel() {
var prevT = index === 0 ? 0 : (sc.steps[index - 1].t || 0);
return Math.max(0, (step.t || 0) - prevT);
}
// 把本步的等待设为 rel(本步及其后的绝对时刻一起平移)
function applyRel(rel) {
var clampedMin = false, clampedMax = false;
if (rel < T_GAP_MIN) { rel = T_GAP_MIN; clampedMin = true; }
if (rel > T_GAP_MAX) { rel = T_GAP_MAX; clampedMax = true; }
var prevT = index === 0 ? 0 : (sc.steps[index - 1].t || 0);
var delta = (prevT + rel) - (step.t || 0);
if (delta) {
for (var k = index; k < sc.steps.length; k++) sc.steps[k].t = (sc.steps[k].t || 0) + delta;
sc.updated = now();
saveDB();
renderList();
}
if (clampedMin) toast('单步等待最小 ' + T_GAP_MIN + 'ms,已改为 ' + T_GAP_MIN);
else if (clampedMax) toast('单步等待最大 ' + T_GAP_MAX + 'ms,已改为 ' + T_GAP_MAX);
return rel;
}
inp.value = String(curRel());
inp.title = '执行时间 ' + T_GAP_MIN + '~' + T_GAP_MAX + 'ms;↑↓ 每次 10,按 Ctrl 走 100,也可用滚轮';
wrap.appendChild(inp);
// 原生 number 的上下调节箭头在 hover/focus 时会自动出现,无需自绘按钮
inp.addEventListener('focus', function () { inp.select(); });
inp.addEventListener('wheel', function (e) {
e.preventDefault();
var delta = (e.ctrlKey ? 100 : 10) * (e.deltaY < 0 ? 1 : -1);
var v0 = parseInt(inp.value, 10);
if (isNaN(v0)) v0 = curRel();
inp.value = String(applyRel(v0 + delta));
}, { passive: false });
// ★ 常驻输入框不能用一次性标记:跟"上次提交值"比较,每次都能提交
var last = inp.value;
function commit(ok) {
if (!ok) { inp.value = last; return; }
var v = parseInt(inp.value, 10);
if (isNaN(v)) v = curRel();
var rel = applyRel(v);
inp.value = String(rel);
last = inp.value;
}
inp.addEventListener('keydown', function (e) {
if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
e.preventDefault(); // 自己算,避免浏览器按 min/max 做步长对齐
var delta = (e.ctrlKey ? 100 : 10) * (e.key === 'ArrowUp' ? 1 : -1);
var v0 = parseInt(inp.value, 10);
if (isNaN(v0)) v0 = curRel();
inp.value = String(applyRel(v0 + delta));
last = inp.value;
return;
}
if (e.key === 'Enter') { e.preventDefault(); commit(true); inp.blur(); }
else if (e.key === 'Escape') { e.preventDefault(); commit(false); inp.blur(); }
});
inp.addEventListener('blur', function () { commit(true); });
// 点原生上下箭头后立即生效(change 事件),不用再失焦
inp.addEventListener('change', function () { commit(true); });
return wrap;
}
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' + (selIx === i ? ' sel' : '') + (s.skip ? ' skip' : ''));
row.dataset.ix = i;
row.appendChild(el('span', 'ix', String(i + 1)));
// ★ 时间整列移到「动作类型」左边(占位仍是 62px),直接就是输入控件
row.appendChild(makeTimeInput(s, i));
// ★ 眼睛按钮:切换「执行时跳过这一步」
var eye = el('button', 'eye');
eye.innerHTML = s.skip ? ICON_EYE_OFF : ICON_EYE;
eye.title = s.skip ? '点击恢复执行' : '点击跳过执行';
eye.addEventListener('click', function (e) {
e.stopPropagation();
s.skip = !s.skip;
sc.updated = now();
saveDB();
// 只更新本行,避免整表重画丢焦点
row.classList.toggle('skip', !!s.skip);
eye.innerHTML = s.skip ? ICON_EYE_OFF : ICON_EYE;
eye.title = s.skip ? '点击恢复执行' : '点击跳过执行';
});
row.appendChild(eye);
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 || '点击');
row.appendChild(el('span', 'kd' + kdCls, kdTxt));
var tx;
if (s.kind === 'setattr') {
// ★ 改值行:名称定宽 + 独立箭头,保证所有改值行的「→」左对齐
tx = el('span', 'tx setattr');
var nmSpan = el('span', 'lbl', (s.name || s.eventKey));
nmSpan.title = (s.name || s.eventKey);
tx.appendChild(nmSpan);
tx.appendChild(el('span', 'arw', '→'));
tx.appendChild(makeValueInput(s)); // 值直接是输入框,占满余下空间
} 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);
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);
// 点行选中(与动作列表同一套选中色);点输入框/删除按钮不选中
row.addEventListener('click', function (ev) {
if (ev.target.closest && ev.target.closest('input, button')) return;
selIx = i;
var all = bd.querySelectorAll('.kjl-ar-step');
for (var k = 0; k < all.length; k++) all[k].classList.remove('sel');
row.classList.add('sel');
});
bd.appendChild(row);
});
}
paint();
var ft = el('div', 'ft');
var clear = el('button', 'kjl-ar-btn2', '清空全部');
clear.addEventListener('click', function () {
var n = (sc.steps || []).length;
if (!n) { toast('没有可清空的步骤'); return; }
askConfirm({
title: '清空全部步骤',
lines: ['动作「' + esc(sc.name) + '」的 ' + n + ' 步 会被清空,且无法恢复。'],
okText: '清空',
danger: true
}).then(function (yes) {
if (!yes) 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);
// ★ 可拖动:按住标题栏移动;位置用 transform 偏移(mask 仍是居中布局)
var ox = 0, oy = 0, sx = 0, sy = 0, dragging = false;
function applyPos() { dlg.style.transform = 'translate(' + ox + 'px,' + oy + 'px)'; }
// 默认位置:与「动作录制与回放」面板左上角重叠
try {
var pr = (UI.panel && UI.panel.isConnected && UI.panel.style.display !== 'none')
? UI.panel.getBoundingClientRect() : null;
if (pr) {
var dr = dlg.getBoundingClientRect();
ox = Math.round(pr.left - dr.left);
oy = Math.round(pr.top - dr.top);
// 别把对话框顶出视口
if (dr.left + ox + dr.width > window.innerWidth - 8) ox = window.innerWidth - 8 - dr.width - dr.left;
if (dr.top + oy + dr.height > window.innerHeight - 8) oy = window.innerHeight - 8 - dr.height - dr.top;
if (dr.left + ox < 8) ox = 8 - dr.left;
if (dr.top + oy < 8) oy = 8 - dr.top;
applyPos();
}
} catch (e) { }
h.style.cursor = 'move';
h.addEventListener('pointerdown', function (e) {
if (e.target.closest('button, input')) return; // 别抢标题输入框/关闭按钮
dragging = true;
sx = e.clientX - ox;
sy = e.clientY - oy;
e.preventDefault();
});
function onDragMove(e) {
if (!dragging) return;
if (!dlg.isConnected) { // 窗口已关,收尾
dragging = false;
document.removeEventListener('pointermove', onDragMove, true);
document.removeEventListener('pointerup', onDragUp, true);
return;
}
ox = e.clientX - sx;
oy = e.clientY - sy;
applyPos();
}
function onDragUp() { dragging = false; }
document.addEventListener('pointermove', onDragMove, true);
document.addEventListener('pointerup', onDragUp, true);
}
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());
}
// 通用的 JSON 下载(导出全部 / 导出单个动作 / 导出文件夹 共用)
function downloadJSON(obj, fileName) {
var data = JSON.stringify(obj, null, 2);
var blob = new Blob([data], { type: 'application/json' });
var a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = fileName;
document.body.appendChild(a);
a.click();
setTimeout(function () { URL.revokeObjectURL(a.href); a.remove(); }, 1000);
}
// 文件名里不能出现的字符换成下划线
function safeName(s) {
var t = String(s == null ? '' : s).replace(/[\\/:*?"<>|\r\n\t]/g, '_').trim();
if (!t) t = '未命名';
return t.length > 60 ? t.slice(0, 60) : t;
}
function exportAll() {
downloadJSON({ v: 2, exported: now(), folders: DB.folders || [], scripts: DB.scripts },
'动作录制-' + fileStamp() + '.json');
}
// ★ 右键「导出动作」——单个动作
function exportOneScript(sc) {
downloadJSON({ v: 2, exported: now(), folders: [], scripts: [sc] },
'动作-' + safeName(sc.name) + '-' + fileStamp() + '.json');
toast('已导出动作「' + sc.name + '」');
}
// ★ 右键「导出动作」——整个文件夹(含其内部所有动作)
function exportOneFolder(fd) {
var kids = DB.scripts.filter(function (s) { return s.folderId === fd.id; });
downloadJSON({
v: 2, exported: now(),
folders: [{ id: fd.id, name: fd.name, collapsed: false }],
scripts: kids
}, '文件夹-' + safeName(fd.name) + '-' + fileStamp() + '.json');
toast('已导出文件夹「' + fd.name + '」及其 ' + kids.length + ' 个动作');
}
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('文件里没有动作');
// 文件夹:按旧 id → 新 id 建映射(旧版导出文件没有 folders,走 else 分支)
DB.folders = DB.folders || [];
var fMap = {};
(obj.folders || []).forEach(function (f) {
if (!f || !f.name) return;
var exist = DB.folders.filter(function (x) { return x.name === f.name; })[0];
if (exist) { fMap[f.id] = exist.id; return; }
var nf = { id: newId(), name: f.name, collapsed: !!f.collapsed };
DB.folders.push(nf);
if (f.id) fMap[f.id] = nf.id;
});
var n = 0;
// ★ 导入时确保有「默认」文件夹(顶层动作都落到它)
var defFd = null;
for (var idz = 0; idz < (DB.folders || []).length; idz++) {
if (DB.folders[idz].name === '默认') { defFd = DB.folders[idz]; break; }
}
if (!defFd) {
defFd = { id: newId(), name: '默认', collapsed: false };
DB.folders.push(defFd);
}
list.forEach(function (s) {
var item = { id: newId(), name: s.name || ('导入 ' + (++n)), steps: s.steps || [], created: now(), updated: now() };
var fid = s.folderId ? fMap[s.folderId] : null;
item.folderId = fid || defFd.id; // 没有所属文件夹 → 归入「默认」
DB.scripts.push(item);
});
saveDB();
renderList();
toast('已导入 ' + list.length + ' 个动作');
} catch (e) {
toast('导入失败:' + (e.message || e), true);
}
};
fr.readAsText(f);
});
inp.click();
}
/* ============================================================ 启动 */
function boot() {
injectCss();
mountButton();
// ★ 一次性迁移:确保存在「默认」文件夹,并把不在任何文件夹里的动作归入它。
// (新需求:顶层禁止添加动作,动作只能在文件夹内。旧数据里的顶层动作也迁进去。)
ensureDefaultFolder();
// 站点重渲染会把按钮顶掉/位置变化,周期性校正(很轻,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();
})();