android-midscene-automation 0.1.34 → 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.
Files changed (32) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/README.md +10 -0
  3. package/package.json +2 -1
  4. package/server/appium-recorder/appium-runner.ts +39 -1
  5. package/server/appium-recorder/image-check.ts +160 -0
  6. package/server/appium-recorder/open-gallery.ts +10 -1
  7. package/server/appium-recorder/report.ts +41 -0
  8. package/server/appium-recorder/repository.ts +6 -0
  9. package/server/appium-recorder/routes.ts +19 -0
  10. package/server/appium-recorder/script-export.ts +1 -1
  11. package/src/appium-recorder/AppiumPage.vue +112 -8
  12. package/src/appium-recorder/action-descriptions.ts +46 -0
  13. package/src/appium-recorder/api.ts +5 -0
  14. package/src/appium-recorder/components/ComponentInspector.vue +9 -2
  15. package/src/appium-recorder/components/FlowActionMenu.vue +26 -5
  16. package/src/appium-recorder/components/FlowCanvas.vue +5 -4
  17. package/src/appium-recorder/components/FlowNodeCard.vue +9 -7
  18. package/src/appium-recorder/components/FlowStepEditor.vue +23 -4
  19. package/src/appium-recorder/components/ImageCheckDialog.vue +143 -0
  20. package/src/appium-recorder/components/MergeBranchesDialog.vue +31 -3
  21. package/src/appium-recorder/components/NestedConditionBranches.vue +6 -2
  22. package/src/appium-recorder/components/RecordedSteps.vue +7 -6
  23. package/src/appium-recorder/flow-copy.ts +6 -0
  24. package/src/appium-recorder/flow-graph.ts +22 -10
  25. package/src/appium-recorder/flow-insert.ts +27 -0
  26. package/src/appium-recorder/flow-labels.ts +4 -1
  27. package/src/appium-recorder/flow-merge.ts +12 -0
  28. package/src/appium-recorder/flow-unmerge.ts +145 -0
  29. package/src/appium-recorder/image-check.ts +117 -0
  30. package/src/appium-recorder/types.ts +15 -0
  31. package/src/appium-recorder/visual-change-pairs.ts +2 -0
  32. package/src/appium-recorder/visual-change.ts +13 -1
