android-midscene-automation 0.1.28 → 0.1.30

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.
@@ -0,0 +1,74 @@
1
+ <script setup lang="ts">
2
+ import { computed, ref, watch } from 'vue';
3
+ import { ElMessage } from 'element-plus';
4
+ import type { AppiumRecordedStep } from '../types';
5
+ import { branchSteps, mergeBranches } from '../flow-merge';
6
+ import { flowBranchLabel } from '../flow-labels';
7
+
8
+ const props = defineProps<{ steps: AppiumRecordedStep[]; conditionId: string; disabled?: boolean }>();
9
+ const emit = defineEmits<{ close: []; confirm: [steps: AppiumRecordedStep[]] }>();
10
+ const commonId = ref('');
11
+ const duplicateId = ref('');
12
+ const confirmed = ref(false);
13
+ const retainedSide = ref<'yes' | 'no'>('yes');
14
+ const condition = computed(() => props.steps.find((step) => step.id === props.conditionId));
15
+ const candidates = computed(() => branchSteps(props.steps, props.conditionId, retainedSide.value));
16
+ const duplicates = computed(() => branchSteps(props.steps, props.conditionId, retainedSide.value === 'yes' ? 'no' : 'yes'));
17
+ const preview = computed(() => {
18
+ if (!commonId.value) return undefined;
19
+ try { return mergeBranches(props.steps, props.conditionId, commonId.value, duplicateId.value); }
20
+ catch (error) { return error instanceof Error ? error.message : String(error); }
21
+ });
22
+ watch(() => props.conditionId, () => {
23
+ retainedSide.value = branchSteps(props.steps, props.conditionId, 'yes').length ? 'yes' : 'no';
24
+ commonId.value = ''; duplicateId.value = ''; confirmed.value = false;
25
+ });
26
+ // 切换方向时清空此前的选择和删除确认,避免误删新方向下的节点。
27
+ watch(retainedSide, () => { commonId.value = ''; duplicateId.value = ''; confirmed.value = false; });
28
+ watch(commonId, () => { duplicateId.value = ''; confirmed.value = false; });
29
+ watch(duplicateId, () => { confirmed.value = false; });
30
+ function submit() {
31
+ if (props.disabled || !preview.value || typeof preview.value === 'string') return;
32
+ if (preview.value.removedCount && !confirmed.value) return;
33
+ try {
34
+ emit('confirm', mergeBranches(props.steps, props.conditionId, commonId.value, duplicateId.value).steps);
35
+ emit('close');
36
+ } catch (error) { ElMessage.error(String(error)); }
37
+ }
38
+ </script>
39
+
40
+ <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">
43
+ <el-form-item label="合并方向">
44
+ <el-radio-group v-model="retainedSide" aria-label="合并方向">
45
+ <el-radio-button value="yes">保留左侧流程</el-radio-button>
46
+ <el-radio-button value="no">保留右侧流程</el-radio-button>
47
+ </el-radio-group>
48
+ </el-form-item>
49
+ <el-form-item label="保留的公共流程起点">
50
+ <el-select v-model="commonId" aria-label="保留的公共流程起点" style="width: 100%">
51
+ <el-option v-for="step in candidates" :key="step.id" :value="step.id"
52
+ :label="`${condition ? flowBranchLabel(condition, step.flow!.parentBranch!) : ''} · ${step.label}`" />
53
+ </el-select>
54
+ </el-form-item>
55
+ <el-form-item :label="retainedSide === 'yes' ? '右侧重复部分起点' : '左侧重复部分起点'">
56
+ <el-select v-model="duplicateId" :disabled="!commonId" aria-label="另一侧重复部分起点" style="width: 100%">
57
+ <el-option value="" label="不删除,保留另一侧全部操作后接入" />
58
+ <el-option v-for="step in duplicates" :key="step.id" :value="step.id" :label="step.label" />
59
+ </el-select>
60
+ </el-form-item>
61
+ <el-alert v-if="typeof preview === 'string'" :title="preview" type="error" :closable="false" />
62
+ <el-checkbox v-else-if="preview?.removedCount" v-model="confirmed">确认删除另一侧从所选起点开始的 {{ preview.removedCount }} 个节点</el-checkbox>
63
+ </el-form>
64
+ <template #footer>
65
+ <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>
67
+ </template>
68
+ </el-dialog>
69
+ </template>
70
+
71
+ <style scoped>
72
+ :deep(.el-checkbox) { height: auto; white-space: normal; }
73
+ :deep(.el-checkbox__label) { white-space: normal; overflow-wrap: anywhere; }
74
+ </style>
@@ -23,9 +23,12 @@ type InsertAction =
23
23
  | 'swipe'
