用户反馈:录制出来的滑动又被套上滑动那套逻辑(方向/幅度/拟人重新生成),
"导致滑动还是不顺畅"。录制就该是录制——按录下的路径与时间原样重放。
- 新步骤 `gesture`「录制手势」:存完整轨迹点列 `[[x,y,t_ms],…]`,
回放时原样交给设备(`d.swipe_points(points, duration)`),不做任何加工。
与 `swipe` 是两套东西(滑动是参数化的,录制是点列)。
- core/gesture.py(新):
· 手机端录制 `PhoneRecorder`:`getevent` 读**真触屏**设备(自动挑 fts_ts 这类、
排除 uinput/vitural-sar 等合成节点),解析两套协议(BTN_TOUCH / ABS_MT_TRACKING_ID);
**合成的注入事件不会出现在真触屏节点上**(实测),所以录到的只有人手的动作;
· 点列清洗:按 ≥16ms 抽稀但**末点必留**(快划时不能把收尾丢了);
· 回放**一次调用**而不是逐点注入:实测这台设备单次触摸 RPC ≈190ms,
逐点回放 20 点要 3.8 秒——只能让设备自己插值,"时间"由点密度还原。
- 接口:POST /api/gesture/record/{start,stop}(需设备权限;重复开始返回 409)。
- 「录制手势」步骤卡片:录到就回填(手机上录 / 网页上录),显示点数/时长;
**滑动步骤上的「录制手势」按钮已移除**(按用户要求,录制不再走滑动逻辑)。
- 录制前自动唤醒设备:**息屏时 u2 抓 UI 树会从 3 秒退化到 65 秒**(实测),
截图也是黑的——这是本次排查最耗时的坑,已写进文档。
自测:假设备单测(点列/时长/抽稀/失败退回直线,10 项);
无头浏览器 + CDP 真模拟拖动 → 录到 14 点/605ms 并按实际轨迹回填;
手机录制接口起停/重复拦截;
真机回放:用录下的轨迹跑任务 → 步骤 `gesture ok`。
⚠️ 手机端"真手指轨迹"这一段需要人真的去划才能端到端验证(我没有手指)。
文档:TASK_DEV §3.2(两套东西对比 + 三个坑)、步骤表 21 种、API、README。
419 lines
18 KiB
JavaScript
419 lines
18 KiB
JavaScript
// ================== 动作录制器 ==================
|
||
// 在设备截图上操作(点/划/按键/输入),自动翻译成**步骤**:
|
||
// · 点 → 命中元素就记「点击元素」(选择器),抓不到就退化成「点击坐标」(百分比)
|
||
// · 划 → 记**「录制手势」**:完整轨迹点列 [[x,y,t_ms],…],**纯回放**,不再套滑动逻辑
|
||
// · 停顿 → 可选记成「等待」(按真实间隔 ±15%)
|
||
//
|
||
// 两个录制来源:
|
||
// · 网页上录:在这个窗口里按住鼠标拖(采样路径 + 时间)
|
||
// · 手机上录:点「手机上录」后**用手指在真机上划**——后端读 getevent 抓真触屏,
|
||
// 录到的是人手的真实轨迹(比在网页上模拟更真)
|
||
//
|
||
// 两种用法(同一个窗口):
|
||
// mode='action' —— 录一段,存成「自定义动作」(进动作库,能拖进任何任务)
|
||
// mode='gesture' —— 录到的手势直接回填给调用方(步骤编辑器里的「录制手势」步骤)
|
||
//
|
||
// 元素树用 /api/uiauto/snapshot 一次取齐(截图 + 元素),**每次操作后自动刷新**,
|
||
// 这样下一次点击就能用最新的树翻译选择器(抖音这种没有 id 的界面靠 text/desc)。
|
||
|
||
let _rec = null;
|
||
|
||
function _recEl() { return document.getElementById('rec-overlay'); }
|
||
|
||
async function openRecorder(opts) {
|
||
opts = opts || {};
|
||
if (_recEl()) return;
|
||
_rec = {
|
||
mode: opts.mode || 'action',
|
||
serial: opts.serial || '',
|
||
onDone: opts.onDone || null,
|
||
steps: [],
|
||
elements: [],
|
||
devW: 0, devH: 0, imgURL: '',
|
||
lastTs: 0, dragFrom: null, busy: false, keepPause: true,
|
||
samples: [], phone: null, autoPhone: !!opts.autoStartPhone,
|
||
shots: 0, unstable: false,
|
||
};
|
||
const ov = document.createElement('div');
|
||
ov.id = 'rec-overlay';
|
||
ov.className = 'modal-overlay show'; // .modal-overlay 默认 display:none,必须带 show
|
||
ov.innerHTML =
|
||
'<div class="modal-box rec-box">' +
|
||
'<div class="rec-head">' +
|
||
'<b>' + (_rec.mode === 'gesture' ? '录制手势' : '录制动作') + '</b>' +
|
||
'<select id="rec-device" class="form-control" style="width:auto" onchange="recPickDevice()"></select>' +
|
||
'<button class="btn btn-sm" onclick="recRefresh()">刷新画面</button>' +
|
||
'<label class="form-check"><input type="checkbox" id="rec-pause" checked onchange="_rec.keepPause=this.checked"><span>记录停顿</span></label>' +
|
||
'<span style="flex:1"></span>' +
|
||
'<button class="btn btn-sm" onclick="closeRecorder()">关闭</button>' +
|
||
'</div>' +
|
||
'<div class="rec-body">' +
|
||
'<div class="rec-stage" id="rec-stage"><div class="rec-hint">正在取画面…</div></div>' +
|
||
'<div class="rec-side">' +
|
||
'<div class="rec-ops">' +
|
||
'<button class="btn btn-sm" onclick="recKey(\'back\')">返回</button>' +
|
||
'<button class="btn btn-sm" onclick="recKey(\'home\')">Home</button>' +
|
||
'<button class="btn btn-sm" onclick="recKey(\'recent\')">最近</button>' +
|
||
'<button class="btn btn-sm" onclick="recKey(\'enter\')">回车</button>' +
|
||
'</div>' +
|
||
'<div class="rec-row"><input id="rec-text" class="form-control" placeholder="要输入的文字">' +
|
||
'<button class="btn btn-sm" onclick="recText()">输入</button></div>' +
|
||
'<div class="rec-ops">' +
|
||
'<button class="btn btn-sm" onclick="recAddWait()">+ 等待</button>' +
|
||
'<button class="btn btn-sm" onclick="recClear()">清空</button>' +
|
||
'</div>' +
|
||
'<div class="rec-list-title">录到的步骤 <span id="rec-count" class="text-muted"></span></div>' +
|
||
'<div class="rec-list" id="rec-list"></div>' +
|
||
'<div class="rec-foot" id="rec-foot"></div>' +
|
||
'</div>' +
|
||
'</div>' +
|
||
'</div>';
|
||
ov.addEventListener('mousedown', function (e) { if (e.target === ov) closeRecorder(); });
|
||
document.body.appendChild(ov);
|
||
await recLoadDevices();
|
||
recRender();
|
||
if (_rec.autoPhone && _rec.serial) recStartPhone();
|
||
}
|
||
|
||
function closeRecorder() {
|
||
const ov = _recEl();
|
||
if (ov) ov.remove();
|
||
_rec = null;
|
||
}
|
||
|
||
async function recLoadDevices() {
|
||
const sel = document.getElementById('rec-device');
|
||
if (!sel) return;
|
||
sel.innerHTML = '<option value="">选择设备…</option>';
|
||
const r = await apiGet('/api/devices');
|
||
if (!r || !r.ok) return;
|
||
(r.items || []).forEach(function (d) {
|
||
const o = document.createElement('option');
|
||
o.value = d.serial;
|
||
o.textContent = d.name ? (d.name + '(' + d.serial + ')') : d.serial;
|
||
sel.appendChild(o);
|
||
});
|
||
if (_rec.serial) { sel.value = _rec.serial; }
|
||
else if ((r.items || []).length) { sel.value = r.items[0].serial; _rec.serial = r.items[0].serial; }
|
||
if (_rec.serial) { await recWake(); recRefresh(); }
|
||
}
|
||
|
||
async function recPickDevice() {
|
||
const sel = document.getElementById('rec-device');
|
||
_rec.serial = sel ? sel.value : '';
|
||
_rec.elements = [];
|
||
recRender();
|
||
if (_rec.serial) { await recWake(); recRefresh(); }
|
||
}
|
||
|
||
// 录制前先唤醒设备:**息屏时 u2 抓 UI 树会退化到几十秒**(实测 65s,唤醒后 3.3s),
|
||
// 截图也是一片黑。唤醒是幂等的,多调一次没有副作用。
|
||
async function recWake() {
|
||
if (!_rec || !_rec.serial) return;
|
||
try {
|
||
const r = await apiPost('/api/device/screen_all', { mode: 'on', serials: [_rec.serial] });
|
||
if (r && r.ok) await new Promise(function (z) { setTimeout(z, 800); });
|
||
} catch (e) { /* 唤醒失败也继续,用户自己会看到黑屏 */ }
|
||
}
|
||
|
||
// 取一张快照:截图 + 元素树(同一个连接背靠背取,避免框错位)
|
||
async function recRefresh() {
|
||
if (!_rec || !_rec.serial) return;
|
||
const r = await apiGet('/api/uiauto/snapshot?serial=' + encodeURIComponent(_rec.serial));
|
||
if (!r || !r.ok) {
|
||
const st = document.getElementById('rec-stage');
|
||
if (st) st.innerHTML = '<div class="rec-hint">取画面失败:' + esc((r && r.error) || '未知错误') + '</div>';
|
||
return;
|
||
}
|
||
_rec.imgURL = r.image || '';
|
||
_rec.devW = r.width || 0;
|
||
_rec.devH = r.height || 0;
|
||
_rec.elements = r.elements || [];
|
||
_rec.unstable = !!r.unstable;
|
||
_rec.shots++;
|
||
recPaint();
|
||
}
|
||
|
||
function recPaint() {
|
||
const st = document.getElementById('rec-stage');
|
||
if (!st) return;
|
||
if (!_rec.imgURL) { st.innerHTML = '<div class="rec-hint">正在取画面…</div>'; return; }
|
||
st.innerHTML = '<img id="rec-img" src="' + _rec.imgURL + '" draggable="false">' +
|
||
(_rec.unstable ? '<div class="rec-warn">画面在变化,这次框可能不准</div>' : '');
|
||
const img = document.getElementById('rec-img');
|
||
img.addEventListener('mousedown', recDown);
|
||
img.addEventListener('mousemove', recMove);
|
||
img.addEventListener('mouseup', recUp);
|
||
img.addEventListener('mouseleave', function () { _rec.dragFrom = null; recPaintOverlay(); });
|
||
recPaintOverlay();
|
||
}
|
||
|
||
// 拖动中的轨迹提示(录制时能看见自己划到哪)
|
||
function recPaintOverlay() {
|
||
const st = document.getElementById('rec-stage');
|
||
if (!st) return;
|
||
let cv = document.getElementById('rec-overlay-line');
|
||
if (!cv) {
|
||
cv = document.createElement('div');
|
||
cv.id = 'rec-overlay-line';
|
||
cv.className = 'rec-line';
|
||
st.appendChild(cv);
|
||
}
|
||
if (!_rec.dragFrom) { cv.style.display = 'none'; return; }
|
||
cv.style.display = 'block';
|
||
const a = _rec.dragFrom;
|
||
const b = _rec.dragTo || a;
|
||
const x = Math.min(a.x, b.x), y = Math.min(a.y, b.y);
|
||
cv.style.left = x + 'px'; cv.style.top = y + 'px';
|
||
cv.style.width = Math.abs(b.x - a.x) + 'px';
|
||
cv.style.height = Math.abs(b.y - a.y) + 'px';
|
||
}
|
||
|
||
function _recPos(e) {
|
||
const img = document.getElementById('rec-img');
|
||
if (!img) return null;
|
||
const r = img.getBoundingClientRect();
|
||
const x = e.clientX - r.left, y = e.clientY - r.top;
|
||
if (x < 0 || y < 0 || x > r.width || y > r.height) return null;
|
||
// 显示坐标 → 设备坐标
|
||
const sx = _rec.devW / r.width, sy = _rec.devH / r.height;
|
||
return { x: Math.round(x * sx), y: Math.round(y * sy), rx: x, ry: y, cssW: r.width, cssH: r.height };
|
||
}
|
||
|
||
function recDown(e) {
|
||
if (_rec.busy) return;
|
||
const p = _recPos(e);
|
||
if (!p) return;
|
||
_rec.dragFrom = p; _rec.dragTo = null; _rec.dragT0 = Date.now();
|
||
// 采样:拖动过程中把每个点连时间一起记下来(这就是"轨迹")
|
||
_rec.samples = [[p.x, p.y, 0]];
|
||
recPaintOverlay();
|
||
}
|
||
|
||
function recMove(e) {
|
||
if (!_rec.dragFrom) return;
|
||
const p = _recPos(e);
|
||
if (!p) return;
|
||
_rec.dragTo = p;
|
||
const t = Date.now() - _rec.dragT0;
|
||
const last = _rec.samples[_rec.samples.length - 1];
|
||
if (!last || t - last[2] >= 8 || Math.abs(p.x - last[0]) + Math.abs(p.y - last[1]) >= 3) {
|
||
if (_rec.samples.length < 400) _rec.samples.push([p.x, p.y, t]);
|
||
}
|
||
recPaintOverlay();
|
||
}
|
||
|
||
async function recUp(e) {
|
||
if (!_rec.dragFrom || _rec.busy) { _rec.dragFrom = null; return; }
|
||
const a = _rec.dragFrom;
|
||
const b = _recPos(e) || a;
|
||
const durMs = Date.now() - _rec.dragT0;
|
||
_rec.dragFrom = null;
|
||
recPaintOverlay();
|
||
const dist = Math.hypot(b.x - a.x, b.y - a.y);
|
||
if (dist < 12 || durMs < 60) { // 视为点击
|
||
_rec.samples = [];
|
||
await recTap(a.x, a.y);
|
||
} else {
|
||
const pts = _rec.samples.slice();
|
||
pts.push([b.x, b.y, durMs]); // 末点补上(决定手势收尾位置)
|
||
_rec.samples = [];
|
||
recPushGesture(pts, 'web');
|
||
await recRefresh();
|
||
}
|
||
}
|
||
|
||
// 一条录到的轨迹 → 「录制手势」步骤(**纯回放**:点列+时长原样存下,不再套滑动逻辑)
|
||
function recPushGesture(points, src) {
|
||
if (!points || points.length < 2) { showToast('没录到有效轨迹(太短了)', 'error'); return; }
|
||
if (_rec.mode === 'gesture') { // 回填模式:直接交给调用方,收工
|
||
const cb = _rec.onDone;
|
||
closeRecorder();
|
||
if (cb) cb({ points: points, speed: 1.0 }, src);
|
||
return;
|
||
}
|
||
const dur = points[points.length - 1][2] || 0;
|
||
_recPush({ type: 'gesture',
|
||
label: '录制手势 ' + points.length + '点/' + (dur / 1000).toFixed(1) + 's',
|
||
params: { points: points, speed: 1.0 } });
|
||
}
|
||
|
||
// ---------- 手机端录制(getevent 抓真手指) ----------
|
||
async function recStartPhone() {
|
||
if (!_rec.serial) { showToast('先选设备', 'error'); return; }
|
||
const r = await apiPost('/api/gesture/record/start', { serial: _rec.serial });
|
||
if (!r || !r.ok) { showToast((r && r.error) || '开始失败', 'error'); return; }
|
||
_rec.phone = { token: r.token, t0: Date.now() };
|
||
showToast('开始录制——现在用手指在手机上划', 'success');
|
||
recRenderFoot();
|
||
}
|
||
|
||
async function recStopPhone() {
|
||
if (!_rec.phone) return;
|
||
const token = _rec.phone.token;
|
||
_rec.phone = null;
|
||
const r = await apiPost('/api/gesture/record/stop', { token: token });
|
||
if (!r || !r.ok) { showToast((r && r.error) || '停止失败', 'error'); recRenderFoot(); return; }
|
||
const gs = r.gestures || [];
|
||
if (!gs.length) {
|
||
showToast('没录到手势——请在手机屏幕上用指划一下(不是点)', 'error');
|
||
recRenderFoot(); return;
|
||
}
|
||
if (_rec.mode === 'gesture') { recPushGesture(gs[gs.length - 1], 'phone'); return; }
|
||
gs.forEach(function (g) { recPushGesture(g, 'phone'); });
|
||
showToast('录到 ' + gs.length + ' 个手势', 'success');
|
||
recRenderFoot();
|
||
recRefresh();
|
||
}
|
||
|
||
// ---------- 翻译:点击 → 步骤 ----------
|
||
function _recFindElement(x, y) {
|
||
// 取包含该点的最小元素;优先可点击的(和 /api/screen/tap 的 snap 同一口径)
|
||
let best = null, bestClick = null;
|
||
(_rec.elements || []).forEach(function (el) {
|
||
const m = (el.bounds || '').match(/\[(\d+),(\d+)\]\[(\d+),(\d+)\]/);
|
||
if (!m) return;
|
||
const x1 = +m[1], y1 = +m[2], x2 = +m[3], y2 = +m[4];
|
||
if (!(x1 <= x && x <= x2 && y1 <= y && y <= y2)) return;
|
||
const area = (x2 - x1) * (y2 - y1);
|
||
const cand = { el: el, area: area, cx: Math.round((x1 + x2) / 2), cy: Math.round((y1 + y2) / 2) };
|
||
if (!best || area < best.area) best = cand;
|
||
if (String(el.clickable) === 'true' && (!bestClick || area < bestClick.area)) bestClick = cand;
|
||
});
|
||
return bestClick || best;
|
||
}
|
||
|
||
async function recTap(x, y) {
|
||
if (!_rec || _rec.busy) return;
|
||
_rec.busy = true;
|
||
try {
|
||
const hit = _recFindElement(x, y);
|
||
let step;
|
||
if (hit && hit.el.suggested && hit.el.suggested.value) {
|
||
step = { type: 'click', label: '点击' + _recShort(hit.el),
|
||
params: { selector_type: hit.el.suggested.type || 'xpath',
|
||
selector_value: hit.el.suggested.value, wait_timeout: 2 } };
|
||
} else {
|
||
step = { type: 'click_xy', label: '点击坐标',
|
||
params: { x: Math.round(x / _rec.devW * 100), y: Math.round(y / _rec.devH * 100) } };
|
||
}
|
||
_recPush(step);
|
||
const r = await apiPost('/api/screen/tap', { serial: _rec.serial, x: x, y: y, snap: 1 });
|
||
if (r && !r.ok) showToast((r.error || '点击失败'), 'error');
|
||
await recRefresh();
|
||
} finally { _rec.busy = false; }
|
||
}
|
||
|
||
function _recShort(el) {
|
||
const s = el.text || el.description || el.name || el.resource_id || '';
|
||
return s ? ('「' + String(s).slice(0, 12) + '」') : '';
|
||
}
|
||
|
||
async function recKey(key) {
|
||
if (!_rec || !_rec.serial) { showToast('先选设备', 'error'); return; }
|
||
_recPush({ type: 'key_event', label: '按键 ' + key, params: { key: key } });
|
||
const r = await apiPost('/api/screen/key', { serial: _rec.serial, key: key });
|
||
if (r && !r.ok) showToast((r.error || '按键失败'), 'error');
|
||
await recRefresh();
|
||
}
|
||
|
||
async function recText() {
|
||
if (!_rec || !_rec.serial) { showToast('先选设备', 'error'); return; }
|
||
const inp = document.getElementById('rec-text');
|
||
const t = (inp ? inp.value : '').trim();
|
||
if (!t) { showToast('先填要输入的文字', 'error'); return; }
|
||
_recPush({ type: 'input_text', label: '输入 ' + t.slice(0, 8),
|
||
params: { mode: 'fixed', fixed_text: t, clear_first: false } });
|
||
const r = await apiPost('/api/screen/text', { serial: _rec.serial, text: t });
|
||
if (r && !r.ok) showToast((r.error || '输入失败'), 'error');
|
||
if (inp) inp.value = '';
|
||
await recRefresh();
|
||
}
|
||
|
||
function recAddWait() {
|
||
_recPush({ type: 'wait', label: '等待', params: { min: 1, max: 3 } });
|
||
}
|
||
|
||
// 入列:按需自动补一条「等待」(真实停顿,±15%),并刷新列表
|
||
function _recPush(step) {
|
||
const now = Date.now();
|
||
if (_rec.keepPause && _rec.lastTs && step.type !== 'wait') {
|
||
const gap = (now - _rec.lastTs) / 1000;
|
||
if (gap >= 1.0) {
|
||
_rec.steps.push({ id: 'rec_' + Math.random().toString(36).slice(2, 8),
|
||
type: 'wait', label: '等待 ' + gap.toFixed(1) + 's',
|
||
params: { min: +(gap * 0.85).toFixed(1), max: +(gap * 1.15).toFixed(1) } });
|
||
}
|
||
}
|
||
step.id = 'rec_' + Math.random().toString(36).slice(2, 8);
|
||
_rec.steps.push(step);
|
||
_rec.lastTs = now;
|
||
recRender();
|
||
}
|
||
|
||
function recClear() { _rec.steps = []; _rec.lastTs = 0; recRender(); }
|
||
|
||
function recDel(i) { _rec.steps.splice(i, 1); recRender(); }
|
||
function recMove2(i, d) {
|
||
const j = i + d;
|
||
if (j < 0 || j >= _rec.steps.length) return;
|
||
const t = _rec.steps[i]; _rec.steps[i] = _rec.steps[j]; _rec.steps[j] = t;
|
||
recRender();
|
||
}
|
||
|
||
function recRender() {
|
||
const list = document.getElementById('rec-list');
|
||
if (!list) return;
|
||
const steps = _rec ? _rec.steps : [];
|
||
document.getElementById('rec-count').textContent = steps.length ? ('共 ' + steps.length + ' 步') : '';
|
||
list.innerHTML = steps.map(function (s, i) {
|
||
return '<div class="rec-item"><span class="rec-idx">' + (i + 1) + '</span>' +
|
||
'<span class="rec-label">' + esc(s.label || s.type) + '</span>' +
|
||
'<span class="rec-ops2">' +
|
||
'<button class="btn btn-xs" onclick="recMove2(' + i + ',-1)" title="上移">↑</button>' +
|
||
'<button class="btn btn-xs" onclick="recMove2(' + i + ',1)" title="下移">↓</button>' +
|
||
'<button class="btn btn-xs btn-danger" onclick="recDel(' + i + ')">×</button>' +
|
||
'</span></div>';
|
||
}).join('') || '<div class="text-muted" style="font-size:12px;padding:6px 0">还没有录到步骤——在左边画面上点一下或划一下试试</div>';
|
||
recRenderFoot();
|
||
}
|
||
|
||
function recRenderFoot() {
|
||
const foot = document.getElementById('rec-foot');
|
||
if (!foot) return;
|
||
const steps = _rec.steps;
|
||
if (_rec.mode === 'gesture') {
|
||
// 回填模式:录到就立刻填回那个步骤,不用再点确认
|
||
foot.innerHTML = _rec.phone
|
||
? '<button class="btn btn-danger" onclick="recStopPhone()">■ 停止并填入</button>'
|
||
: '<button class="btn btn-primary" onclick="recStartPhone()">● 手机上录</button>' +
|
||
'<div class="help">或在左边画面上按住鼠标拖一下——录到就立刻填进那一步</div>';
|
||
return;
|
||
}
|
||
const phoneBtn = _rec.phone
|
||
? '<button class="btn btn-danger" onclick="recStopPhone()">■ 停止手机录制</button>'
|
||
: '<button class="btn btn-primary" onclick="recStartPhone()">● 手机上录</button>';
|
||
foot.innerHTML =
|
||
'<div class="rec-row" style="margin-bottom:6px">' + phoneBtn +
|
||
'<span class="help" style="margin:0">' + (_rec.phone
|
||
? '正在录:请用手指在手机屏幕上划,划完点「停止」'
|
||
: '手机上用手指划 = 录真手指轨迹;在左边画面上拖 = 网页录制') + '</span></div>' +
|
||
'<div class="rec-row"><input id="rec-name" class="form-control" placeholder="动作名,如「抖音-进评论」">' +
|
||
'<button class="btn btn-primary" onclick="recSaveAsAction()">存成动作</button></div>' +
|
||
'<div class="help">划出来的每一段都是一条「录制手势」步骤(按原路径与时长回放,不再套滑动逻辑)</div>';
|
||
}
|
||
|
||
async function recSaveAsAction() {
|
||
const el = document.getElementById('rec-name');
|
||
const name = (el ? el.value : '').trim();
|
||
if (!name) { showToast('先给动作起个名', 'error'); return; }
|
||
const steps = _rec.steps.filter(function (s) { return s.type !== 'wait' || true; })
|
||
.map(function (s) { return { type: s.type, label: s.label, params: s.params }; });
|
||
if (!steps.length) { showToast('还没有录到步骤', 'error'); return; }
|
||
const r = await apiPost('/api/custom_actions', { name: name, icon: '⏺', steps: steps });
|
||
if (!r || !r.ok) { showToast((r && r.error) || '保存失败', 'error'); return; }
|
||
showToast('已存成动作「' + name + '」(' + steps.length + ' 步)', 'success');
|
||
if (typeof loadCustomActions === 'function') loadCustomActions();
|
||
closeRecorder();
|
||
}
|