@@ -0,0 +1,143 @@
1
+ <script setup lang="ts">
2
+ import { computed, ref } from 'vue';
3
+ import { ElMessage } from 'element-plus';
4
+ import { Aim, Camera, Upload, QuestionFilled } from '@element-plus/icons-vue';
5
+ import { IMAGE_CHECK_MODES, validateImageCheck, imageTemplateSize, imageTemplateRegionIssue, expandedImageTemplateRegion, type ImageCheckConfig } from '../image-check';
6
+ import { captureImageCheckRegion } from '../api';
7
+
8
+ const props = defineProps<{ config: ImageCheckConfig; deviceId: string; hasElement: boolean; editing: boolean; picking: boolean }>();
9
+ const emit = defineEmits<{ update: [config: ImageCheckConfig]; close: []; confirm: []; pick: []; useElement: [] }>();
10
+ const busy = ref(false);
11
+ const regionIssue = computed(() => imageTemplateRegionIssue(props.config));
12
+ const expandedRegion = computed(() => expandedImageTemplateRegion(props.config));
13
+ const fileInput = ref<HTMLInputElement>();
14
+ const uploadSlot = ref<'template' | 'negativeTemplate'>('template');
15
+ const slots = computed(() => props.config.mode === 'state'
16
+ ? [{ key: 'template' as const, label: '选中模板' }, { key: 'negativeTemplate' as const, label: '未选中模板' }]
17
+ : props.config.mode === 'template' ? [{ key: 'template' as const, label: '模板' }] : []);
18
+ const numericFields = computed(() => [
19
+ ...(['template', 'state'].includes(props.config.mode) ? [{ key: 'threshold' as const, label: '匹配得分阈值', min: 0.01, max: 1, step: 0.01 }] : []),
20
+ ...(props.config.mode === 'state' ? [{ key: 'minScoreGap' as const, label: '两种状态最小得分差', min: 0.001, max: 1, step: 0.01 }] : []),
21
+ ...(['black', 'color', 'change'].includes(props.config.mode) ? [
22
+ { key: 'tolerance' as const, label: props.config.mode === 'black' ? '暗色亮度阈值 (0–255)' : 'RGB 通道容差 (0–255)', min: 0, max: 255, step: 1 },
23
+ { key: 'ratio' as const, label: props.config.mode === 'change' ? '变化像素比例 %' : '目标像素占比 %', min: 0.01, max: 100, step: 1 },
24
+ ] : []),
25
+ ...(props.config.mode !== 'state' ? [
26
+ { key: 'durationMs' as const, label: '最长观察时长 ms', min: 0, max: 60000, step: 1000 },
27
+ { key: 'intervalMs' as const, label: '采样间隔 ms', min: 200, max: 10000, step: 200 },
28
+ ...(props.config.mode !== 'change' ? [{ key: 'consecutive' as const, label: '连续满足帧数', min: 1, max: 100, step: 1 }] : []),
29
+ ] : []),
30
+ ]);
31
+ function patch(value: Partial<ImageCheckConfig>) { emit('update', { ...props.config, ...value }); }
32
+ function confirm() {
33
+ try { validateImageCheck(props.config); emit('confirm'); }
34
+ catch (error) { ElMessage.warning((error as Error).message); }
35
+ }
36
+ async function capture(key: 'template' | 'negativeTemplate') {
37
+ busy.value = true;
38
+ const regionKey = JSON.stringify(props.config.region);
39
+ const deviceId = props.deviceId;
40
+ try {
41
+ const result = await captureImageCheckRegion(props.deviceId, props.config.region, props.config.screenWidth, props.config.screenHeight);
42
+ if (deviceId !== props.deviceId || regionKey !== JSON.stringify(props.config.region)) throw new Error('采集期间目标区域已变化,请重新采集');
43
+ patch({ [key]: result.base64, screenWidth: result.screenWidth, screenHeight: result.screenHeight });
44
+ } catch (error) { ElMessage.error((error as Error).message); }
45
+ finally { busy.value = false; }
46
+ }
47
+ function chooseFile(key: 'template' | 'negativeTemplate') { uploadSlot.value = key; fileInput.value?.click(); }
48
+ async function upload(event: Event) {
49
+ const input = event.target as HTMLInputElement;
50
+ const file = input.files?.[0];
51
+ input.value = '';
52
+ if (!file) return;
53
+ try {
54
+ if (file.size > 3 * 1024 * 1024 || file.type !== 'image/png') throw new Error('请选择小于 3MB 的 PNG 模板');
55
+ const bytes = new Uint8Array(await file.arrayBuffer());
56
+ if (Array.from(bytes.slice(0, 8)).join(',') !== '137,80,78,71,13,10,26,10') throw new Error('模板不是有效 PNG');
57
+ const reader = new FileReader();
58
+ reader.onload = () => patch({ [uploadSlot.value]: String(reader.result).split(',')[1] });
59
+ reader.readAsDataURL(file);
60
+ } catch (error) { ElMessage.error((error as Error).message); }
61
+ }
62
+ </script>
63
+
64
+ <template>
65
+ <el-dialog :model-value="true" :title="editing ? '修改图像判断' : '添加图像判断'" width="520px" top="5vh"
66
+ class="image-check-dialog" append-to-body draggable :modal="false" modal-penetrable
67
+ :close-on-click-modal="false" :close-on-press-escape="false" :show-close="!busy" @close="emit('close')">
68
+ <el-form label-position="top" size="small" :disabled="busy">
69
+ <el-form-item label="检测模式">
70
+ <el-select :model-value="config.mode" @update:model-value="patch({ mode: $event, consecutive: $event === 'black' ? 3 : 1, ratio: $event === 'change' ? 2 : 95 })">
71
+ <el-option v-for="(label, key) in IMAGE_CHECK_MODES" :key="key" :label="label" :value="key" />
72
+ </el-select>
73
+ <el-tooltip content="适合固定图标、图片勾选、黑屏、颜色和画面变化。不识别任意文字或业务语义,不能仅凭外观证明按钮可用或视频卡死。证据不足时报告无法判定。" :show-after="200">
74
+ <el-icon class="image-check-help"><QuestionFilled /></el-icon>
75
+ </el-tooltip>
76
+ </el-form-item>
77
+ <el-form-item label="检测目标">
78
+ <el-radio-group :model-value="config.target" @update:model-value="$event === 'element' ? emit('useElement') : patch({ target: 'region' })">
79
+ <el-radio-button value="element" :disabled="!hasElement && config.target !== 'element'">当前选中元素</el-radio-button>
80
+ <el-radio-button value="region">框选区域</el-radio-button>
81
+ </el-radio-group>
82
+ <el-tooltip content="组件按实时定位结果截图,同名组件必须限定父级;框选区域只在采集时相同屏幕尺寸下执行,尺寸变化需重新框选。模板按原始尺寸匹配,不自动缩放。">
83
+ <el-icon class="image-check-help"><QuestionFilled /></el-icon>
84
+ </el-tooltip>
85
+ </el-form-item>
86
+ <el-form-item v-if="config.target === 'region'" label="设备预览区域">
87
+ <el-button :icon="Aim" @click="emit('pick')">{{ picking ? '正在框选' : '框选 / 拖动区域' }}</el-button>
88
+ </el-form-item>
89
+ <div class="image-check-grid">
90
+ <el-form-item v-for="(label, key) in { x: 'X', y: 'Y', width: '宽度', height: '高度' }" :key="key" :label="label">
91
+ <el-input-number :model-value="config.region[key]" :disabled="config.target === 'element'" :min="key === 'x' || key === 'y' ? 0 : 1" :max="16000" :precision="0" controls-position="right"
92
+ @update:model-value="$event !== undefined && patch({ region: { ...config.region, [key]: $event } })" />
93
+ </el-form-item>
94
+ </div>
95
+ <div v-if="slots.length" class="image-check-grid">
96
+ <div v-for="slot in slots" :key="slot.key" class="image-check-template">
97
+ <span>{{ slot.label }}</span>
98
+ <small v-if="imageTemplateSize(config[slot.key])" class="image-check-size">{{ imageTemplateSize(config[slot.key])!.width }} × {{ imageTemplateSize(config[slot.key])!.height }} px</small>
99
+ <el-image v-if="config[slot.key]" :src="'data:image/png;base64,' + config[slot.key]" :preview-src-list="['data:image/png;base64,' + config[slot.key]]" fit="contain" preview-teleported />
100
+ <div v-else class="image-check-empty">未采集</div>
101
+ <div>
102
+ <el-tooltip content="截取当前检测区域作为模板,可再次采集替换"><el-button :icon="Camera" :disabled="!deviceId" :loading="busy" aria-label="采集模板" @click="capture(slot.key)" /></el-tooltip>
103
+ <el-tooltip content="上传 PNG 替换模板"><el-button :icon="Upload" aria-label="上传模板" @click="chooseFile(slot.key)" /></el-tooltip>
104
+ </div>
105
+ </div>
106
+ </div>
107
+ <input ref="fileInput" type="file" accept="image/png" hidden @change="upload" />
108
+ <el-alert v-if="regionIssue" :title="regionIssue" type="warning" show-icon :closable="false" class="image-check-region-warning">
109
+ <el-button v-if="expandedRegion" size="small" @click="patch({ region: expandedRegion })">扩大检测区域</el-button>
110
+ </el-alert>
111
+ <el-form-item v-if="config.mode === 'template' || config.mode === 'change'" label="判断条件">
112
+ <el-radio-group :model-value="config.expectation" @update:model-value="patch({ expectation: $event as 'present' | 'absent' })">
113
+ <el-radio-button value="present">{{ config.mode === 'template' ? '图片存在' : '画面有变化' }}</el-radio-button>
114
+ <el-radio-button value="absent">{{ config.mode === 'template' ? '图片不存在' : '持续无明显变化' }}</el-radio-button>
115
+ </el-radio-group>
116
+ </el-form-item>
117
+ <el-form-item v-if="config.mode === 'color'" label="目标颜色 #RRGGBB">
118
+ <el-color-picker :model-value="config.color" @update:model-value="$event && patch({ color: $event })" />
119
+ <el-input :model-value="config.color" maxlength="7" @update:model-value="patch({ color: $event })" />
120
+ </el-form-item>
121
+ <div class="image-check-grid">
122
+ <el-form-item v-for="field in numericFields" :key="field.key" :label="field.label">
123
+ <el-input-number :model-value="config[field.key]" :min="field.min" :max="field.max" :step="field.step" controls-position="right" @update:model-value="$event !== undefined && patch({ [field.key]: $event })" />
124
+ </el-form-item>
125
+ </div>
126
+ </el-form>
127
+ <template #footer><el-button :disabled="busy" @click="emit('close')">取消</el-button><el-button type="primary" :disabled="busy" @click="confirm">{{ editing ? '保存' : '添加' }}</el-button></template>
128
+ </el-dialog>
129
+ </template>
130
+
131
+ <style>
132
+ .image-check-dialog { max-width: calc(100vw - 24px); }
133
+ .image-check-dialog .el-dialog__body { max-height: 70vh; overflow-y: auto; }
134
+ .image-check-dialog .el-select { flex: 1; }
135
+ .image-check-dialog .el-input-number { width: 100%; }
136
+ .image-check-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 12px; }
137
+ .image-check-help { margin-left: 8px; color: #606266; }
138
+ .image-check-template { display: grid; gap: 6px; margin-bottom: 16px; min-width: 0; }
139
+ .image-check-size { color: #606266; font-size: 12px; }
140
+ .image-check-region-warning { margin-bottom: 16px; }
141
+ .image-check-template .el-image, .image-check-empty { height: 96px; width: 100%; background: #eff5f3; border: 1px solid #d5dfdb; border-radius: 4px; }
142
+ .image-check-empty { display: grid; place-items: center; color: #606266; }
143
+ </style>
@@ -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 || !preview || typeof preview === 'string' || Boolean(preview.removedCount && !confirmed)" @click="submit">合并分支</el-button>
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>
@@ -33,6 +33,7 @@ type InsertAction =
33
33
  | 'checkedState'
34
34
  | 'radioButtonState'
35
35
  | 'aiRecognition'
36
+ | 'imageCheck'
36
37
  | 'textClick'
37
38
  | 'tapIfExists'
38
39
  | 'inputIfExists'
@@ -117,6 +118,7 @@ const actionGroups: Array<{ title: string; actions: Array<{ type: InsertAction;
117
118
  { type: 'delay', label: '添加延时' },
118
119
  { type: 'popupCondition', label: '判断存在' },
119
120
  { type: 'aiRecognition', label: 'AI 识别' },
121
+ { type: 'imageCheck', label: '图像判断' },
120
122
  { type: 'tapIfExists', label: '存在则点击' },
121
123
  { type: 'inputIfExists', label: '存在则输入' },
122
124
  { type: 'clearIfExists', label: '存在则清空' },
@@ -162,6 +164,7 @@ function typeLabel(step: AppiumRecordedStep) {
162
164
  checkedState: '判断勾选',
163
165
  radioButtonState: 'RadioButton 状态',
164
166
  aiRecognition: 'AI 识别',
167
+ imageCheck: '图像判断',
165
168
  openGallery: '启动相册',
166
169
  textClick: '文字点击',
167
170
  };
@@ -169,6 +172,7 @@ function typeLabel(step: AppiumRecordedStep) {
169
172
  }
170
173
 
171
174
  function stepMeta(step: AppiumRecordedStep) {
175
+ if (step.type === 'imageCheck') return flowStepMeta(step);
172
176
  if (step.type === 'delay') return `${step.timeoutMs || 1000}ms`;
173
177
  if (step.type === 'input' || step.type === 'inputIfExists') return `输入内容:${step.value || '空'}`;
174
178
  if (step.type === 'longPress') return flowStepMeta(step);
@@ -336,14 +340,14 @@ function branchSplitPath() {
336
340
  @click="$emit('copy', [item.index])"
337
341
  />
338
342
  </span></el-tooltip>
339
- <el-tooltip v-if="item.step.type === 'input' || item.step.type === 'inputIfExists'" content="修改输入内容:编辑此节点要输入的文字" placement="top" :show-after="200">
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">
340
344
  <span style="display: inline-flex">
341
345
  <el-button
342
346
  text
343
347
  size="small"
344
348
  :icon="Edit"
345
349
  :disabled="disabled"
346
- title="修改输入内容"
350
+ :title="item.step.type === 'runScript' ? '修改连接脚本' : item.step.type === 'imageCheck' ? '修改图像判断配置' : '修改输入内容'"
347
351
  @click="$emit('editInput', item.index)"
348
352
  />
349
353
  </span></el-tooltip>
@@ -26,9 +26,9 @@ const props = defineProps<{
26
26
  const emit = defineEmits<{
27
27
  remove: [index: number];
28
28
  copy: [indexes: number[]];
29
- paste: [index: number, branch?: FlowBranch];
29
+ paste: [index: number, branch?: FlowBranch, beforeStepId?: string];
30
30
  addDelay: [index?: number];
31
- insertAction: [index: number, action: InsertAction];
31
+ insertAction: [index: number, action: InsertAction, beforeStepId?: string];
32
32
  insertBranchAction: [index: number, branch: FlowBranch, action: InsertAction];
33
33
  editInput: [index: number];
34
34
  previewLinkedScript: [index: number];
@@ -89,6 +89,7 @@ const insertActionGroups: FlowActionGroup[] = [
89
89
  { type: 'delay', label: '添加延时' },
90
90
  { type: 'popupCondition', label: '判断存在' },
91
91
  { type: 'aiRecognition', label: 'AI 识别' },
92
+ { type: 'imageCheck', label: '图像判断' },
92
93
  { type: 'tapIfExists', label: '存在则点击' },
93
94
  { type: 'inputIfExists', label: '存在则输入' },
94
95
  { type: 'clearIfExists', label: '存在则清空' },
@@ -330,9 +331,9 @@ function updateStep(index: number, step: AppiumRecordedStep) {
330
331
  :is-copy-selected="isCopySelected"
331
332
  @node-click="handleNodeClick"
332
333
  @copy="(indexes) => emit('copy', indexes)"
333
- @paste="(index, branch) => emit('paste', index, branch)"
334
+ @paste="(index, branch, beforeStepId) => emit('paste', index, branch, beforeStepId)"
334
335
  @remove="(index) => emit('remove', index)"
335
- @insert-action="(index, action) => emit('insertAction', index, action)"
336
+ @insert-action="(index, action, beforeStepId) => emit('insertAction', index, action, beforeStepId)"
336
337
  @insert-branch-action="(index, branch, action) => emit('insertBranchAction', index, branch, action)"
337
338
  @edit-input="(index) => emit('editInput', index)"
338
339
  @preview-linked-script="(index) => emit('previewLinkedScript', index)"
@@ -406,9 +407,9 @@ function updateStep(index: number, step: AppiumRecordedStep) {
406
407
  :is-copy-selected="isCopySelected"
407
408
  @node-click="handleNodeClick"
408
409
  @copy="(indexes) => emit('copy', indexes)"
409
- @paste="(index, branch) => emit('paste', index, branch)"
410
+ @paste="(index, branch, beforeStepId) => emit('paste', index, branch, beforeStepId)"
410
411
  @remove="(index) => emit('remove', index)"
411
- @insert-action="(index, action) => emit('insertAction', index, action)"
412
+ @insert-action="(index, action, beforeStepId) => emit('insertAction', index, action, beforeStepId)"
412
413
  @insert-branch-action="(index, branch, action) => emit('insertBranchAction', index, branch, action)"
413
414
  @edit-input="(index) => emit('editInput', index)"
414
415
  @preview-linked-script="(index) => emit('previewLinkedScript', index)"
@@ -1,4 +1,5 @@
1
1
  import type { AppiumRecordedStep } from './types';
2
+ import { insertBeforeSharedStep } from './flow-insert';
2
3
 
3
4
  export type FlowBranch = 'yes' | 'no';
4
5
 
@@ -10,6 +11,7 @@ export type FlowClipboard = {
10
11
  type PasteTarget = {
11
12
  afterIndex: number;
12
13
  branch?: FlowBranch;
14
+ beforeStepId?: string;
13
15
  };
14
16
 
15
17
  const targetKeys = [
@@ -178,6 +180,8 @@ export function pasteFlowClipboard(
178
180
  });
179
181
  const copies = clipboard.steps.map((source) => {
180
182
  const step = clone(source);
183
+ // 合并记录引用原流程 ID,不能随节点复制到另一条流程。
184
+ delete step.mergeUndo;
181
185
  step.id = idMap.get(source.id) || createId();
182
186
  if (step.breakLoopTargetId) step.breakLoopTargetId = idMap.get(step.breakLoopTargetId) || step.breakLoopTargetId;
183
187
  const flow = { ...(step.flow || {}) };
@@ -226,6 +230,8 @@ export function pasteFlowClipboard(
226
230
  const lastRoot = copiedRoots[copiedRoots.length - 1];
227
231
  if (!firstRoot || !lastRoot) throw new Error('复制节点数据无效');
228
232
 
233
+ if (target.beforeStepId) return insertBeforeSharedStep(steps, target.beforeStepId, copies, copiedRoots.map(step => step.id));
234
+
229
235
  const nextSteps = [...steps];
230
236
  let insertAfterIndex = target.afterIndex;
231
237
  let continuationId = '';
@@ -34,6 +34,7 @@ export type InsertAction =
34
34
  | 'checkedState'
35
35
  | 'radioButtonState'
36
36
  | 'aiRecognition'
37
+ | 'imageCheck'
37
38
  | 'textClick'
38
39
  | 'tapIfExists'
39
40
  | 'inputIfExists'
@@ -107,6 +108,7 @@ export type FlowGraphNodeData =
107
108
  kind: 'insert';
108
109
  actionGroups: FlowActionGroup[];
109
110
  afterIndex: number;
111
+ beforeStepId?: string;
110
112
  branch?: FlowBranch;
111
113
  branchLabel?: string;
112
114
  conditionIndex?: number;
@@ -260,7 +262,8 @@ function branchConnectionTargetId(items: StepItem[], condition: AppiumRecordedSt
260
262
  const targetId = branch === 'yes' ? condition.flow?.yesTargetId : condition.flow?.noTargetId;
261
263
  if (!targetId) return condition.type === 'loop' && branch === 'yes' ? '' : continuationTargetId(items, condition);
262
264
  const target = items.find(({ step }) => step.id === targetId)?.step;
263
- return target && target.flow?.parentConditionId === condition.flow?.parentConditionId ? target.id : '';
265
+ // 合流目标可以属于祖先作用域,不能用同父节点限制过滤合法的显式连接。
266
+ return target?.id || '';
264
267
  }
265
268
 
266
269
  function estimateTextLines(text: string | undefined, charsPerLine: number) {
@@ -378,8 +381,9 @@ export function buildFlowGraph(
378
381
  afterIndex: number,
379
382
  branch?: FlowBranch,
380
383
  condition?: StepItem,
384
+ beforeStepId?: string,
381
385
  ) => {
382
- const id = insertNodeId(afterIndex, branch, condition?.step.id);
386
+ const id = beforeStepId ? `insert:before:${beforeStepId}` : insertNodeId(afterIndex, branch, condition?.step.id);
383
387
  const isStartInsert = afterIndex < 0 && !branch;
384
388
  addNode({
385
389
  id,
@@ -398,6 +402,7 @@ export function buildFlowGraph(
398
402
  ? options.startActionGroups
399
403
  : withLaunchAppAction(options.mainActionGroups),
400
404
  afterIndex,
405
+ beforeStepId,
401
406
  branch,
402
407
  branchLabel: branch && condition ? flowBranchLabel(condition.step, branch) : undefined,
403
408
  conditionIndex: condition?.index,
@@ -453,7 +458,7 @@ export function buildFlowGraph(
453
458
  canCopy: item.step.type !== 'launchApp'
454
459
  && item.step.type !== 'clearAppData'
455
460
  && item.step.visualChange?.role !== 'end',
456
- canEditInput: item.step.type === 'input' || item.step.type === 'inputIfExists',
461
+ canEditInput: ['input', 'inputIfExists', 'imageCheck', 'runScript'].includes(item.step.type),
457
462
  canExecute: item.step.type === 'launchApp' || item.step.type === 'clearAppData' || item.step.type === 'aiRecognition',
458
463
  missingAiModel: item.step.type === 'aiRecognition' && !options.aiRecognitionModelConfigured,
459
464
  },
@@ -585,6 +590,20 @@ export function buildFlowGraph(
585
590
  }
586
591
  });
587
592
 
593
+ // 汇入线先接公共插入点,再接原节点;分支内插入点仍保持独立。
594
+ const joins = new Map<string, string>();
595
+ items.forEach(({ step }) => {
596
+ const target = defaultKind(step) === 'condition' && stepById.get(continuationTargetId(items, step));
597
+ if (!target) return;
598
+ const targetId = nodeIdForStep(target.step);
599
+ const insertId = `insert:before:${target.step.id}`;
600
+ if (joins.has(insertId) || !links.some(link => link.target === targetId)) return;
601
+ addInsertNode(target.index - 1, undefined, undefined, target.step.id);
602
+ links.forEach(link => { if (link.target === targetId) link.target = insertId; });
603
+ addVisibleLink(insertId, targetId);
604
+ joins.set(insertId, nodeIdForStep(step));
605
+ });
606
+
588
607
  const selectedDescendantIds = collectDescendantIds(
589
608
  steps,
590
609
  options.selectedCopyIndexes.map((index) => steps[index]?.id).filter((id): id is string => Boolean(id)),
@@ -752,13 +771,6 @@ export function buildFlowGraph(
752
771
  (nodeOrder.get(a.source) || 0) - (nodeOrder.get(b.source) || 0)
753
772
  ));
754
773
  // 公共起点从两侧汇入,不能被最后一条分支连线拉到左列或右列。
755
- const joins = new Map<string, string>();
756
- items.forEach(({ step }) => {
757
- const target = defaultKind(step) === 'condition' && stepById.get(continuationTargetId(items, step));
758
- if (defaultKind(step) === 'condition' && target && !joins.has(nodeIdForStep(target.step))) {
759
- joins.set(nodeIdForStep(target.step), nodeIdForStep(step));
760
- }
761
- });
762
774
  const alignVisibleChainGeometry = () => {
763
775
  orderedLinks.forEach((link) => {
764
776
  const source = nodeById.get(link.source);
@@ -0,0 +1,27 @@
1
+ import type { AppiumRecordedStep } from './types';
2
+
3
+ /** 在公共节点前插入,不改变原有分支归属,并重接所有显式汇入连接。 */
4
+ export function insertBeforeSharedStep(steps: AppiumRecordedStep[], targetId: string, additions: AppiumRecordedStep[], rootIds: string[]) {
5
+ const index = steps.findIndex(step => step.id === targetId);
6
+ if (index < 0) throw new Error('合流节点已不存在,请重新选择插入位置');
7
+ const target = steps[index];
8
+ const roots = new Set(rootIds);
9
+ const first = rootIds[0];
10
+ const last = rootIds[rootIds.length - 1];
11
+ if (!first || !last) throw new Error('没有可插入的节点');
12
+ const inserted = additions.map(step => roots.has(step.id) ? {
13
+ ...step, flow: { ...step.flow, parentConditionId: target.flow?.parentConditionId, parentBranch: target.flow?.parentBranch,
14
+ ...(step.id === last ? { successTargetId: targetId } : {}) },
15
+ } : step);
16
+ const result = steps.map(step => {
17
+ if (!step.flow) return step;
18
+ const flow = { ...step.flow };
19
+ for (const key of ['yesTargetId', 'noTargetId', 'successTargetId'] as const) {
20
+ if (flow[key] === targetId) flow[key] = first;
21
+ }
22
+ return { ...step, flow };
23
+ });
24
+ // 无显式目标的默认返回会按同作用域顺序落到新节点。
25
+ result.splice(index, 0, ...inserted);
26
+ return result;
27
+ }
@@ -4,9 +4,10 @@ import { visualChangeMeta } from './visual-change';
4
4
  import { longPressMode } from './long-press';
5
5
  import { isNativeStateCondition } from './native-control-state';
6
6
  import { DEFAULT_LOG_PREFIX } from './stage-log';
7
+ import { imageCheckSummary } from './image-check';
7
8
 
8
9
  export function isBooleanCondition(step: Pick<AppiumRecordedStep, 'type'>) {
9
- return isNativeStateCondition(step) || step.type === 'aiRecognition' || step.type === 'textClick';
10
+ return isNativeStateCondition(step) || step.type === 'aiRecognition' || step.type === 'textClick' || step.type === 'imageCheck';
10
11
  }
11
12
 
12
13
  export function defaultFlowKind(step: AppiumRecordedStep): FlowKind {
@@ -47,6 +48,7 @@ export function flowTypeLabel(step: AppiumRecordedStep) {
47
48
  checkedState: '判断勾选',
48
49
  radioButtonState: 'RadioButton 状态',
49
50
  aiRecognition: 'AI 识别',
51
+ imageCheck: '图像判断',
50
52
  textClick: '文字点击',
51
53
  assertText: '断言文本',
52
54
  key: '按键',
@@ -73,6 +75,7 @@ export function flowTypeLabel(step: AppiumRecordedStep) {
73
75
  }
74
76
 
75
77
  export function flowStepMeta(step: AppiumRecordedStep) {
78
+ if (step.type === 'imageCheck') return imageCheckSummary(step.imageCheck);
76
79
  if (step.type === 'extractVariable') return `${step.extractVariable?.attribute || 'text'} → ${step.extractVariable?.name || '未设置变量名'}`;
77
80
  if (step.type === 'loop') return `最多 ${step.loop?.maxIterations ?? '?'} 次 · ${step.loop?.exitWhen === 'exists' ? '元素出现时退出' : step.loop?.exitWhen === 'notExists' ? '元素消失时退出' : '固定次数'}${step.loop?.exitWhen !== 'never' ? ` ${step.selector?.value || ''}` : ''}`;
78
81
  if (step.type === 'breakLoop') return step.breakLoopTargetId ? '退出指定循环,继续循环结束后的流程' : '退出当前循环,继续循环结束后的流程';
@@ -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
  }