24
24
  | 'pinch'
25
25
  | 'launchApp'
26
+ | 'openGallery'
27
+ | 'endFlow'
26
28
  | 'clearAppData'
27
29
  | 'popupCondition'
28
30
  | 'checkboxState'
31
+ | 'checkedState'
29
32
  | 'radioButtonState'
30
33
  | 'aiRecognition'
31
34
  | 'textClick'
@@ -87,9 +90,8 @@ const actionGroups: Array<{ title: string; actions: Array<{ type: InsertAction;
87
90
  { type: 'clearInput', label: '清空输入' },
88
91
  { type: 'coordinateTap', label: '点击坐标' },
89
92
  { type: 'longPress', label: '长按' },
90
- { type: 'checkboxState', label: '判断 Checkbox 状态' },
93
+ { type: 'checkedState', label: '判断勾选' },
91
94
  { type: 'textClick', label: '文字点击' },
92
- { type: 'radioButtonState', label: '判断 RadioButton 状态' },
93
95
  ],
94
96
  },
95
97
  {
@@ -97,6 +99,7 @@ const actionGroups: Array<{ title: string; actions: Array<{ type: InsertAction;
97
99
  actions: [
98
100
  { type: 'keyBack', label: '系统返回' },
99
101
  { type: 'keyHome', label: 'Home 键' },
102
+ { type: 'openGallery', label: '启动相册' },
100
103
  { type: 'keyRecent', label: '最近任务' },
101
104
  { type: 'keyPower', label: '电源键' },
102
105
  { type: 'swipe', label: '滑动' },
@@ -126,6 +129,7 @@ const actionGroups: Array<{ title: string; actions: Array<{ type: InsertAction;
126
129
  title: '流程控制',
127
130
  actions: [
128
131
  { type: 'noop', label: '空节点' },
132
+ { type: 'endFlow', label: '终止流程' },
129
133
  { type: 'log', label: '输出日志' },
130
134
  { type: 'runScript', label: '连接脚本' },
131
135
  ],
@@ -145,10 +149,13 @@ function typeLabel(step: AppiumRecordedStep) {
145
149
  coordinateTap: '坐标点击', swipe: '滑动', launchApp: '启动 APP', clearAppData: '清理 APP 缓存', longPress: '长按',
146
150
  pinch: '双指缩放', runScript: '连接脚本', screenshot: '截图', visualChange: '检测画面变化',
147
151
  noop: '空节点',
152
+ endFlow: '终止流程',
148
153
  log: '输出日志',
149
154
  checkboxState: 'Checkbox 状态',
155
+ checkedState: '判断勾选',
150
156
  radioButtonState: 'RadioButton 状态',
151
157
  aiRecognition: 'AI 识别',
158
+ openGallery: '启动相册',
152
159
  textClick: '文字点击',
153
160
  };
154
161
  return labels[step.type] || step.type;
@@ -312,16 +319,19 @@ function branchSplitPath() {
312
319
  </span>
313
320
  </button>
314
321
  <span class="appium-flow-node__actions" @click.stop>
322
+ <el-tooltip v-if="!copyMode && canCopyStep(item.step)" content="复制节点:复制后可在插入位置粘贴" placement="top" :show-after="200">
323
+ <span style="display: inline-flex">
315
324
  <el-button
316
- v-if="!copyMode && canCopyStep(item.step)"
317
325
  text
318
326
  size="small"
319
327
  :icon="CopyDocument"
320
328
  title="复制节点"
321
329
  @click="$emit('copy', [item.index])"
322
330
  />
331
+ </span></el-tooltip>
332
+ <el-tooltip v-if="item.step.type === 'input' || item.step.type === 'inputIfExists'" content="修改输入内容:编辑此节点要输入的文字" placement="top" :show-after="200">
333
+ <span style="display: inline-flex">
323
334
  <el-button
324
- v-if="item.step.type === 'input' || item.step.type === 'inputIfExists'"
325
335
  text
326
336
  size="small"
327
337
  :icon="Edit"
@@ -329,6 +339,9 @@ function branchSplitPath() {
329
339
  title="修改输入内容"
330
340
  @click="$emit('editInput', item.index)"
331
341
  />
342
+ </span></el-tooltip>
343
+ <el-tooltip content="删除节点:移除此操作;判断节点同时删除所属分支子节点" placement="top" :show-after="200">
344
+ <span style="display: inline-flex">
332
345
  <el-button
333
346
  text
334
347
  size="small"
@@ -337,6 +350,7 @@ function branchSplitPath() {
337
350
  title="删除节点"
338
351
  @click="$emit('remove', item.index)"
339
352
  />
353
+ </span></el-tooltip>
340
354
  </span>
341
355
  </div>
342
356
  <FlowStepEditor
@@ -1,4 +1,6 @@
1
1
  <script setup lang="ts">
2
+ import { ref } from 'vue';
3
+ import { View } from '@element-plus/icons-vue';
2
4
  import type { AppiumNode } from '../types';
3
5
 
4
6
  defineProps<{
@@ -6,6 +8,7 @@ defineProps<{
6
8
  currentActivity?: string;
7
9
  loading?: boolean;
8
10
  }>();
11
+ const detailsVisible = ref(false);
9
12
  </script>
10
13
 
11
14
  <template>
@@ -14,6 +17,15 @@ defineProps<{
14
17
  element-loading-text="正在检测页面变化..."
15
18
  class="appium-node-detail"
16
19
  >
20
+ <div class="appium-node-summary">
21
+ <div class="appium-node-summary__text" v-if="node">
22
+ <strong :title="node.label">{{ node.label }}</strong>
23
+ <span>{{ node.className.split('.').pop() }}<template v-if="node.checkable"> · {{ node.checked === undefined ? '勾选状态未知' : node.checked ? '已勾选' : '未勾选' }}</template><template v-if="node.selector.matchCount"> · {{ node.selector.matchCount > 1 ? `定位重复 ${node.selector.matchCount}` : '定位唯一' }}</template></span>
24
+ </div>
25
+ <span v-else class="appium-node-summary__empty">请选择组件</span>
26
+ <el-button size="small" type="primary" :icon="View" :disabled="!node" @click="detailsVisible = true">详情</el-button>
27
+ </div>
28
+ <el-drawer v-model="detailsVisible" title="组件详情" size="520px" style="max-width: 94vw" append-to-body>
17
29
  <template v-if="node">
18
30
  <div class="appium-node-detail__title">{{ node.label }}</div>
19
31
  <el-descriptions :column="1" border size="small">
@@ -25,7 +37,6 @@ defineProps<{
25
37
  <el-descriptions-item v-if="node.checkable" label="checked">{{ node.checked === undefined ? '-' : String(node.checked) }}</el-descriptions-item>
26
38
  <el-descriptions-item label="当前 Activity">{{ currentActivity || '-' }}</el-descriptions-item>
27
39
  <el-descriptions-item label="selector">
28
- <span v-if="currentActivity">Activity {{ currentActivity }} · </span>
29
40
  {{ node.selector.strategy }} {{ node.selector.value || '' }}
30
41
  <el-tag
31
42
  v-if="node.selector.matchCount"
@@ -37,12 +48,10 @@ defineProps<{
37
48
  </el-descriptions-item>
38
49
  <el-descriptions-item label="推荐定位">
39
50
  <span v-if="node.contextSelector">
40
- <template v-if="currentActivity">Activity {{ currentActivity }} + </template>
41
51
  父级 {{ node.contextSelector.strategy }} {{ node.contextSelector.value || '' }}
42
52
  + 子级 {{ node.selector.strategy }} {{ node.selector.value || '' }}
43
53
  </span>
44
54
  <span v-else>
45
- <template v-if="currentActivity">Activity {{ currentActivity }} + </template>
46
55
  {{ node.selector.unique ? '当前 selector 唯一' : '使用当前 selector 或坐标兜底' }}
47
56
  </span>
48
57
  </el-descriptions-item>
@@ -54,7 +63,16 @@ defineProps<{
54
63
  </el-descriptions-item>
55
64
  </el-descriptions>
56
65
  </template>
57
- <el-empty v-else description="请选择组件" />
66
+ <p v-else>请选择组件</p>
67
+ </el-drawer>
58
68
 
59
69
  </div>
60
70
  </template>
71
+
72
+ <style scoped>
73
+ .appium-node-summary { display: flex; align-items: center; justify-content: space-between; gap: 8px; min-height: 44px; padding: 6px 0; }
74
+ .appium-node-summary__text { display: grid; gap: 2px; min-width: 0; font-size: 12px; }
75
+ .appium-node-summary__text strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 13px; }
76
+ .appium-node-summary__text span, .appium-node-summary__empty { color: var(--el-text-color-secondary); font-size: 12px; }
77
+ :deep(.el-descriptions__content) { overflow-wrap: anywhere; word-break: break-word; }
78
+ </style>
@@ -1,10 +1,12 @@
1
1
  <script setup lang="ts">
2
2
  import { computed, nextTick, shallowRef, watch } from 'vue';
3
- import { ElMessage } from 'element-plus';
4
- import { Aim, CopyDocument, FullScreen } from '@element-plus/icons-vue';
3
+ import { ElMessage, ElMessageBox } from 'element-plus';
4
+ import { Aim, CopyDocument, FullScreen, Close, Delete } from '@element-plus/icons-vue';
5
+ import { removeFlowSteps } from '../flow-remove';
5
6
  import type { AppiumRecordedStep } from '../types';
6
7
  import { createFlowClipboard } from '../flow-copy';
7
8
  import FlowCanvas from './FlowCanvas.vue';
9
+ import MergeBranchesDialog from './MergeBranchesDialog.vue';
8
10
  import { labelFlowStep } from '../flow-labels';
9
11
  import type { FlowActionGroup, FlowBranch, InsertAction } from '../flow-graph';
10
12
 
@@ -13,6 +15,7 @@ const props = defineProps<{
13
15
  steps: AppiumRecordedStep[];
14
16
  disabled?: boolean;
15
17
  removeDisabled?: boolean;
18
+ mergeDisabled?: boolean;
16
19
  allowedLockedActions?: InsertAction[];
17
20
  launchingStepId?: string;
18
21
  clipboardCount?: number;
@@ -29,10 +32,14 @@ const emit = defineEmits<{
29
32
  previewLinkedScript: [index: number];
30
33
  executeStep: [index: number];
31
34
  updateStep: [index: number, step: AppiumRecordedStep];
35
+ replaceSteps: [steps: AppiumRecordedStep[]];
32
36
  }>();
33
37
 
34
38
  const expandedStepIndex = shallowRef<number | null>(null);
39
+ const mergeConditionId = shallowRef('');
35
40
  const copyMode = shallowRef(false);
41
+ const deleteMode = shallowRef(false);
42
+ const deleting = shallowRef(false);
36
43
  const selectedCopyIndexes = shallowRef<number[]>([]);
37
44
  const flowDialogVisible = shallowRef(false);
38
45
  const mainResetViewToken = shallowRef(0);
@@ -47,9 +54,8 @@ const insertActionGroups: FlowActionGroup[] = [
47
54
  { type: 'clearInput', label: '清空输入' },
48
55
  { type: 'coordinateTap', label: '点击坐标' },
49
56
  { type: 'longPress', label: '长按' },
50
- { type: 'checkboxState', label: '判断 Checkbox 状态' },
57
+ { type: 'checkedState', label: '判断勾选' },
51
58
  { type: 'textClick', label: '文字点击' },
52
- { type: 'radioButtonState', label: '判断 RadioButton 状态' },
53
59
  ],
54
60
  },
55
61
  {
@@ -63,6 +69,7 @@ const insertActionGroups: FlowActionGroup[] = [
63
69
  { type: 'pinch', label: '双指缩放' },
64
70
  { type: 'clearAppData', label: '清理 App 缓存' },
65
71
  { type: 'launchApp', label: '启动 App' },
72
+ { type: 'openGallery', label: '启动相册' },
66
73
  ],
67
74
  },
68
75
  {
@@ -88,6 +95,7 @@ const insertActionGroups: FlowActionGroup[] = [
88
95
  title: '流程控制',
89
96
  actions: [
90
97
  { type: 'noop', label: '空节点' },
98
+ { type: 'endFlow', label: '终止流程' },
91
99
  { type: 'log', label: '输出日志' },
92
100
  { type: 'runScript', label: '连接脚本' },
93
101
  ],
@@ -121,7 +129,7 @@ function toggleCopySelection(index: number) {
121
129
  const step = props.steps[index];
122
130
  if (!step) return;
123
131
  if (step.type === 'launchApp' || step.type === 'clearAppData') {
124
- ElMessage.warning('App 初始化节点不能复制');
132
+ ElMessage.warning(deleteMode.value ? 'App 初始化节点不参与批量删除' : 'App 初始化节点不能复制');
125
133
  return;
126
134
  }
127
135
  if (selectedCopyIndexes.value.includes(index)) {
@@ -139,7 +147,8 @@ function toggleCopySelection(index: number) {
139
147
  try {
140
148
  createFlowClipboard(props.steps, candidate);
141
149
  } catch (error) {
142
- ElMessage.warning(error instanceof Error ? error.message : '当前节点不能一起复制');
150
+ const message = error instanceof Error ? error.message : '当前节点不能一起选择';
151
+ ElMessage.warning(deleteMode.value ? message.replace(/复制/g, '删除') : message);
143
152
  return;
144
153
  }
145
154
  selectedCopyIndexes.value = candidate;
@@ -161,12 +170,14 @@ function handleNodeClick(index: number) {
161
170
  }
162
171
 
163
172
  function startCopyMode() {
173
+ deleteMode.value = false;
164
174
  copyMode.value = true;
165
175
  selectedCopyIndexes.value = [];
166
176
  expandedStepIndex.value = null;
167
177
  }
168
178
 
169
179
  function cancelCopyMode() {
180
+ deleteMode.value = false;
170
181
  copyMode.value = false;
171
182
  selectedCopyIndexes.value = [];
172
183
  }
@@ -177,6 +188,33 @@ function copySelectedNodes() {
177
188
  cancelCopyMode();
178
189
  }
179
190
 
191
+ function startDeleteMode() {
192
+ if (props.mergeDisabled || props.removeDisabled) return;
193
+ startCopyMode();
194
+ deleteMode.value = true;
195
+ }
196
+
197
+ async function deleteSelectedNodes() {
198
+ if (deleting.value || props.mergeDisabled || props.removeDisabled || !selectedCopyIndexes.value.length) return;
199
+ const original = props.steps;
200
+ const next = removeFlowSteps(original, selectedCopyIndexes.value);
201
+ deleting.value = true;
202
+ try {
203
+ await ElMessageBox.confirm(`确认删除选中的 ${original.length - next.length} 个节点?所属分支子节点会一起删除,公共流程保留。`, '批量删除', {
204
+ type: 'warning', confirmButtonText: '删除', cancelButtonText: '取消', appendTo: 'body',
205
+ });
206
+ if (props.steps !== original || props.mergeDisabled || props.removeDisabled) {
207
+ ElMessage.warning('流程或运行状态已变化,请重新选择');
208
+ return;
209
+ }
210
+ emit('replaceSteps', next);
211
+ cancelCopyMode();
212
+ } catch { /* 取消确认时保留选中状态。 */ }
213
+ finally { deleting.value = false; }
214
+ }
215
+
216
+ watch(() => props.steps, () => { if (copyMode.value) cancelCopyMode(); });
217
+
180
218
  function resetMainFlowPosition() {
181
219
  mainResetViewToken.value += 1;
182
220
  }
@@ -216,40 +254,43 @@ function updateStep(index: number, step: AppiumRecordedStep) {
216
254
  <div class="appium-flow-toolbar">
217
255
  <template v-if="copyMode">
218
256
  <span class="appium-flow-toolbar__status">已选择 {{ selectedCopyIndexes.length }} 个节点</span>
219
- <el-button
257
+ <el-tooltip :content="deleteMode ? '删除选中' : '复制选中'" placement="top" :show-after="200"><span class="flow-toolbar-icon">
258
+ <el-button :aria-label="deleteMode ? '删除选中' : '复制选中'"
220
259
  size="small"
221
- type="primary"
222
- :disabled="!selectedCopyIndexes.length"
223
- :icon="CopyDocument"
224
- @click="copySelectedNodes"
225
- >
226
- 复制选中
227
- </el-button>
228
- <el-button size="small" @click="cancelCopyMode">取消</el-button>
260
+ :type="deleteMode ? 'danger' : 'primary'"
261
+ :loading="deleting"
262
+ :disabled="!selectedCopyIndexes.length || (deleteMode && (mergeDisabled || removeDisabled))"
263
+ :icon="deleteMode ? Delete : CopyDocument"
264
+ @click="deleteMode ? deleteSelectedNodes() : copySelectedNodes()"
265
+ />
266
+ </span></el-tooltip>
267
+ <el-tooltip content="取消" placement="top" :show-after="200"><span class="flow-toolbar-icon"><el-button size="small" :icon="Close" aria-label="取消" @click="cancelCopyMode" /></span></el-tooltip>
229
268
  </template>
230
- <el-button
231
- v-else
269
+ <el-tooltip v-else content="批量复制" placement="top" :show-after="200"><span class="flow-toolbar-icon">
270
+ <el-button aria-label="批量复制"
232
271
  size="small"
233
272
  :icon="CopyDocument"
234
273
  :disabled="!steps.length"
235
274
  @click="startCopyMode"
236
- >
237
- 批量复制
238
- </el-button>
239
- <el-button
275
+ />
276
+ </span></el-tooltip>
277
+ <el-tooltip v-if="!copyMode" content="批量删除" placement="top" :show-after="200"><span class="flow-toolbar-icon">
278
+ <el-button aria-label="批量删除" size="small" :icon="Delete" :disabled="!steps.length || mergeDisabled || removeDisabled" @click="startDeleteMode" />
279
+ </span></el-tooltip>
280
+ <el-tooltip content="还原位置" placement="top" :show-after="200"><span class="flow-toolbar-icon">
281
+ <el-button aria-label="还原位置"
240
282
  size="small"
241
283
  :icon="Aim"
242
284
  @click="resetMainFlowPosition"
243
- >
244
- 还原位置
245
- </el-button>
246
- <el-button
285
+ />
286
+ </span></el-tooltip>
287
+ <el-tooltip content="放大" placement="top" :show-after="200"><span class="flow-toolbar-icon">
288
+ <el-button aria-label="放大"
247
289
  size="small"
248
290
  :icon="FullScreen"
249
291
  @click="flowDialogVisible = true"
250
- >
251
- 放大
252
- </el-button>
292
+ />
293
+ </span></el-tooltip>
253
294
  </div>
254
295
 
255
296
  <FlowCanvas
@@ -257,9 +298,11 @@ function updateStep(index: number, step: AppiumRecordedStep) {
257
298
  :steps="steps"
258
299
  :expanded-step-index="expandedStepIndex"
259
300
  :copy-mode="copyMode"
301
+ :delete-mode="deleteMode"
260
302
  :selected-copy-indexes="selectedCopyIndexes"
261
303
  :disabled="disabled"
262
304
  :remove-disabled="removeDisabled"
305
+ :merge-disabled="mergeDisabled"
263
306
  :launching-step-id="launchingStepId"
264
307
  :ai-recognition-model-configured="aiRecognitionModelConfigured"
265
308
  :clipboard-count="clipboardCount"
@@ -282,6 +325,7 @@ function updateStep(index: number, step: AppiumRecordedStep) {
282
325
  @preview-linked-script="(index) => emit('previewLinkedScript', index)"
283
326
  @execute-step="(index) => emit('executeStep', index)"
284
327
  @update-step="(index, step) => updateStep(index, step)"
328
+ @merge="mergeConditionId = steps[$event]?.id || ''"
285
329
  />
286
330
 
287
331
  <el-dialog
@@ -294,26 +338,29 @@ function updateStep(index: number, step: AppiumRecordedStep) {
294
338
  <div class="appium-flow-toolbar appium-flow-dialog__toolbar">
295
339
  <template v-if="copyMode">
296
340
  <span class="appium-flow-toolbar__status">已选择 {{ selectedCopyIndexes.length }} 个节点</span>
297
- <el-button
341
+ <el-tooltip :content="deleteMode ? '删除选中' : '复制选中'" placement="top" :show-after="200"><span class="flow-toolbar-icon">
342
+ <el-button :aria-label="deleteMode ? '删除选中' : '复制选中'"
298
343
  size="small"
299
- type="primary"
300
- :disabled="!selectedCopyIndexes.length"
301
- :icon="CopyDocument"
302
- @click="copySelectedNodes"
303
- >
304
- 复制选中
305
- </el-button>
306
- <el-button size="small" @click="cancelCopyMode">取消</el-button>
344
+ :type="deleteMode ? 'danger' : 'primary'"
345
+ :loading="deleting"
346
+ :disabled="!selectedCopyIndexes.length || (deleteMode && (mergeDisabled || removeDisabled))"
347
+ :icon="deleteMode ? Delete : CopyDocument"
348
+ @click="deleteMode ? deleteSelectedNodes() : copySelectedNodes()"
349
+ />
350
+ </span></el-tooltip>
351
+ <el-tooltip content="取消" placement="top" :show-after="200"><span class="flow-toolbar-icon"><el-button size="small" :icon="Close" aria-label="取消" @click="cancelCopyMode" /></span></el-tooltip>
307
352
  </template>
308
- <el-button
309
- v-else
353
+ <el-tooltip v-else content="批量复制" placement="top" :show-after="200"><span class="flow-toolbar-icon">
354
+ <el-button aria-label="批量复制"
310
355
  size="small"
311
356
  :icon="CopyDocument"
312
357
  :disabled="!steps.length"
313
358
  @click="startCopyMode"
314
- >
315
- 批量复制
316
- </el-button>
359
+ />
360
+ </span></el-tooltip>
361
+ <el-tooltip v-if="!copyMode" content="批量删除" placement="top" :show-after="200"><span class="flow-toolbar-icon">
362
+ <el-button aria-label="批量删除" size="small" :icon="Delete" :disabled="!steps.length || mergeDisabled || removeDisabled" @click="startDeleteMode" />
363
+ </span></el-tooltip>
317
364
  </div>
318
365
  <FlowCanvas
319
366
  id="appium-flow-dialog"
@@ -321,9 +368,11 @@ function updateStep(index: number, step: AppiumRecordedStep) {
321
368
  :steps="steps"
322
369
  :expanded-step-index="expandedStepIndex"
323
370
  :copy-mode="copyMode"
371
+ :delete-mode="deleteMode"
324
372
  :selected-copy-indexes="selectedCopyIndexes"
325
373
  :disabled="disabled"
326
374
  :remove-disabled="removeDisabled"
375
+ :merge-disabled="mergeDisabled"
327
376
  :launching-step-id="launchingStepId"
328
377
  :ai-recognition-model-configured="aiRecognitionModelConfigured"
329
378
  :clipboard-count="clipboardCount"
@@ -346,7 +395,15 @@ function updateStep(index: number, step: AppiumRecordedStep) {
346
395
  @preview-linked-script="(index) => emit('previewLinkedScript', index)"
347
396
  @execute-step="(index) => emit('executeStep', index)"
348
397
  @update-step="(index, step) => updateStep(index, step)"
398
+ @merge="mergeConditionId = steps[$event]?.id || ''"
349
399
  />
350
400
  </el-dialog>
401
+ <MergeBranchesDialog :steps="steps" :condition-id="mergeConditionId" :disabled="mergeDisabled"
402
+ @close="mergeConditionId = ''" @confirm="expandedStepIndex = null; emit('replaceSteps', $event)" />
351
403
  </div>
352
404
  </template>
405
+
406
+ <style scoped>
407
+ .flow-toolbar-icon { display: inline-flex; }
408
+ .flow-toolbar-icon .el-button { width: 30px; height: 30px; padding: 0; }
409
+ </style>
@@ -0,0 +1,15 @@
1
+ import type { InjectionKey, Ref } from 'vue';
2
+
3
+ export const DEFAULT_FLOW_BACKGROUND = '#d4e8dd';
4
+ export const FLOW_BACKGROUND_PRESETS = ['#d4e8dd', '#d5e6f5', '#e4dcf2', '#f2dedc', '#d5eae9', '#e8e4cc'];
5
+ export const flowBackgroundKey: InjectionKey<Readonly<Ref<string>>> = Symbol('flowBackground');
6
+
7
+ export function isHexColor(value: unknown): value is string {
8
+ return typeof value === 'string' && /^#(?:[\da-f]{3}|[\da-f]{6})$/i.test(value.trim());
9
+ }
10
+
11
+ export function normalizeFlowBackground(value: unknown) {
12
+ if (!isHexColor(value)) return DEFAULT_FLOW_BACKGROUND;
13
+ const color = value.trim().toLowerCase();
14
+ return color.length === 4 ? '#' + [...color.slice(1)].map((char) => char + char).join('') : color;
15
+ }
@@ -24,9 +24,12 @@ export type InsertAction =
24
24
  | 'swipe'
25
25
  | 'pinch'
26
26
  | 'launchApp'
27
+ | 'openGallery'
28
+ | 'endFlow'
27
29
  | 'clearAppData'
28
30
  | 'popupCondition'
29
31
  | 'checkboxState'
32
+ | 'checkedState'
30
33
  | 'radioButtonState'
31
34
  | 'aiRecognition'
32
35
  | 'textClick'
@@ -87,8 +90,10 @@ export type FlowGraphNodeData =
87
90
  expanded: boolean;
88
91
  selected: boolean;
89
92
  copyMode: boolean;
93
+ deleteMode?: boolean;
90
94
  disabled?: boolean;
91
95
  removeDisabled?: boolean;
96
+ mergeDisabled?: boolean;
92
97
  launching?: boolean;
93
98
  canCopy: boolean;
94
99
  canEditInput: boolean;
@@ -130,8 +135,10 @@ type BuildFlowGraphOptions = {
130
135
  expandedStepIndex: number | null;
131
136
  selectedCopyIndexes: number[];
132
137
  copyMode: boolean;
138
+ deleteMode?: boolean;
133
139
  disabled?: boolean;
134
140
  removeDisabled?: boolean;
141
+ mergeDisabled?: boolean;
135
142
  launchingStepId?: string;
136
143
  clipboardCount?: number;
137
144
  canOpenInsertMenu: boolean;
@@ -231,7 +238,7 @@ function branchConnectionTargetId(items: StepItem[], condition: AppiumRecordedSt
231
238
  }
232
239
  const targetId = branch === 'yes' ? condition.flow?.yesTargetId : condition.flow?.noTargetId;
233
240
  const target = items.find(({ step }) => step.id === targetId)?.step;
234
- return target && !target.flow?.parentConditionId ? target.id : '';
241
+ return target && target.flow?.parentConditionId === condition.flow?.parentConditionId ? target.id : '';
235
242
  }
236
243
 
237
244
  function estimateTextLines(text: string | undefined, charsPerLine: number) {
@@ -421,10 +428,12 @@ export function buildFlowGraph(
421
428
  expanded: options.expandedStepIndex === item.index,
422
429
  selected: options.isCopySelected(item.index),
423
430
  copyMode: options.copyMode,
431
+ deleteMode: options.deleteMode,
424
432
  disabled: item.step.type === 'launchApp' || item.step.type === 'clearAppData'
425
433
  ? options.isAppExecutionDisabled(item.step.type)
426
434
  : item.step.type === 'aiRecognition' ? options.isInsertActionDisabled('aiRecognition') : options.disabled,
427
435
  removeDisabled: options.removeDisabled,
436
+ mergeDisabled: options.mergeDisabled,
428
437
  launching: options.launchingStepId === item.step.id,
429
438
  canCopy: item.step.type !== 'launchApp'
430
439
  && item.step.type !== 'clearAppData'
@@ -542,7 +551,8 @@ export function buildFlowGraph(
542
551
 
543
552
  const targetId = item.step.flow?.successTargetId;
544
553
  const explicitTarget = targetId ? stepById.get(targetId) : undefined;
545
- const explicitMainTarget = explicitTarget && !explicitTarget.step.flow?.parentConditionId
554
+ const explicitMainTarget = explicitTarget && (!explicitTarget.step.flow?.parentConditionId
555
+ || explicitTarget.step.flow.parentConditionId !== item.step.flow?.parentConditionId)
546
556
  ? explicitTarget
547
557
  : undefined;
548
558
  const target = explicitMainTarget || nextSibling;
@@ -733,11 +743,29 @@ export function buildFlowGraph(
733
743
  const orderedLinks = [...links].sort((a, b) => (
734
744
  (nodeOrder.get(a.source) || 0) - (nodeOrder.get(b.source) || 0)
735
745
  ));
746
+ // 公共起点从两侧汇入,不能被最后一条分支连线拉到左列或右列。
747
+ const joins = new Map<string, string>();
748
+ items.forEach(({ step }) => {
749
+ const target = step.flow?.successTargetId && stepById.get(step.flow.successTargetId);
750
+ if (defaultKind(step) === 'condition' && target && !joins.has(nodeIdForStep(target.step))) {
751
+ joins.set(nodeIdForStep(target.step), nodeIdForStep(step));
752
+ }
753
+ });
736
754
  const alignVisibleChainGeometry = () => {
737
755
  orderedLinks.forEach((link) => {
738
756
  const source = nodeById.get(link.source);
739
757
  const target = nodeById.get(link.target);
740
758
  if (!source || !target?.data) return;
759
+ const joinOwner = joins.get(target.id);
760
+ if (joinOwner && target.id !== joinOwner) {
761
+ const incoming = links.filter((edge) => edge.target === target.id).map((edge) => nodeById.get(edge.source)!).filter(Boolean);
762
+ const owner = nodeById.get(joinOwner);
763
+ if (owner && incoming.length) {
764
+ target.position.y = Math.round(Math.max(...incoming.map((node) => node.position.y + nodeHeight(node))) + FLOW_STANDARD_LINE_GAP * 2);
765
+ setCenterX(target, centerX(owner));
766
+ }
767
+ return;
768
+ }
741
769
  const kind = target.data.kind;
742
770
  if (!['branch', 'split', 'insert'].includes(kind) && !canAlignStepAfterFlowPoint(source, target)) return;
743
771
 
@@ -777,7 +805,7 @@ export function buildFlowGraph(
777
805
  selectable: false,
778
806
  focusable: false,
779
807
  class: 'appium-vue-flow-edge',
780
- data: { branch: link.branch },
808
+ data: { branch: link.branch, merge: joins.has(link.target) },
781
809
  }));
782
810
 
783
811
  return { nodes: positionedNodes, edges };