// ==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(); })();