android-midscene-automation 0.1.35 → 0.1.36
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +5 -2
- package/README.md +5 -2
- package/package.json +1 -1
- package/server/appium-recorder/open-gallery.ts +10 -1
- package/server/appium-recorder/repository.ts +1 -0
- package/server/appium-recorder/script-export.ts +1 -1
- package/src/appium-recorder/AppiumPage.vue +26 -4
- package/src/appium-recorder/components/FlowNodeCard.vue +6 -6
- package/src/appium-recorder/components/FlowStepEditor.vue +23 -4
- package/src/appium-recorder/components/MergeBranchesDialog.vue +31 -3
- package/src/appium-recorder/components/NestedConditionBranches.vue +2 -2
- package/src/appium-recorder/flow-copy.ts +2 -0
- package/src/appium-recorder/flow-graph.ts +1 -1
- package/src/appium-recorder/flow-merge.ts +12 -0
- package/src/appium-recorder/flow-unmerge.ts +145 -0
- package/src/appium-recorder/types.ts +12 -0
- package/src/appium-recorder/visual-change.ts +13 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
# 更新记录
|
|
2
|
+
## v0.1.36
|
|
3
|
+
- 新合并支持取消:原合并按钮切换为取消合并,还原分支和被删除的重复节点,保留配置编辑;公共流程新增的普通操作可选择保留左侧、右侧或两侧,结构冲突时阻止还原。本地保存还原记录,导出及节点复制不携带历史记录。
|
|
4
|
+
- 启动相册前先停止当前设备用户下的目标相册进程,再重新打开;保留相册自动适配,不清除照片及应用数据,停止失败时明确报错。
|
|
5
|
+
- 修复修改元素或父级定位后仍使用旧备用定位、画面变化像素容差无法设为 0 的问题。
|
|
6
|
+
|
|
2
7
|
## v0.1.35
|
|
3
|
-
- 修复已有图像判断配置无效时导致整个脚本列表加载失败的问题,读取保留原数据,保存和执行时再校验。
|
|
4
|
-
- 图像判断配置显示模板尺寸,提前提示模板超出检测区域,并支持手动一键扩大框选区域;回放尺寸不匹配时展示具体尺寸,避免仅在运行时得到笼统错误。
|
|
5
8
|
- 新增“图像判断”分支节点,支持本地 OpenCV 模板匹配、图片勾选双模板、黑屏、区域颜色和多帧变化检测;支持组件定位、框选区域、模板采集与上传、连续采样及 true/false 分支,证据不足时明确报告无法判定。HTML 报告展示指标和可放大的检测图片,并沿用敏感运行截图保护。
|
|
6
9
|
- 合流后的公共节点上方新增插入入口,支持新增和粘贴操作,所有汇入分支先执行新操作再进入原公共节点。
|
|
7
10
|
- 修复嵌套判断显式合流到外层公共节点时连接被过滤、公共节点提前排列的问题,支持跨层分支合流。
|
package/README.md
CHANGED
|
@@ -1229,9 +1229,12 @@ Linux: ~/.local/share/android-midscene-automation
|
|
|
1229
1229
|
|
|
1230
1230
|
# 项目更新记录
|
|
1231
1231
|
|
|
1232
|
+
## v0.1.36
|
|
1233
|
+
- 新合并支持取消:原合并按钮切换为取消合并,还原分支和被删除的重复节点,保留配置编辑;公共流程新增的普通操作可选择保留左侧、右侧或两侧,结构冲突时阻止还原。本地保存还原记录,导出及节点复制不携带历史记录。
|
|
1234
|
+
- 启动相册前先停止当前设备用户下的目标相册进程,再重新打开;保留相册自动适配,不清除照片及应用数据,停止失败时明确报错。
|
|
1235
|
+
- 修复修改元素或父级定位后仍使用旧备用定位、画面变化像素容差无法设为 0 的问题。
|
|
1236
|
+
|
|
1232
1237
|
## v0.1.35
|
|
1233
|
-
- 修复已有图像判断配置无效时导致整个脚本列表加载失败的问题,读取保留原数据,保存和执行时再校验。
|
|
1234
|
-
- 图像判断配置显示模板尺寸,提前提示模板超出检测区域,并支持手动一键扩大框选区域;回放尺寸不匹配时展示具体尺寸,避免仅在运行时得到笼统错误。
|
|
1235
1238
|
- 新增“图像判断”分支节点,支持本地 OpenCV 模板匹配、图片勾选双模板、黑屏、区域颜色和多帧变化检测;支持组件定位、框选区域、模板采集与上传、连续采样及 true/false 分支,证据不足时明确报告无法判定。HTML 报告展示指标和可放大的检测图片,并沿用敏感运行截图保护。
|
|
1236
1239
|
- 合流后的公共节点上方新增插入入口,支持新增和粘贴操作,所有汇入分支先执行新操作再进入原公共节点。
|
|
1237
1240
|
- 修复嵌套判断显式合流到外层公共节点时连接被过滤、公共节点提前排列的问题,支持跨层分支合流。
|
package/package.json
CHANGED
|
@@ -43,10 +43,19 @@ export async function openGalleryOnDevice(deviceId: string, signal?: AbortSignal
|
|
|
43
43
|
}
|
|
44
44
|
}
|
|
45
45
|
if (!component) throw new Error('未找到可启动的相册应用,请检查手机是否安装并启用了相册');
|
|
46
|
+
// 仅停止已解析出的目标相册,避免复用后台页面;不清除应用数据。
|
|
47
|
+
const packageName = component.split('/')[0]!;
|
|
48
|
+
try {
|
|
49
|
+
const stopped = await run(['am', 'force-stop', '--user', 'current', packageName]);
|
|
50
|
+
if (/Error:|Exception|Permission Denial/i.test(stopped)) throw new Error(stopped);
|
|
51
|
+
} catch (error) {
|
|
52
|
+
if (signal?.aborted) throw error;
|
|
53
|
+
throw new Error(`停止相册失败(${packageName}):${error instanceof Error ? error.message : String(error)}`);
|
|
54
|
+
}
|
|
46
55
|
const output = await run(['am', 'start', '-W', '--user', 'current', '-a', MAIN, '-n', component]);
|
|
47
56
|
// am start 某些错误仍返回 0,不能只检查进程退出码。
|
|
48
57
|
if (!/^Status:\s*ok\s*$/im.test(output) || /Error:|Exception|Permission Denial/i.test(output)) {
|
|
49
58
|
throw new Error(`启动相册失败:${output || component}`);
|
|
50
59
|
}
|
|
51
|
-
return `已启动相册:${component}
|
|
60
|
+
return `已启动相册:${component}(启动前已停止相册进程)`;
|
|
52
61
|
}
|
|
@@ -13,6 +13,7 @@ import { getPresetVariables, savePresetVariables, deleteScriptVariables } from '
|
|
|
13
13
|
import { validateImageCheck, type ImageCheckConfig } from '../../src/appium-recorder/image-check';
|
|
14
14
|
|
|
15
15
|
export type AppiumRecordedStepRecord = {
|
|
16
|
+
mergeUndo?: import('../../src/appium-recorder/types').BranchMergeUndo;
|
|
16
17
|
id: string;
|
|
17
18
|
type:
|
|
18
19
|
| 'tap'
|
|
@@ -45,7 +45,7 @@ export async function createAppiumScriptExport(
|
|
|
45
45
|
appActivity: script.appActivity,
|
|
46
46
|
deviceId: script.deviceId,
|
|
47
47
|
variables: clearSecrets(script.variables),
|
|
48
|
-
steps: script.steps.map(step => ({ ...step, ...(step.parameters ? { parameters: clearSecrets(step.parameters) } : {}) })),
|
|
48
|
+
steps: script.steps.map(step => ({ ...step, mergeUndo: undefined, ...(step.parameters ? { parameters: clearSecrets(step.parameters) } : {}) })),
|
|
49
49
|
},
|
|
50
50
|
}), null, 2);
|
|
51
51
|
};
|
|
@@ -60,6 +60,7 @@ import {
|
|
|
60
60
|
boundsToVisualRegion,
|
|
61
61
|
createStepId,
|
|
62
62
|
normalizeVisualChangeConfig,
|
|
63
|
+
syncVisualChangeThreshold,
|
|
63
64
|
visualRegionToBounds,
|
|
64
65
|
} from './visual-change';
|
|
65
66
|
import {
|
|
@@ -182,6 +183,7 @@ const currentActivity = shallowRef('');
|
|
|
182
183
|
const replayOutput = shallowRef('');
|
|
183
184
|
const replayOutputDialogVisible = shallowRef(false);
|
|
184
185
|
const linkedScriptDialogVisible = shallowRef(false);
|
|
186
|
+
const linkedScriptEditingId = shallowRef('');
|
|
185
187
|
const linkedScriptTargetId = shallowRef('');
|
|
186
188
|
const linkedParameters = reactive<AppiumRecordedStep>({ id: 'link-parameters', type: 'runScript', label: '', parameters: [], returns: [] });
|
|
187
189
|
const linkedScriptInsertIndex = shallowRef<number | undefined>();
|
|
@@ -1166,6 +1168,7 @@ async function addSwipeStep(index?: number, branchTarget?: BranchTarget) {
|
|
|
1166
1168
|
}
|
|
1167
1169
|
|
|
1168
1170
|
function openLinkedScriptDialog(index?: number, branchTarget?: BranchTarget) {
|
|
1171
|
+
linkedScriptEditingId.value = '';
|
|
1169
1172
|
if (!linkableScripts.value.length) {
|
|
1170
1173
|
ElMessage.warning('暂无可连接的已保存脚本');
|
|
1171
1174
|
return;
|
|
@@ -1215,7 +1218,14 @@ function addLinkedScriptStep() {
|
|
|
1215
1218
|
);
|
|
1216
1219
|
return;
|
|
1217
1220
|
}
|
|
1218
|
-
|
|
1221
|
+
const editingIndex = linkedScriptEditingId.value ? steps.value.findIndex(step => step.id === linkedScriptEditingId.value) : -1;
|
|
1222
|
+
if (linkedScriptEditingId.value && editingIndex < 0) { ElMessage.warning('原节点已删除,请重新打开'); return; }
|
|
1223
|
+
if (editingIndex >= 0) {
|
|
1224
|
+
const original = steps.value[editingIndex]!;
|
|
1225
|
+
updateStep(editingIndex, { ...original, value: script.id,
|
|
1226
|
+
...(original.label.startsWith('连接脚本 ') ? { label: `连接脚本 ${script.name}` } : {}),
|
|
1227
|
+
parameters: JSON.parse(JSON.stringify(linkedParameters.parameters || [])), returns: JSON.parse(JSON.stringify(linkedParameters.returns || [])) });
|
|
1228
|
+
} else insertStep(
|
|
1219
1229
|
{ ...createRunScriptStep(script), parameters: JSON.parse(JSON.stringify(linkedParameters.parameters || [])), returns: JSON.parse(JSON.stringify(linkedParameters.returns || [])) },
|
|
1220
1230
|
linkedScriptInsertIndex.value,
|
|
1221
1231
|
linkedScriptBranchTarget.value || undefined,
|
|
@@ -1224,10 +1234,12 @@ function addLinkedScriptStep() {
|
|
|
1224
1234
|
linkedScriptBranchTarget.value = null;
|
|
1225
1235
|
linkedScriptExpectedActivity.value = '';
|
|
1226
1236
|
linkedScriptDialogVisible.value = false;
|
|
1227
|
-
ElMessage.success('已添加连接脚本步骤');
|
|
1237
|
+
ElMessage.success(editingIndex >= 0 ? '已修改连接脚本' : '已添加连接脚本步骤');
|
|
1238
|
+
linkedScriptEditingId.value = '';
|
|
1228
1239
|
}
|
|
1229
1240
|
|
|
1230
1241
|
function closeLinkedScriptDialog() {
|
|
1242
|
+
linkedScriptEditingId.value = '';
|
|
1231
1243
|
linkedScriptDialogVisible.value = false;
|
|
1232
1244
|
linkedScriptInsertIndex.value = undefined;
|
|
1233
1245
|
linkedScriptBranchTarget.value = null;
|
|
@@ -1791,11 +1803,21 @@ function updateStep(index: number, step: AppiumRecordedStep) {
|
|
|
1791
1803
|
? syncVisualChangePairConfig(candidate, visualChange)
|
|
1792
1804
|
: candidate
|
|
1793
1805
|
));
|
|
1794
|
-
steps.value = nextSteps;
|
|
1806
|
+
steps.value = syncVisualChangeThreshold(nextSteps, step);
|
|
1795
1807
|
}
|
|
1796
1808
|
|
|
1797
1809
|
async function editInputStep(index: number) {
|
|
1798
1810
|
const step = steps.value[index];
|
|
1811
|
+
if (step?.type === 'runScript' && !recordingBusy.value) {
|
|
1812
|
+
const parentId = step.flow?.parentConditionId;
|
|
1813
|
+
openLinkedScriptDialog(index - 1, parentId && step.flow?.parentBranch ? { stepId: parentId, branch: step.flow.parentBranch } : undefined);
|
|
1814
|
+
if (!linkedScriptDialogVisible.value) return;
|
|
1815
|
+
linkedScriptEditingId.value = step.id;
|
|
1816
|
+
linkedScriptTargetId.value = step.value || '';
|
|
1817
|
+
linkedParameters.parameters = JSON.parse(JSON.stringify(step.parameters || []));
|
|
1818
|
+
linkedParameters.returns = JSON.parse(JSON.stringify(step.returns || []));
|
|
1819
|
+
return;
|
|
1820
|
+
}
|
|
1799
1821
|
if (step?.type === 'imageCheck' && !recordingBusy.value) {
|
|
1800
1822
|
imageCheckIndex = index;
|
|
1801
1823
|
imageCheckEditing = true;
|
|
@@ -2491,7 +2513,7 @@ watch(
|
|
|
2491
2513
|
</el-form>
|
|
2492
2514
|
<template #footer>
|
|
2493
2515
|
<el-button @click="closeLinkedScriptDialog">取消</el-button>
|
|
2494
|
-
<el-button type="primary" @click="addLinkedScriptStep"
|
|
2516
|
+
<el-button type="primary" @click="addLinkedScriptStep">{{ linkedScriptEditingId ? '保存' : '添加连接' }}</el-button>
|
|
2495
2517
|
</template>
|
|
2496
2518
|
</el-dialog>
|
|
2497
2519
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import { computed, nextTick, onBeforeUnmount, onMounted, shallowRef, watch } from 'vue';
|
|
3
3
|
import { Handle, Position } from '@vue-flow/core';
|
|
4
|
-
import { CopyDocument, Delete, Edit, Plus, VideoPlay, View, WarningFilled, Connection } from '@element-plus/icons-vue';
|
|
4
|
+
import { CopyDocument, Delete, Edit, Plus, VideoPlay, View, WarningFilled, Connection, RefreshLeft } from '@element-plus/icons-vue';
|
|
5
5
|
import { AI_MODEL_CONFIG_HINT } from '../ai-recognition';
|
|
6
6
|
import type { AppiumRecordedStep } from '../types';
|
|
7
7
|
import {
|
|
@@ -187,9 +187,9 @@ watch(() => props.data, () => {
|
|
|
187
187
|
</span>
|
|
188
188
|
</button>
|
|
189
189
|
<span v-if="!props.readonly && !data.deleteMode" class="appium-flow-step-card__actions nodrag nopan" @click.stop>
|
|
190
|
-
<el-tooltip v-if="data.flowKind === 'condition' && data.step.type !== 'loop' && !data.copyMode" content="合并分支:将两侧后续节点汇入公共流程" placement="top" :show-after="200">
|
|
191
|
-
<span class="appium-node-action-tooltip"><el-button text size="small" :icon="Connection"
|
|
192
|
-
title="合并分支" aria-label="合并分支" :disabled="data.mergeDisabled
|
|
190
|
+
<el-tooltip v-if="data.flowKind === 'condition' && data.step.type !== 'loop' && !data.copyMode" :content="data.step.flow?.successTargetId ? '取消合并:还原分支并保留后续编辑' : '合并分支:将两侧后续节点汇入公共流程'" placement="top" :show-after="200">
|
|
191
|
+
<span class="appium-node-action-tooltip"><el-button text size="small" :icon="data.step.flow?.successTargetId ? RefreshLeft : Connection"
|
|
192
|
+
:title="data.step.flow?.successTargetId ? '取消合并' : '合并分支'" :aria-label="data.step.flow?.successTargetId ? '取消合并' : '合并分支'" :disabled="data.mergeDisabled"
|
|
193
193
|
@click="emit('merge', data.index)" />
|
|
194
194
|
</span></el-tooltip>
|
|
195
195
|
<el-tooltip v-if="!data.copyMode && data.canCopy" content="复制节点:复制后可在插入位置粘贴" placement="top" :show-after="200">
|
|
@@ -215,14 +215,14 @@ watch(() => props.data, () => {
|
|
|
215
215
|
@click="emit('execute', data.index)"
|
|
216
216
|
/>
|
|
217
217
|
</span></el-tooltip>
|
|
218
|
-
<el-tooltip v-if="data.canEditInput" :content="data.step.type === 'imageCheck' ? '修改图像判断配置' : '修改输入内容:编辑此节点要输入的文字'" placement="top" :show-after="200">
|
|
218
|
+
<el-tooltip v-if="data.canEditInput" :content="data.step.type === 'runScript' ? '修改连接脚本' : data.step.type === 'imageCheck' ? '修改图像判断配置' : '修改输入内容:编辑此节点要输入的文字'" placement="top" :show-after="200">
|
|
219
219
|
<span class="appium-node-action-tooltip">
|
|
220
220
|
<el-button
|
|
221
221
|
text
|
|
222
222
|
size="small"
|
|
223
223
|
:icon="Edit"
|
|
224
224
|
:disabled="data.disabled"
|
|
225
|
-
:title="data.step.type === 'imageCheck' ? '修改图像判断配置' : '修改输入内容'"
|
|
225
|
+
:title="data.step.type === 'runScript' ? '修改连接脚本' : data.step.type === 'imageCheck' ? '修改图像判断配置' : '修改输入内容'"
|
|
226
226
|
@click="emit('editInput', data.index)"
|
|
227
227
|
/>
|
|
228
228
|
</span></el-tooltip>
|
|
@@ -5,7 +5,6 @@ import { normalizeVisualChangeConfig } from '../visual-change';
|
|
|
5
5
|
import { longPressMode } from '../long-press';
|
|
6
6
|
import { defaultFlowKind, isBooleanCondition } from '../flow-labels';
|
|
7
7
|
import TextClickSettings from './TextClickSettings.vue';
|
|
8
|
-
import { isNativeStateCondition } from '../native-control-state';
|
|
9
8
|
import LongPressSettings from './LongPressSettings.vue';
|
|
10
9
|
import StageLogSettings from './StageLogSettings.vue';
|
|
11
10
|
import LoopSettings from './LoopSettings.vue';
|
|
@@ -47,7 +46,7 @@ function defaultKind(): FlowKind {
|
|
|
47
46
|
function patchStep(patch: Partial<AppiumRecordedStep>) {
|
|
48
47
|
emit('update', {
|
|
49
48
|
index: props.index,
|
|
50
|
-
step: { ...props.step, ...patch },
|
|
49
|
+
step: { ...props.step, ...patch, ...('contextSelector' in patch ? { selectorChain: undefined } : {}) },
|
|
51
50
|
});
|
|
52
51
|
}
|
|
53
52
|
|
|
@@ -79,6 +78,10 @@ function patchSwipe(patch: Partial<SwipeGesture>) {
|
|
|
79
78
|
});
|
|
80
79
|
}
|
|
81
80
|
|
|
81
|
+
function patchPinch(patch: Partial<NonNullable<AppiumRecordedStep['pinch']>>) {
|
|
82
|
+
patchStep({ pinch: { direction: 'out', centerX: 0, centerY: 0, percent: 0.5, ...props.step.pinch, ...patch } });
|
|
83
|
+
}
|
|
84
|
+
|
|
82
85
|
function patchTapCoordinate(axis: 'centerX' | 'centerY', value: number | undefined) {
|
|
83
86
|
if (value === undefined || !Number.isFinite(value)) return;
|
|
84
87
|
const fallback = {
|
|
@@ -125,7 +128,7 @@ function patchSelector(patch: Partial<AppiumSelector>) {
|
|
|
125
128
|
patchStep({
|
|
126
129
|
selector: { ...props.step.selector!, ...patch },
|
|
127
130
|
// 修改元素目标后,不能继续优先使用录制时的备用 XPath。
|
|
128
|
-
|
|
131
|
+
selectorChain: undefined,
|
|
129
132
|
});
|
|
130
133
|
}
|
|
131
134
|
</script>
|
|
@@ -173,6 +176,22 @@ function patchSelector(patch: Partial<AppiumSelector>) {
|
|
|
173
176
|
</el-form-item>
|
|
174
177
|
</div>
|
|
175
178
|
<LongPressSettings v-if="step.type === 'longPress'" :step="step" :disabled="disabled" @update="patchStep" />
|
|
179
|
+
<el-form-item v-if="['waitActivity', 'launchApp', 'clearAppData'].includes(step.type)" :label="step.type === 'waitActivity' ? '目标 Activity' : '目标 APP 包名'">
|
|
180
|
+
<el-input :model-value="step.value || ''" :disabled="disabled" @update:model-value="patchStep({ value: String($event) })" />
|
|
181
|
+
</el-form-item>
|
|
182
|
+
<template v-if="step.type === 'pinch'">
|
|
183
|
+
<el-form-item label="缩放方向">
|
|
184
|
+
<el-select :model-value="step.pinch?.direction || 'out'" :disabled="disabled" @update:model-value="patchPinch({ direction: $event })">
|
|
185
|
+
<el-option label="放大" value="out" /><el-option label="缩小" value="in" />
|
|
186
|
+
</el-select>
|
|
187
|
+
</el-form-item>
|
|
188
|
+
<el-form-item v-for="axis in (['centerX', 'centerY'] as const)" :key="axis" :label="axis === 'centerX' ? '中心 X' : '中心 Y'">
|
|
189
|
+
<el-input-number :model-value="step.pinch?.[axis] ?? 0" :disabled="disabled" :min="0" :max="99999" :precision="0" controls-position="right" @update:model-value="patchPinch({ [axis]: $event ?? 0 })" />
|
|
190
|
+
</el-form-item>
|
|
191
|
+
<el-form-item label="缩放比例">
|
|
192
|
+
<el-input-number :model-value="step.pinch?.percent ?? 0.5" :disabled="disabled" :min="0.01" :max="1" :step="0.05" controls-position="right" @update:model-value="patchPinch({ percent: $event ?? 0.5 })" />
|
|
193
|
+
</el-form-item>
|
|
194
|
+
</template>
|
|
176
195
|
<div v-if="step.type === 'coordinateTap'" class="appium-flow-editor__grid">
|
|
177
196
|
<el-form-item label="坐标 X">
|
|
178
197
|
<el-input-number :model-value="step.fallback?.centerX ?? 0" :disabled="disabled" :min="0" :max="99999" :precision="0" controls-position="right" aria-label="坐标 X" @update:model-value="patchTapCoordinate('centerX', $event)" />
|
|
@@ -221,7 +240,7 @@ function patchSelector(patch: Partial<AppiumSelector>) {
|
|
|
221
240
|
<el-form-item label="变化阈值 %">
|
|
222
241
|
<el-input-number
|
|
223
242
|
:model-value="visualChangeConfig.changeRatioThreshold"
|
|
224
|
-
:disabled="
|
|
243
|
+
:disabled="disabled"
|
|
225
244
|
:min="0.01"
|
|
226
245
|
:max="100"
|
|
227
246
|
:step="0.1"
|
|
@@ -4,6 +4,7 @@ import { ElMessage } from 'element-plus';
|
|
|
4
4
|
import type { AppiumRecordedStep } from '../types';
|
|
5
5
|
import { branchSteps, mergeBranches } from '../flow-merge';
|
|
6
6
|
import { flowBranchLabel } from '../flow-labels';
|
|
7
|
+
import { unmergeBranches, type AddedNodeSide } from '../flow-unmerge';
|
|
7
8
|
|
|
8
9
|
const props = defineProps<{ steps: AppiumRecordedStep[]; conditionId: string; disabled?: boolean }>();
|
|
9
10
|
const emit = defineEmits<{ close: []; confirm: [steps: AppiumRecordedStep[]] }>();
|
|
@@ -12,6 +13,13 @@ const duplicateId = ref('');
|
|
|
12
13
|
const confirmed = ref(false);
|
|
13
14
|
const retainedSide = ref<'yes' | 'no'>('yes');
|
|
14
15
|
const condition = computed(() => props.steps.find((step) => step.id === props.conditionId));
|
|
16
|
+
const unmerging = ref(false);
|
|
17
|
+
const addedSide = ref<AddedNodeSide>('both');
|
|
18
|
+
const undoPreview = computed(() => {
|
|
19
|
+
if (!unmerging.value) return undefined;
|
|
20
|
+
try { return unmergeBranches(props.steps, props.conditionId, addedSide.value); }
|
|
21
|
+
catch (error) { return error instanceof Error ? error.message : String(error); }
|
|
22
|
+
});
|
|
15
23
|
const candidates = computed(() => branchSteps(props.steps, props.conditionId, retainedSide.value));
|
|
16
24
|
const duplicates = computed(() => branchSteps(props.steps, props.conditionId, retainedSide.value === 'yes' ? 'no' : 'yes'));
|
|
17
25
|
const preview = computed(() => {
|
|
@@ -20,6 +28,7 @@ const preview = computed(() => {
|
|
|
20
28
|
catch (error) { return error instanceof Error ? error.message : String(error); }
|
|
21
29
|
});
|
|
22
30
|
watch(() => props.conditionId, () => {
|
|
31
|
+
if (props.conditionId) unmerging.value = Boolean(condition.value?.flow?.successTargetId);
|
|
23
32
|
retainedSide.value = branchSteps(props.steps, props.conditionId, 'yes').length ? 'yes' : 'no';
|
|
24
33
|
commonId.value = ''; duplicateId.value = ''; confirmed.value = false;
|
|
25
34
|
});
|
|
@@ -28,6 +37,12 @@ watch(retainedSide, () => { commonId.value = ''; duplicateId.value = ''; confirm
|
|
|
28
37
|
watch(commonId, () => { duplicateId.value = ''; confirmed.value = false; });
|
|
29
38
|
watch(duplicateId, () => { confirmed.value = false; });
|
|
30
39
|
function submit() {
|
|
40
|
+
if (unmerging.value) {
|
|
41
|
+
if (props.disabled || !undoPreview.value || typeof undoPreview.value === 'string') return;
|
|
42
|
+
try { emit('confirm', unmergeBranches(props.steps, props.conditionId, addedSide.value).steps); emit('close'); }
|
|
43
|
+
catch (error) { ElMessage.error(String(error)); }
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
31
46
|
if (props.disabled || !preview.value || typeof preview.value === 'string') return;
|
|
32
47
|
if (preview.value.removedCount && !confirmed.value) return;
|
|
33
48
|
try {
|
|
@@ -38,8 +53,20 @@ function submit() {
|
|
|
38
53
|
</script>
|
|
39
54
|
|
|
40
55
|
<template>
|
|
41
|
-
<el-dialog :model-value="Boolean(conditionId)" title="合并分支" width="min(520px, 94vw)" append-to-body :close-on-click-modal="false" @close="emit('close')">
|
|
42
|
-
<el-form label-position="top">
|
|
56
|
+
<el-dialog :model-value="Boolean(conditionId)" :title="unmerging ? '取消合并' : '合并分支'" width="min(520px, 94vw)" append-to-body :close-on-click-modal="false" @close="emit('close')">
|
|
57
|
+
<el-form v-if="unmerging" label-position="top">
|
|
58
|
+
<el-alert v-if="typeof undoPreview === 'string'" :title="undoPreview" type="warning" :closable="false" />
|
|
59
|
+
<el-alert v-else title="还原合并前的分支及被删除的重复节点,保留现有节点配置修改。" type="info" :closable="false" />
|
|
60
|
+
<el-form-item v-if="condition?.mergeUndo && (typeof undoPreview === 'string' || undoPreview?.addedCount)" label="公共流程新增操作保留位置">
|
|
61
|
+
<el-radio-group v-model="addedSide" aria-label="新增操作保留位置">
|
|
62
|
+
<el-radio-button value="both">两侧都保留</el-radio-button>
|
|
63
|
+
<el-radio-button value="yes">仅左侧</el-radio-button>
|
|
64
|
+
<el-radio-button value="no">仅右侧</el-radio-button>
|
|
65
|
+
</el-radio-group>
|
|
66
|
+
<p>原来源分支保留新增操作的相对位置;分配到另一侧的操作放在该分支末尾。</p>
|
|
67
|
+
</el-form-item>
|
|
68
|
+
</el-form>
|
|
69
|
+
<el-form v-else label-position="top">
|
|
43
70
|
<el-form-item label="合并方向">
|
|
44
71
|
<el-radio-group v-model="retainedSide" aria-label="合并方向">
|
|
45
72
|
<el-radio-button value="yes">保留左侧流程</el-radio-button>
|
|
@@ -63,7 +90,8 @@ function submit() {
|
|
|
63
90
|
</el-form>
|
|
64
91
|
<template #footer>
|
|
65
92
|
<el-button @click="emit('close')">取消</el-button>
|
|
66
|
-
<el-button type="primary" :disabled="disabled || !
|
|
93
|
+
<el-button v-if="unmerging" type="primary" :disabled="disabled || !undoPreview || typeof undoPreview === 'string'" @click="submit">取消合并</el-button>
|
|
94
|
+
<el-button v-else type="primary" :disabled="disabled || !preview || typeof preview === 'string' || Boolean(preview.removedCount && !confirmed)" @click="submit">合并分支</el-button>
|
|
67
95
|
</template>
|
|
68
96
|
</el-dialog>
|
|
69
97
|
</template>
|
|
@@ -340,14 +340,14 @@ function branchSplitPath() {
|
|
|
340
340
|
@click="$emit('copy', [item.index])"
|
|
341
341
|
/>
|
|
342
342
|
</span></el-tooltip>
|
|
343
|
-
<el-tooltip v-if="
|
|
343
|
+
<el-tooltip v-if="['input', 'inputIfExists', 'imageCheck', 'runScript'].includes(item.step.type)" :content="item.step.type === 'runScript' ? '修改连接脚本' : item.step.type === 'imageCheck' ? '修改图像判断配置' : '修改输入内容:编辑此节点要输入的文字'" placement="top" :show-after="200">
|
|
344
344
|
<span style="display: inline-flex">
|
|
345
345
|
<el-button
|
|
346
346
|
text
|
|
347
347
|
size="small"
|
|
348
348
|
:icon="Edit"
|
|
349
349
|
:disabled="disabled"
|
|
350
|
-
:title="item.step.type === 'imageCheck' ? '修改图像判断配置' : '修改输入内容'"
|
|
350
|
+
:title="item.step.type === 'runScript' ? '修改连接脚本' : item.step.type === 'imageCheck' ? '修改图像判断配置' : '修改输入内容'"
|
|
351
351
|
@click="$emit('editInput', item.index)"
|
|
352
352
|
/>
|
|
353
353
|
</span></el-tooltip>
|
|
@@ -180,6 +180,8 @@ export function pasteFlowClipboard(
|
|
|
180
180
|
});
|
|
181
181
|
const copies = clipboard.steps.map((source) => {
|
|
182
182
|
const step = clone(source);
|
|
183
|
+
// 合并记录引用原流程 ID,不能随节点复制到另一条流程。
|
|
184
|
+
delete step.mergeUndo;
|
|
183
185
|
step.id = idMap.get(source.id) || createId();
|
|
184
186
|
if (step.breakLoopTargetId) step.breakLoopTargetId = idMap.get(step.breakLoopTargetId) || step.breakLoopTargetId;
|
|
185
187
|
const flow = { ...(step.flow || {}) };
|
|
@@ -458,7 +458,7 @@ export function buildFlowGraph(
|
|
|
458
458
|
canCopy: item.step.type !== 'launchApp'
|
|
459
459
|
&& item.step.type !== 'clearAppData'
|
|
460
460
|
&& item.step.visualChange?.role !== 'end',
|
|
461
|
-
canEditInput:
|
|
461
|
+
canEditInput: ['input', 'inputIfExists', 'imageCheck', 'runScript'].includes(item.step.type),
|
|
462
462
|
canExecute: item.step.type === 'launchApp' || item.step.type === 'clearAppData' || item.step.type === 'aiRecognition',
|
|
463
463
|
missingAiModel: item.step.type === 'aiRecognition' && !options.aiRecognitionModelConfigured,
|
|
464
464
|
},
|
|
@@ -107,5 +107,17 @@ export function mergeBranches(steps: AppiumRecordedStep[], conditionId: string,
|
|
|
107
107
|
visited.add(step.id);
|
|
108
108
|
}
|
|
109
109
|
rest.forEach(visit);
|
|
110
|
+
// 仅记录结构差异和实际被删除的节点,配置编辑不会被取消合并覆盖。
|
|
111
|
+
const lastCommon = Math.max(...commonRoots.map(step => steps.indexOf(step)));
|
|
112
|
+
mergedCondition.mergeUndo = JSON.parse(JSON.stringify({
|
|
113
|
+
version: 1, conditionId, source: side, commonIds: [...commonIds], originalIds: steps.map(step => step.id),
|
|
114
|
+
boundaryId: steps.slice(lastCommon + 1).find(step => !ownedIds.has(step.id)
|
|
115
|
+
&& step.flow?.parentConditionId === condition.flow?.parentConditionId
|
|
116
|
+
&& step.flow?.parentBranch === condition.flow?.parentBranch)?.id,
|
|
117
|
+
flows: steps.filter(step => !removedIds.has(step.id) && (ownedIds.has(step.id)
|
|
118
|
+
|| keys.some(key => step.flow?.[key] !== byId.get(step.id)?.flow[key])))
|
|
119
|
+
.map(step => ({ id: step.id, before: step.flow, after: byId.get(step.id)?.flow })),
|
|
120
|
+
removed: steps.filter(step => removedIds.has(step.id)),
|
|
121
|
+
}));
|
|
110
122
|
return { steps: rest, removedCount: removedIds.size };
|
|
111
123
|
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import type { AppiumRecordedStep } from './types';
|
|
2
|
+
import { branchSteps } from './flow-merge';
|
|
3
|
+
import { defaultFlowKind } from './flow-labels';
|
|
4
|
+
import { createStepId } from './visual-change';
|
|
5
|
+
|
|
6
|
+
const edges = ['yesTargetId', 'noTargetId', 'successTargetId', 'failureTargetId'] as const;
|
|
7
|
+
const structure = [...edges, 'parentConditionId', 'parentBranch'] as const;
|
|
8
|
+
export type AddedNodeSide = 'yes' | 'no' | 'both';
|
|
9
|
+
|
|
10
|
+
export function unmergeBranches(steps: AppiumRecordedStep[], conditionId: string, addedSide?: AddedNodeSide) {
|
|
11
|
+
const condition = steps.find(step => step.id === conditionId);
|
|
12
|
+
const record = condition?.mergeUndo;
|
|
13
|
+
if (!record || record.version !== 1 || record.conditionId !== conditionId) {
|
|
14
|
+
throw new Error('该合并没有可用的还原记录,无法安全取消;仅新执行的合并支持还原');
|
|
15
|
+
}
|
|
16
|
+
const original = new Set(record.originalIds);
|
|
17
|
+
const common = new Set(record.commonIds);
|
|
18
|
+
const affected = new Set(record.flows.map(item => item.id));
|
|
19
|
+
const byId = new Map(steps.map(step => [step.id, step]));
|
|
20
|
+
if (record.flows.some(item => !byId.has(item.id)) || record.removed.some(step => byId.has(step.id))) {
|
|
21
|
+
throw new Error('合并涉及的节点已删除或 ID 冲突,无法安全取消');
|
|
22
|
+
}
|
|
23
|
+
const boundary = record.boundaryId ? steps.findIndex(step => step.id === record.boundaryId) : steps.length;
|
|
24
|
+
if (boundary < 0) throw new Error('公共流程边界已删除,无法安全取消');
|
|
25
|
+
const added = steps.slice(steps.indexOf(condition!) + 1, boundary).filter(step => !original.has(step.id)
|
|
26
|
+
&& step.flow?.parentConditionId === condition!.flow?.parentConditionId
|
|
27
|
+
&& step.flow?.parentBranch === condition!.flow?.parentBranch);
|
|
28
|
+
const shared = new Set([...common, ...added.map(step => step.id)]);
|
|
29
|
+
// 普通新增操作可分配到任一侧;控制节点和跨节点配对不做猜测性复制。
|
|
30
|
+
if (added.some(step => defaultFlowKind(step) === 'condition'
|
|
31
|
+
|| ['visualChange', 'breakLoop', 'endFlow'].includes(step.type) || step.mergeUndo)) {
|
|
32
|
+
throw new Error('公共流程新增了判断、循环、终止或配对节点,请先移出这些节点再取消合并');
|
|
33
|
+
}
|
|
34
|
+
if (added.some(step => edges.some(key => step.flow?.[key]
|
|
35
|
+
&& !shared.has(step.flow[key]!) && step.flow[key] !== record.boundaryId))) {
|
|
36
|
+
throw new Error('公共流程新增操作存在外部连接,请先处理该连接再取消合并');
|
|
37
|
+
}
|
|
38
|
+
for (const item of record.flows) {
|
|
39
|
+
const current = byId.get(item.id)!;
|
|
40
|
+
for (const key of structure) {
|
|
41
|
+
if (current.flow?.[key] === item.after?.[key]) continue;
|
|
42
|
+
const target = edges.includes(key as typeof edges[number]) ? byId.get(current.flow?.[key] || '') : undefined;
|
|
43
|
+
if (!target || original.has(target.id) || defaultFlowKind(target) === 'condition') {
|
|
44
|
+
throw new Error('合并后的连接或分支归属已改变,请先处理结构冲突再取消合并');
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
for (const step of steps) {
|
|
49
|
+
if (!affected.has(step.id) && original.has(step.id) && edges.some(key => shared.has(step.flow?.[key] || ''))) {
|
|
50
|
+
throw new Error('其他流程引用了公共节点,无法安全取消合并');
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
const side = addedSide || record.source;
|
|
54
|
+
const next: AppiumRecordedStep[] = JSON.parse(JSON.stringify(steps));
|
|
55
|
+
const nextById = new Map(next.map(step => [step.id, step]));
|
|
56
|
+
// 只撤回合并写入的结构字段,保留用户修改的名称、参数、备注等。
|
|
57
|
+
for (const item of record.flows) {
|
|
58
|
+
const step = nextById.get(item.id)!;
|
|
59
|
+
const flow = step.flow ||= {};
|
|
60
|
+
for (const key of structure) {
|
|
61
|
+
if (item.before?.[key] === item.after?.[key]) continue;
|
|
62
|
+
const current = flow[key];
|
|
63
|
+
if (current !== item.after?.[key] && !shared.has(current || '')) continue;
|
|
64
|
+
if (item.before?.[key] === undefined) delete flow[key];
|
|
65
|
+
else Object.assign(flow, { [key]: item.before[key] });
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
// 恢复被合并删除的另一侧节点,按原始邻接顺序插回,而非覆盖现有节点。
|
|
69
|
+
for (const backup of record.removed) {
|
|
70
|
+
const restored: AppiumRecordedStep = JSON.parse(JSON.stringify(backup));
|
|
71
|
+
const later = record.originalIds.slice(record.originalIds.indexOf(backup.id) + 1);
|
|
72
|
+
const anchor = later.map(id => next.findIndex(step => step.id === id)).find(index => index >= 0);
|
|
73
|
+
next.splice(anchor ?? next.length, 0, restored);
|
|
74
|
+
nextById.set(restored.id, restored);
|
|
75
|
+
}
|
|
76
|
+
const assigned = side === 'both' ? record.source : side;
|
|
77
|
+
for (const item of added) {
|
|
78
|
+
const step = nextById.get(item.id)!;
|
|
79
|
+
step.flow = { ...step.flow, parentConditionId: conditionId, parentBranch: assigned };
|
|
80
|
+
for (const key of edges) delete step.flow[key];
|
|
81
|
+
}
|
|
82
|
+
if (assigned !== record.source) {
|
|
83
|
+
for (const item of added) {
|
|
84
|
+
const index = next.findIndex(step => step.id === item.id);
|
|
85
|
+
next.push(...next.splice(index, 1));
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
if (side === 'both') {
|
|
89
|
+
for (const item of added) {
|
|
90
|
+
const copy: AppiumRecordedStep = JSON.parse(JSON.stringify(nextById.get(item.id)));
|
|
91
|
+
copy.id = createStepId();
|
|
92
|
+
copy.flow!.parentBranch = record.source === 'yes' ? 'no' : 'yes';
|
|
93
|
+
next.push(copy);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
const other = record.source === 'yes' ? 'no' : 'yes';
|
|
97
|
+
if (added.length && (side === 'both' || side === other)) {
|
|
98
|
+
const siblings = branchSteps(next, conditionId, other);
|
|
99
|
+
const appended = siblings.slice(-added.length);
|
|
100
|
+
const previous = siblings[siblings.length - appended.length - 1];
|
|
101
|
+
if (previous && (defaultFlowKind(previous) === 'condition' || ['endFlow', 'breakLoop'].includes(previous.type))) {
|
|
102
|
+
throw new Error('另一侧末尾是判断、循环或终止操作,新增节点不能安全追加,请仅保留在原来源分支');
|
|
103
|
+
}
|
|
104
|
+
if (previous?.flow?.successTargetId) {
|
|
105
|
+
appended[appended.length - 1]!.flow!.successTargetId = previous.flow.successTargetId;
|
|
106
|
+
previous.flow.successTargetId = appended[0]!.id;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
const restoredCondition = nextById.get(conditionId)!;
|
|
110
|
+
delete restoredCondition.mergeUndo;
|
|
111
|
+
const ownerBranch = (step: AppiumRecordedStep): 'yes' | 'no' | undefined => {
|
|
112
|
+
const visited = new Set<string>();
|
|
113
|
+
let current: AppiumRecordedStep | undefined = step;
|
|
114
|
+
while (current?.flow?.parentConditionId && !visited.has(current.id)) {
|
|
115
|
+
visited.add(current.id);
|
|
116
|
+
if (current.flow.parentConditionId === conditionId) return current.flow.parentBranch;
|
|
117
|
+
current = nextById.get(current.flow.parentConditionId);
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
// 新增的分支尾节点可能仍指向公共入口,改接自己一侧的后续节点。
|
|
121
|
+
for (const step of next) {
|
|
122
|
+
const branch = ownerBranch(step);
|
|
123
|
+
if (!branch) continue;
|
|
124
|
+
const siblings = next.filter(item => item.flow?.parentConditionId === step.flow?.parentConditionId && item.flow?.parentBranch === step.flow?.parentBranch);
|
|
125
|
+
const sibling = siblings[siblings.indexOf(step) + 1];
|
|
126
|
+
for (const key of edges) {
|
|
127
|
+
const target = nextById.get(step.flow?.[key] || '');
|
|
128
|
+
if (!target || !shared.has(target.id)) continue;
|
|
129
|
+
if (ownerBranch(target) === branch && !added.some(item => item.id === target.id)) continue;
|
|
130
|
+
if (sibling) step.flow![key] = sibling.id;
|
|
131
|
+
else delete step.flow![key];
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
for (const branch of ['yes', 'no'] as const) {
|
|
135
|
+
const first = branchSteps(next, conditionId, branch)[0];
|
|
136
|
+
const key = branch === 'yes' ? 'yesTargetId' : 'noTargetId';
|
|
137
|
+
if (first) restoredCondition.flow![key] = first.id;
|
|
138
|
+
else delete restoredCondition.flow![key];
|
|
139
|
+
}
|
|
140
|
+
// 将两个分支的完整子树重新放到判断之后,公共后继保持原位。
|
|
141
|
+
const owned = next.filter(step => ownerBranch(step));
|
|
142
|
+
const result = next.filter(step => !owned.includes(step));
|
|
143
|
+
result.splice(result.indexOf(restoredCondition) + 1, 0, ...owned);
|
|
144
|
+
return { steps: result, addedCount: added.length };
|
|
145
|
+
}
|
|
@@ -68,6 +68,7 @@ export type AppiumNode = {
|
|
|
68
68
|
};
|
|
69
69
|
|
|
70
70
|
export type AppiumRecordedStep = {
|
|
71
|
+
mergeUndo?: BranchMergeUndo;
|
|
71
72
|
id: string;
|
|
72
73
|
type:
|
|
73
74
|
| 'tap'
|
|
@@ -161,6 +162,17 @@ export type AppiumRecordedStep = {
|
|
|
161
162
|
};
|
|
162
163
|
};
|
|
163
164
|
|
|
165
|
+
export type BranchMergeUndo = {
|
|
166
|
+
version: 1;
|
|
167
|
+
conditionId: string;
|
|
168
|
+
source: 'yes' | 'no';
|
|
169
|
+
commonIds: string[];
|
|
170
|
+
originalIds: string[];
|
|
171
|
+
boundaryId?: string;
|
|
172
|
+
flows: { id: string; before: AppiumRecordedStep['flow']; after: AppiumRecordedStep['flow'] }[];
|
|
173
|
+
removed: AppiumRecordedStep[];
|
|
174
|
+
};
|
|
175
|
+
|
|
164
176
|
export type AppiumRecordedScript = {
|
|
165
177
|
id: string;
|
|
166
178
|
name: string;
|
|
@@ -11,6 +11,18 @@ export function createStepId() {
|
|
|
11
11
|
return `step_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
+
export function syncVisualChangeThreshold(steps: AppiumRecordedStep[], source: AppiumRecordedStep) {
|
|
15
|
+
const config = source.visualChange;
|
|
16
|
+
if (source.type !== 'visualChange' || config?.role !== 'end' || !config.pairId) return steps;
|
|
17
|
+
const { changeRatioThreshold } = normalizeVisualChangeConfig(config);
|
|
18
|
+
// 回放以开始节点配置为准,结束节点修改阈值时同步整组;不同配对互不影响。
|
|
19
|
+
return steps.map(step => (
|
|
20
|
+
step.type === 'visualChange' && step.visualChange && step.visualChange.pairId === config.pairId
|
|
21
|
+
? { ...step, visualChange: { ...step.visualChange, changeRatioThreshold } }
|
|
22
|
+
: step
|
|
23
|
+
));
|
|
24
|
+
}
|
|
25
|
+
|
|
14
26
|
export function boundsToVisualRegion(bounds: AppiumBounds) {
|
|
15
27
|
return {
|
|
16
28
|
x: bounds.left,
|
|
@@ -47,7 +59,7 @@ export function normalizeVisualChangeConfig(
|
|
|
47
59
|
changeRatioThreshold: Math.max(0.01, Number(config?.changeRatioThreshold) || VISUAL_CHANGE_DEFAULTS.changeRatioThreshold),
|
|
48
60
|
pixelmatchThreshold: Math.min(
|
|
49
61
|
1,
|
|
50
|
-
Math.max(0, Number(config?.pixelmatchThreshold)
|
|
62
|
+
Math.max(0, Number.isFinite(config?.pixelmatchThreshold) ? Number(config?.pixelmatchThreshold) : VISUAL_CHANGE_DEFAULTS.pixelmatchThreshold),
|
|
51
63
|
),
|
|
52
64
|
};
|
|
53
65
|
}
|