android-midscene-automation 0.1.19 → 0.1.21

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.
@@ -1,13 +1,23 @@
1
1
  <script setup lang="ts">
2
2
  import { computed, reactive, shallowRef, watch } from 'vue';
3
- import { Delete, Edit, FullScreen, Link, Timer, VideoPlay } from '@element-plus/icons-vue';
3
+ import { ElMessage } from 'element-plus';
4
+ import { CopyDocument, Delete, Edit, FullScreen, Link, Timer, VideoPlay } from '@element-plus/icons-vue';
4
5
  import type { AppiumRecordedStep } from '../types';
6
+ import { createFlowClipboard } from '../flow-copy';
7
+ import {
8
+ buildConditionLayouts,
9
+ FLOW_BRANCH_GAP,
10
+ FLOW_NODE_WIDTH,
11
+ placeConditionLayout,
12
+ } from '../flow-layout';
5
13
  import FlowStepEditor from './FlowStepEditor.vue';
14
+ import NestedConditionBranches from './NestedConditionBranches.vue';
6
15
 
7
16
  type FlowKind = 'action' | 'condition' | 'assertion';
8
17
  type BranchName = 'yes' | 'no';
9
18
  type FlowViewport = 'main' | 'overview';
10
19
  type StepItem = { step: AppiumRecordedStep; index: number };
20
+ const PASTE_COMMAND = '__paste_flow_nodes__';
11
21
  type InsertAction =
12
22
  | 'delay'
13
23
  | 'tap'
@@ -39,10 +49,13 @@ const props = defineProps<{
39
49
  disabled?: boolean;
40
50
  allowedLockedActions?: InsertAction[];
41
51
  launchingStepId?: string;
52
+ clipboardCount?: number;
42
53
  }>();
43
54
 
44
55
  const emit = defineEmits<{
45
56
  remove: [index: number];
57
+ copy: [indexes: number[]];
58
+ paste: [index: number, branch?: BranchName];
46
59
  addDelay: [index?: number];
47
60
  insertAction: [index: number, action: InsertAction];
48
61
  insertBranchAction: [index: number, branch: BranchName, action: InsertAction];
@@ -54,6 +67,8 @@ const emit = defineEmits<{
54
67
  }>();
55
68
 
56
69
  const expandedStepIndex = shallowRef<number | null>(null);
70
+ const copyMode = shallowRef(false);
71
+ const selectedCopyIndexes = shallowRef<number[]>([]);
57
72
  const flowDialogVisible = shallowRef(false);
58
73
  const flowPan = reactive({ x: 0, y: 0 });
59
74
  const flowScale = shallowRef(1);
@@ -79,6 +94,11 @@ const visibleStepItems = computed(() => stepItems.value
79
94
  .filter(({ step }) => !branchTargetIds.value.has(step.id)));
80
95
  const hasFlowSteps = computed(() => visibleStepItems.value.length > 0);
81
96
  const hasLaunchAppStep = computed(() => props.steps.some((step) => step.type === 'launchApp'));
97
+ const conditionLayouts = computed(() => buildConditionLayouts(props.steps));
98
+ const flowContentWidth = computed(() => Math.max(
99
+ 980,
100
+ ...visibleStepItems.value.map(({ step }) => conditionLayouts.value.get(step.id)?.totalWidth || 0),
101
+ ));
82
102
  const flowAxisByStepId = computed(() => {
83
103
  const axes = new Map<string, number>();
84
104
  let inheritedAxis = 50;
@@ -86,8 +106,15 @@ const flowAxisByStepId = computed(() => {
86
106
  const incomingAxes: number[] = [];
87
107
  visibleStepItems.value.forEach(({ step: condition }) => {
88
108
  if (defaultKind(condition) !== 'condition') return;
89
- if (branchConnectionTargetId(condition, 'yes') === step.id) incomingAxes.push(25);
90
- if (branchConnectionTargetId(condition, 'no') === step.id) incomingAxes.push(75);
109
+ const layout = conditionLayouts.value.get(condition.id);
110
+ if (!layout) return;
111
+ const placement = placeConditionLayout(
112
+ layout,
113
+ flowContentWidth.value,
114
+ axes.get(condition.id) ?? inheritedAxis,
115
+ );
116
+ if (branchConnectionTargetId(condition, 'yes') === step.id) incomingAxes.push(placement.yesAxis);
117
+ if (branchConnectionTargetId(condition, 'no') === step.id) incomingAxes.push(placement.noAxis);
91
118
  });
92
119
  if (incomingAxes.length) {
93
120
  inheritedAxis = incomingAxes.reduce((total, axis) => total + axis, 0) / incomingAxes.length;
@@ -221,7 +248,7 @@ function targetLabel(id?: string) {
221
248
  if (!id) return '结束';
222
249
  const index = props.steps.findIndex((step) => step.id === id);
223
250
  if (index < 0) return '未找到目标';
224
- return `${index + 1}. ${props.steps[index].label}`;
251
+ return props.steps[index].label;
225
252
  }
226
253
 
227
254
  function branchStepItems(step: AppiumRecordedStep, branch: BranchName) {
@@ -269,21 +296,51 @@ function flowAxisStyle(step: AppiumRecordedStep) {
269
296
  return { '--flow-axis': `${flowAxis(step)}%` };
270
297
  }
271
298
 
299
+ function conditionLayout(step: AppiumRecordedStep) {
300
+ return conditionLayouts.value.get(step.id) || {
301
+ yesWidth: FLOW_NODE_WIDTH,
302
+ noWidth: FLOW_NODE_WIDTH,
303
+ totalWidth: FLOW_NODE_WIDTH * 2 + FLOW_BRANCH_GAP,
304
+ yesAxis: 25,
305
+ noAxis: 75,
306
+ };
307
+ }
308
+
309
+ function branchAxis(step: AppiumRecordedStep, branch: BranchName) {
310
+ const layout = conditionLayout(step);
311
+ const placement = placeConditionLayout(layout, flowContentWidth.value, flowAxis(step));
312
+ return branch === 'yes' ? placement.yesAxis : placement.noAxis;
313
+ }
314
+
315
+ function branchLayoutStyle(step: AppiumRecordedStep) {
316
+ const layout = conditionLayout(step);
317
+ const placement = placeConditionLayout(layout, flowContentWidth.value, flowAxis(step));
318
+ return {
319
+ width: `${layout.totalWidth}px`,
320
+ minWidth: `${layout.totalWidth}px`,
321
+ gridTemplateColumns: `${layout.yesWidth}px ${layout.noWidth}px`,
322
+ justifySelf: 'start',
323
+ marginLeft: `${placement.left}px`,
324
+ };
325
+ }
326
+
272
327
  function branchPath(step: AppiumRecordedStep, branch: BranchName) {
273
- const targetAxis = branch === 'yes' ? 25 : 75;
274
- return `M${flowAxis(step)} 0 C${flowAxis(step)} 16 ${targetAxis} 16 ${targetAxis} 31 L${targetAxis} 48`;
328
+ const layout = conditionLayout(step);
329
+ const placement = placeConditionLayout(layout, flowContentWidth.value, flowAxis(step));
330
+ const targetAxis = branch === 'yes' ? layout.yesAxis : layout.noAxis;
331
+ return `M${placement.startAxis} 0 C${placement.startAxis} 16 ${targetAxis} 16 ${targetAxis} 31 L${targetAxis} 48`;
275
332
  }
276
333
 
277
334
  function mergeTargetAxis(step: AppiumRecordedStep) {
278
335
  const axes = [
279
- branchConnectionTargetId(step, 'yes') ? 25 : 0,
280
- branchConnectionTargetId(step, 'no') ? 75 : 0,
336
+ branchConnectionTargetId(step, 'yes') ? branchAxis(step, 'yes') : 0,
337
+ branchConnectionTargetId(step, 'no') ? branchAxis(step, 'no') : 0,
281
338
  ].filter(Boolean);
282
339
  return axes.reduce((total, axis) => total + axis, 0) / axes.length;
283
340
  }
284
341
 
285
342
  function mergePath(step: AppiumRecordedStep, branch: BranchName) {
286
- const sourceAxis = branch === 'yes' ? 25 : 75;
343
+ const sourceAxis = branchAxis(step, branch);
287
344
  const targetAxis = mergeTargetAxis(step);
288
345
  return `M${sourceAxis} 0 C${sourceAxis} 20 ${targetAxis} 20 ${targetAxis} 44`;
289
346
  }
@@ -300,15 +357,90 @@ function toggleExpanded(index: number) {
300
357
  expandedStepIndex.value = expandedStepIndex.value === index ? null : index;
301
358
  }
302
359
 
360
+ function isDescendantStep(descendantIndex: number, ancestorIndex: number) {
361
+ const ancestorId = props.steps[ancestorIndex]?.id;
362
+ let parentId = props.steps[descendantIndex]?.flow?.parentConditionId;
363
+ while (ancestorId && parentId) {
364
+ if (parentId === ancestorId) return true;
365
+ parentId = props.steps.find((item) => item.id === parentId)?.flow?.parentConditionId;
366
+ }
367
+ return false;
368
+ }
369
+
370
+ function isCopySelected(index: number) {
371
+ return selectedCopyIndexes.value.includes(index)
372
+ || selectedCopyIndexes.value.some((selectedIndex) => isDescendantStep(index, selectedIndex));
373
+ }
374
+
375
+ function toggleCopySelection(index: number) {
376
+ const step = props.steps[index];
377
+ if (!step) return;
378
+ if (step.type === 'launchApp') {
379
+ ElMessage.warning('启动 APP 节点不能复制');
380
+ return;
381
+ }
382
+ const current = selectedCopyIndexes.value;
383
+ if (current.includes(index)) {
384
+ selectedCopyIndexes.value = current.filter((item) => item !== index);
385
+ return;
386
+ }
387
+ if (current.some((item) => isDescendantStep(index, item))) {
388
+ ElMessage.info('该子节点已随判断节点选中');
389
+ return;
390
+ }
391
+ const effectiveCurrent = current.filter((item) => !isDescendantStep(item, index));
392
+ const candidate = [...effectiveCurrent, index].sort((left, right) => left - right);
393
+ try {
394
+ createFlowClipboard(props.steps, candidate);
395
+ } catch (error) {
396
+ ElMessage.warning(error instanceof Error ? error.message : '当前节点不能一起复制');
397
+ return;
398
+ }
399
+ selectedCopyIndexes.value = candidate;
400
+ }
401
+
402
+ function startCopyMode() {
403
+ copyMode.value = true;
404
+ selectedCopyIndexes.value = [];
405
+ expandedStepIndex.value = null;
406
+ }
407
+
408
+ function cancelCopyMode() {
409
+ copyMode.value = false;
410
+ selectedCopyIndexes.value = [];
411
+ }
412
+
413
+ function copySelectedNodes() {
414
+ if (!selectedCopyIndexes.value.length) return;
415
+ emit('copy', selectedCopyIndexes.value);
416
+ cancelCopyMode();
417
+ }
418
+
419
+ function copySingleNode(index: number) {
420
+ emit('copy', [index]);
421
+ }
422
+
303
423
  function insertAction(index: number, command: string | number | object) {
424
+ if (command === PASTE_COMMAND) {
425
+ emit('paste', index);
426
+ return;
427
+ }
304
428
  emit('insertAction', index, command as InsertAction);
305
429
  }
306
430
 
307
431
  function insertStartAction(command: string | number | object) {
432
+ if (command === PASTE_COMMAND) {
433
+ emit('paste', -1);
434
+ return;
435
+ }
308
436
  emit('insertAction', -1, command as InsertAction);
309
437
  }
310
438
 
311
439
  function insertBranchAction(index: number, branch: BranchName, command: string | number | object) {
440
+ if (command === PASTE_COMMAND) {
441
+ emit('paste', index, branch);
442
+ return;
443
+ }
312
444
  emit('insertBranchAction', index, branch, command as InsertAction);
313
445
  }
314
446
 
@@ -374,6 +506,10 @@ function handleFlowWheel(event: WheelEvent, viewport: FlowViewport) {
374
506
 
375
507
  function handleNodeClick(index: number) {
376
508
  if (flowDragMoved.value) return;
509
+ if (copyMode.value) {
510
+ toggleCopySelection(index);
511
+ return;
512
+ }
377
513
  toggleExpanded(index);
378
514
  }
379
515
 
@@ -421,6 +557,28 @@ watch(() => props.steps.length, () => {
421
557
  <template>
422
558
  <div class="appium-recorded-steps-panel">
423
559
  <div class="appium-flow-toolbar">
560
+ <template v-if="copyMode">
561
+ <span class="appium-flow-toolbar__status">已选择 {{ selectedCopyIndexes.length }} 个节点</span>
562
+ <el-button
563
+ size="small"
564
+ type="primary"
565
+ :disabled="!selectedCopyIndexes.length"
566
+ :icon="CopyDocument"
567
+ @click="copySelectedNodes"
568
+ >
569
+ 复制选中
570
+ </el-button>
571
+ <el-button size="small" @click="cancelCopyMode">取消</el-button>
572
+ </template>
573
+ <el-button
574
+ v-else
575
+ size="small"
576
+ :icon="CopyDocument"
577
+ :disabled="!steps.length"
578
+ @click="startCopyMode"
579
+ >
580
+ 批量复制
581
+ </el-button>
424
582
  <el-button
425
583
  size="small"
426
584
  :icon="FullScreen"
@@ -444,6 +602,8 @@ watch(() => props.steps.length, () => {
444
602
  class="appium-flow-content"
445
603
  :style="{
446
604
  transform: `translate(${flowPan.x}px, ${flowPan.y}px) scale(${flowScale})`,
605
+ width: `${flowContentWidth}px`,
606
+ minWidth: `${flowContentWidth}px`,
447
607
  '--appium-flow-line-width': '3px',
448
608
  }"
449
609
  >
@@ -475,6 +635,13 @@ watch(() => props.steps.length, () => {
475
635
  </button>
476
636
  <template #dropdown>
477
637
  <el-dropdown-menu>
638
+ <el-dropdown-item
639
+ v-if="clipboardCount"
640
+ :command="PASTE_COMMAND"
641
+ divided
642
+ >
643
+ 粘贴 {{ clipboardCount }} 个节点
644
+ </el-dropdown-item>
478
645
  <template v-for="group in startActionGroups" :key="group.title">
479
646
  <div class="appium-action-dropdown__group">{{ group.title }}</div>
480
647
  <el-dropdown-item
@@ -510,7 +677,11 @@ watch(() => props.steps.length, () => {
510
677
  class="appium-flow-node"
511
678
  :class="[
512
679
  `appium-flow-node--${defaultKind(step)}`,
513
- { 'appium-flow-node--expanded': expandedStepIndex === index },
680
+ {
681
+ 'appium-flow-node--expanded': expandedStepIndex === index,
682
+ 'appium-flow-node--selected': isCopySelected(index),
683
+ 'appium-flow-node--copying': copyMode,
684
+ },
514
685
  ]"
515
686
  >
516
687
  <button
@@ -519,7 +690,6 @@ watch(() => props.steps.length, () => {
519
690
  :aria-expanded="expandedStepIndex === index"
520
691
  @click.stop="handleNodeClick(index)"
521
692
  >
522
- <span class="appium-flow-node__index">{{ index + 1 }}</span>
523
693
  <span class="appium-flow-node__body">
524
694
  <span class="appium-flow-node__title" :title="step.label">{{ step.label }}</span>
525
695
  <span class="appium-flow-node__meta" :title="stepMeta(step)">
@@ -531,6 +701,14 @@ watch(() => props.steps.length, () => {
531
701
  </span>
532
702
  </button>
533
703
  <span class="appium-flow-node__actions" @click.stop>
704
+ <el-button
705
+ v-if="!copyMode && step.type !== 'launchApp'"
706
+ text
707
+ size="small"
708
+ :icon="CopyDocument"
709
+ title="复制节点"
710
+ @click="copySingleNode(index)"
711
+ />
534
712
  <el-button
535
713
  v-if="step.type === 'launchApp'"
536
714
  text
@@ -561,7 +739,11 @@ watch(() => props.steps.length, () => {
561
739
  </span>
562
740
  </div>
563
741
 
564
- <div v-if="defaultKind(step) === 'condition'" class="appium-flow-branches">
742
+ <div
743
+ v-if="defaultKind(step) === 'condition'"
744
+ class="appium-flow-branches"
745
+ :style="branchLayoutStyle(step)"
746
+ >
565
747
  <svg class="appium-flow-branch-lines" viewBox="0 0 100 48" preserveAspectRatio="none" aria-hidden="true">
566
748
  <path :d="branchPath(step, 'yes')" />
567
749
  <path :d="branchPath(step, 'no')" />
@@ -589,7 +771,11 @@ watch(() => props.steps.length, () => {
589
771
  class="appium-flow-node appium-flow-branch-step"
590
772
  :class="[
591
773
  `appium-flow-node--${defaultKind(item.step)}`,
592
- { 'appium-flow-node--expanded': expandedStepIndex === item.index },
774
+ {
775
+ 'appium-flow-node--expanded': expandedStepIndex === item.index,
776
+ 'appium-flow-node--selected': isCopySelected(item.index),
777
+ 'appium-flow-node--copying': copyMode,
778
+ },
593
779
  ]"
594
780
  >
595
781
  <button
@@ -598,7 +784,6 @@ watch(() => props.steps.length, () => {
598
784
  :aria-expanded="expandedStepIndex === item.index"
599
785
  @click.stop="handleNodeClick(item.index)"
600
786
  >
601
- <span class="appium-flow-node__index">{{ item.index + 1 }}</span>
602
787
  <span class="appium-flow-node__body">
603
788
  <span class="appium-flow-node__title">{{ item.step.label }}</span>
604
789
  <span class="appium-flow-node__meta">
@@ -609,6 +794,14 @@ watch(() => props.steps.length, () => {
609
794
  </span>
610
795
  </button>
611
796
  <span class="appium-flow-node__actions" @click.stop>
797
+ <el-button
798
+ v-if="!copyMode"
799
+ text
800
+ size="small"
801
+ :icon="CopyDocument"
802
+ title="复制节点"
803
+ @click="copySingleNode(item.index)"
804
+ />
612
805
  <el-button
613
806
  text
614
807
  size="small"
@@ -626,6 +819,27 @@ watch(() => props.steps.length, () => {
626
819
  :disabled="disabled"
627
820
  @update="updateStep"
628
821
  />
822
+ <NestedConditionBranches
823
+ v-if="defaultKind(item.step) === 'condition'"
824
+ :steps="steps"
825
+ :condition="item.step"
826
+ :condition-index="item.index"
827
+ :expanded-step-index="expandedStepIndex"
828
+ :copy-mode="copyMode"
829
+ :selected-copy-indexes="selectedCopyIndexes"
830
+ :disabled="disabled"
831
+ :allowed-locked-actions="allowedLockedActions"
832
+ :clipboard-count="clipboardCount"
833
+ @node-click="handleNodeClick"
834
+ @copy="$emit('copy', $event)"
835
+ @remove="$emit('remove', $event)"
836
+ @paste="(childIndex, childBranch) => $emit('paste', childIndex, childBranch)"
837
+ @insert-branch-action="(childIndex, childBranch, action) => $emit('insertBranchAction', childIndex, childBranch, action)"
838
+ @connect-next="(childIndex, childBranch) => $emit('connectNext', childIndex, childBranch)"
839
+ @disconnect-next="(childIndex, childBranch) => $emit('disconnectNext', childIndex, childBranch)"
840
+ @edit-input="$emit('editInput', $event)"
841
+ @update-step="(childIndex, childStep) => $emit('updateStep', childIndex, childStep)"
842
+ />
629
843
  </div>
630
844
  </div>
631
845
  <span
@@ -653,6 +867,13 @@ watch(() => props.steps.length, () => {
653
867
  </el-button>
654
868
  <template #dropdown>
655
869
  <el-dropdown-menu>
870
+ <el-dropdown-item
871
+ v-if="clipboardCount"
872
+ :command="PASTE_COMMAND"
873
+ divided
874
+ >
875
+ 粘贴 {{ clipboardCount }} 个节点
876
+ </el-dropdown-item>
656
877
  <template v-for="group in mainActionGroups" :key="group.title">
657
878
  <div class="appium-action-dropdown__group">{{ group.title }}</div>
658
879
  <el-dropdown-item
@@ -703,7 +924,11 @@ watch(() => props.steps.length, () => {
703
924
  class="appium-flow-node appium-flow-branch-step"
704
925
  :class="[
705
926
  `appium-flow-node--${defaultKind(item.step)}`,
706
- { 'appium-flow-node--expanded': expandedStepIndex === item.index },
927
+ {
928
+ 'appium-flow-node--expanded': expandedStepIndex === item.index,
929
+ 'appium-flow-node--selected': isCopySelected(item.index),
930
+ 'appium-flow-node--copying': copyMode,
931
+ },
707
932
  ]"
708
933
  >
709
934
  <button
@@ -712,7 +937,6 @@ watch(() => props.steps.length, () => {
712
937
  :aria-expanded="expandedStepIndex === item.index"
713
938
  @click.stop="handleNodeClick(item.index)"
714
939
  >
715
- <span class="appium-flow-node__index">{{ item.index + 1 }}</span>
716
940
  <span class="appium-flow-node__body">
717
941
  <span class="appium-flow-node__title">{{ item.step.label }}</span>
718
942
  <span class="appium-flow-node__meta">
@@ -723,6 +947,14 @@ watch(() => props.steps.length, () => {
723
947
  </span>
724
948
  </button>
725
949
  <span class="appium-flow-node__actions" @click.stop>
950
+ <el-button
951
+ v-if="!copyMode"
952
+ text
953
+ size="small"
954
+ :icon="CopyDocument"
955
+ title="复制节点"
956
+ @click="copySingleNode(item.index)"
957
+ />
726
958
  <el-button
727
959
  text
728
960
  size="small"
@@ -740,6 +972,27 @@ watch(() => props.steps.length, () => {
740
972
  :disabled="disabled"
741
973
  @update="updateStep"
742
974
  />
975
+ <NestedConditionBranches
976
+ v-if="defaultKind(item.step) === 'condition'"
977
+ :steps="steps"
978
+ :condition="item.step"
979
+ :condition-index="item.index"
980
+ :expanded-step-index="expandedStepIndex"
981
+ :copy-mode="copyMode"
982
+ :selected-copy-indexes="selectedCopyIndexes"
983
+ :disabled="disabled"
984
+ :allowed-locked-actions="allowedLockedActions"
985
+ :clipboard-count="clipboardCount"
986
+ @node-click="handleNodeClick"
987
+ @copy="$emit('copy', $event)"
988
+ @remove="$emit('remove', $event)"
989
+ @paste="(childIndex, childBranch) => $emit('paste', childIndex, childBranch)"
990
+ @insert-branch-action="(childIndex, childBranch, action) => $emit('insertBranchAction', childIndex, childBranch, action)"
991
+ @connect-next="(childIndex, childBranch) => $emit('connectNext', childIndex, childBranch)"
992
+ @disconnect-next="(childIndex, childBranch) => $emit('disconnectNext', childIndex, childBranch)"
993
+ @edit-input="$emit('editInput', $event)"
994
+ @update-step="(childIndex, childStep) => $emit('updateStep', childIndex, childStep)"
995
+ />
743
996
  </div>
744
997
  </div>
745
998
  <span
@@ -767,6 +1020,13 @@ watch(() => props.steps.length, () => {
767
1020
  </el-button>
768
1021
  <template #dropdown>
769
1022
  <el-dropdown-menu>
1023
+ <el-dropdown-item
1024
+ v-if="clipboardCount"
1025
+ :command="PASTE_COMMAND"
1026
+ divided
1027
+ >
1028
+ 粘贴 {{ clipboardCount }} 个节点
1029
+ </el-dropdown-item>
770
1030
  <template v-for="group in mainActionGroups" :key="group.title">
771
1031
  <div class="appium-action-dropdown__group">{{ group.title }}</div>
772
1032
  <el-dropdown-item
@@ -837,6 +1097,13 @@ watch(() => props.steps.length, () => {
837
1097
  </button>
838
1098
  <template #dropdown>
839
1099
  <el-dropdown-menu>
1100
+ <el-dropdown-item
1101
+ v-if="clipboardCount"
1102
+ :command="PASTE_COMMAND"
1103
+ divided
1104
+ >
1105
+ 粘贴 {{ clipboardCount }} 个节点
1106
+ </el-dropdown-item>
840
1107
  <template v-for="group in mainActionGroups" :key="group.title">
841
1108
  <div class="appium-action-dropdown__group">{{ group.title }}</div>
842
1109
  <el-dropdown-item
@@ -861,6 +1128,30 @@ watch(() => props.steps.length, () => {
861
1128
  class="appium-flow-dialog"
862
1129
  append-to-body
863
1130
  >
1131
+ <div class="appium-flow-toolbar appium-flow-dialog__toolbar">
1132
+ <template v-if="copyMode">
1133
+ <span class="appium-flow-toolbar__status">已选择 {{ selectedCopyIndexes.length }} 个节点</span>
1134
+ <el-button
1135
+ size="small"
1136
+ type="primary"
1137
+ :disabled="!selectedCopyIndexes.length"
1138
+ :icon="CopyDocument"
1139
+ @click="copySelectedNodes"
1140
+ >
1141
+ 复制选中
1142
+ </el-button>
1143
+ <el-button size="small" @click="cancelCopyMode">取消</el-button>
1144
+ </template>
1145
+ <el-button
1146
+ v-else
1147
+ size="small"
1148
+ :icon="CopyDocument"
1149
+ :disabled="!steps.length"
1150
+ @click="startCopyMode"
1151
+ >
1152
+ 批量复制
1153
+ </el-button>
1154
+ </div>
864
1155
  <div
865
1156
  class="appium-flow-dialog-canvas"
866
1157
  :class="{ 'appium-flow-dialog-canvas--dragging': draggingViewport === 'overview' }"
@@ -875,6 +1166,8 @@ watch(() => props.steps.length, () => {
875
1166
  class="appium-flow-content appium-flow-content--overview"
876
1167
  :style="{
877
1168
  transform: `translate(${overviewPan.x}px, ${overviewPan.y}px) scale(${overviewScale})`,
1169
+ width: `${flowContentWidth}px`,
1170
+ minWidth: `${flowContentWidth}px`,
878
1171
  '--appium-flow-line-width': '3px',
879
1172
  }"
880
1173
  >
@@ -889,6 +1182,40 @@ watch(() => props.steps.length, () => {
889
1182
  <path d="M5 0 V100" />
890
1183
  </svg>
891
1184
  <div class="appium-flow-start__node">开始</div>
1185
+ <el-dropdown
1186
+ trigger="click"
1187
+ :disabled="!canOpenInsertMenu()"
1188
+ max-height="320px"
1189
+ popper-class="appium-action-dropdown"
1190
+ @command="insertStartAction($event)"
1191
+ >
1192
+ <button
1193
+ type="button"
1194
+ class="appium-flow-insert"
1195
+ :disabled="!canOpenInsertMenu()"
1196
+ >
1197
+ <el-icon><Timer /></el-icon>
1198
+ <span>插入操作</span>
1199
+ </button>
1200
+ <template #dropdown>
1201
+ <el-dropdown-menu>
1202
+ <el-dropdown-item v-if="clipboardCount" :command="PASTE_COMMAND" divided>
1203
+ 粘贴 {{ clipboardCount }} 个节点
1204
+ </el-dropdown-item>
1205
+ <template v-for="group in startActionGroups" :key="group.title">
1206
+ <div class="appium-action-dropdown__group">{{ group.title }}</div>
1207
+ <el-dropdown-item
1208
+ v-for="action in group.actions"
1209
+ :key="action.type"
1210
+ :command="action.type"
1211
+ :disabled="isStartActionDisabled(action.type)"
1212
+ >
1213
+ {{ action.label }}
1214
+ </el-dropdown-item>
1215
+ </template>
1216
+ </el-dropdown-menu>
1217
+ </template>
1218
+ </el-dropdown>
892
1219
  </div>
893
1220
  <div
894
1221
  v-for="{ step, index } in visibleStepItems"
@@ -910,7 +1237,11 @@ watch(() => props.steps.length, () => {
910
1237
  class="appium-flow-node"
911
1238
  :class="[
912
1239
  `appium-flow-node--${defaultKind(step)}`,
913
- { 'appium-flow-node--expanded': expandedStepIndex === index },
1240
+ {
1241
+ 'appium-flow-node--expanded': expandedStepIndex === index,
1242
+ 'appium-flow-node--selected': isCopySelected(index),
1243
+ 'appium-flow-node--copying': copyMode,
1244
+ },
914
1245
  ]"
915
1246
  >
916
1247
  <button
@@ -919,7 +1250,6 @@ watch(() => props.steps.length, () => {
919
1250
  :aria-expanded="expandedStepIndex === index"
920
1251
  @click="handleNodeClick(index)"
921
1252
  >
922
- <span class="appium-flow-node__index">{{ index + 1 }}</span>
923
1253
  <span class="appium-flow-node__body">
924
1254
  <span class="appium-flow-node__title" :title="step.label">{{ step.label }}</span>
925
1255
  <span class="appium-flow-node__meta" :title="stepMeta(step)">
@@ -930,6 +1260,43 @@ watch(() => props.steps.length, () => {
930
1260
  <span v-if="step.note" class="appium-flow-node__note" :title="step.note">{{ step.note }}</span>
931
1261
  </span>
932
1262
  </button>
1263
+ <span class="appium-flow-node__actions" @click.stop>
1264
+ <el-button
1265
+ v-if="!copyMode && step.type !== 'launchApp'"
1266
+ text
1267
+ size="small"
1268
+ :icon="CopyDocument"
1269
+ title="复制节点"
1270
+ @click="copySingleNode(index)"
1271
+ />
1272
+ <el-button
1273
+ v-if="step.type === 'launchApp'"
1274
+ text
1275
+ size="small"
1276
+ :icon="VideoPlay"
1277
+ :loading="launchingStepId === step.id"
1278
+ :disabled="isLaunchExecutionDisabled()"
1279
+ title="立即执行启动 App"
1280
+ @click="$emit('executeStep', index)"
1281
+ />
1282
+ <el-button
1283
+ v-if="step.type === 'input' || step.type === 'inputIfExists'"
1284
+ text
1285
+ size="small"
1286
+ :icon="Edit"
1287
+ :disabled="disabled"
1288
+ title="修改输入内容"
1289
+ @click="$emit('editInput', index)"
1290
+ />
1291
+ <el-button
1292
+ text
1293
+ size="small"
1294
+ :icon="Delete"
1295
+ :disabled="disabled"
1296
+ title="删除节点"
1297
+ @click="$emit('remove', index)"
1298
+ />
1299
+ </span>
933
1300
  </div>
934
1301
 
935
1302
  <FlowStepEditor
@@ -940,7 +1307,11 @@ watch(() => props.steps.length, () => {
940
1307
  @update="updateStep"
941
1308
  />
942
1309
 
943
- <div v-if="defaultKind(step) === 'condition'" class="appium-flow-branches">
1310
+ <div
1311
+ v-if="defaultKind(step) === 'condition'"
1312
+ class="appium-flow-branches"
1313
+ :style="branchLayoutStyle(step)"
1314
+ >
944
1315
  <svg class="appium-flow-branch-lines" viewBox="0 0 100 48" preserveAspectRatio="none" aria-hidden="true">
945
1316
  <path :d="branchPath(step, 'yes')" />
946
1317
  <path :d="branchPath(step, 'no')" />
@@ -968,7 +1339,11 @@ watch(() => props.steps.length, () => {
968
1339
  class="appium-flow-node appium-flow-branch-step"
969
1340
  :class="[
970
1341
  `appium-flow-node--${defaultKind(item.step)}`,
971
- { 'appium-flow-node--expanded': expandedStepIndex === item.index },
1342
+ {
1343
+ 'appium-flow-node--expanded': expandedStepIndex === item.index,
1344
+ 'appium-flow-node--selected': isCopySelected(item.index),
1345
+ 'appium-flow-node--copying': copyMode,
1346
+ },
972
1347
  ]"
973
1348
  >
974
1349
  <button
@@ -977,7 +1352,6 @@ watch(() => props.steps.length, () => {
977
1352
  :aria-expanded="expandedStepIndex === item.index"
978
1353
  @click="handleNodeClick(item.index)"
979
1354
  >
980
- <span class="appium-flow-node__index">{{ item.index + 1 }}</span>
981
1355
  <span class="appium-flow-node__body">
982
1356
  <span class="appium-flow-node__title">{{ item.step.label }}</span>
983
1357
  <span class="appium-flow-node__meta">
@@ -987,6 +1361,24 @@ watch(() => props.steps.length, () => {
987
1361
  <span v-if="item.step.note" class="appium-flow-node__note" :title="item.step.note">{{ item.step.note }}</span>
988
1362
  </span>
989
1363
  </button>
1364
+ <span class="appium-flow-node__actions" @click.stop>
1365
+ <el-button
1366
+ v-if="!copyMode"
1367
+ text
1368
+ size="small"
1369
+ :icon="CopyDocument"
1370
+ title="复制节点"
1371
+ @click="copySingleNode(item.index)"
1372
+ />
1373
+ <el-button
1374
+ text
1375
+ size="small"
1376
+ :icon="Delete"
1377
+ :disabled="disabled"
1378
+ title="删除节点"
1379
+ @click="$emit('remove', item.index)"
1380
+ />
1381
+ </span>
990
1382
  </div>
991
1383
  <FlowStepEditor
992
1384
  v-if="expandedStepIndex === item.index"
@@ -995,6 +1387,27 @@ watch(() => props.steps.length, () => {
995
1387
  :disabled="disabled"
996
1388
  @update="updateStep"
997
1389
  />
1390
+ <NestedConditionBranches
1391
+ v-if="defaultKind(item.step) === 'condition'"
1392
+ :steps="steps"
1393
+ :condition="item.step"
1394
+ :condition-index="item.index"
1395
+ :expanded-step-index="expandedStepIndex"
1396
+ :copy-mode="copyMode"
1397
+ :selected-copy-indexes="selectedCopyIndexes"
1398
+ :disabled="disabled"
1399
+ :allowed-locked-actions="allowedLockedActions"
1400
+ :clipboard-count="clipboardCount"
1401
+ @node-click="handleNodeClick"
1402
+ @copy="$emit('copy', $event)"
1403
+ @remove="$emit('remove', $event)"
1404
+ @paste="(childIndex, childBranch) => $emit('paste', childIndex, childBranch)"
1405
+ @insert-branch-action="(childIndex, childBranch, action) => $emit('insertBranchAction', childIndex, childBranch, action)"
1406
+ @connect-next="(childIndex, childBranch) => $emit('connectNext', childIndex, childBranch)"
1407
+ @disconnect-next="(childIndex, childBranch) => $emit('disconnectNext', childIndex, childBranch)"
1408
+ @edit-input="$emit('editInput', $event)"
1409
+ @update-step="(childIndex, childStep) => $emit('updateStep', childIndex, childStep)"
1410
+ />
998
1411
  </div>
999
1412
  </div>
1000
1413
  <span
@@ -1003,6 +1416,53 @@ watch(() => props.steps.length, () => {
1003
1416
  >
1004
1417
  {{ targetLabel(step.flow?.yesTargetId) }}
1005
1418
  </span>
1419
+ <div class="appium-flow-branch__actions">
1420
+ <el-dropdown
1421
+ trigger="click"
1422
+ :disabled="!canOpenInsertMenu()"
1423
+ max-height="320px"
1424
+ popper-class="appium-action-dropdown"
1425
+ @command="insertBranchAction(index, 'yes', $event)"
1426
+ >
1427
+ <el-button
1428
+ size="small"
1429
+ class="appium-flow-branch__insert"
1430
+ :disabled="!canOpenInsertMenu()"
1431
+ :icon="Timer"
1432
+ @click.stop
1433
+ >
1434
+ 插入操作
1435
+ </el-button>
1436
+ <template #dropdown>
1437
+ <el-dropdown-menu>
1438
+ <el-dropdown-item v-if="clipboardCount" :command="PASTE_COMMAND" divided>
1439
+ 粘贴 {{ clipboardCount }} 个节点
1440
+ </el-dropdown-item>
1441
+ <template v-for="group in mainActionGroups" :key="group.title">
1442
+ <div class="appium-action-dropdown__group">{{ group.title }}</div>
1443
+ <el-dropdown-item
1444
+ v-for="action in group.actions"
1445
+ :key="action.type"
1446
+ :command="action.type"
1447
+ :disabled="isInsertActionDisabled(action.type)"
1448
+ >
1449
+ {{ action.label }}
1450
+ </el-dropdown-item>
1451
+ </template>
1452
+ </el-dropdown-menu>
1453
+ </template>
1454
+ </el-dropdown>
1455
+ <el-button
1456
+ v-if="shouldShowBranchConnect(index, step, 'yes')"
1457
+ size="small"
1458
+ class="appium-flow-branch__connect"
1459
+ :icon="Link"
1460
+ :disabled="disabled"
1461
+ @click.stop="toggleBranchConnection(index, step, 'yes')"
1462
+ >
1463
+ {{ branchConnectionTargetId(step, 'yes') ? '取消连接' : '连接到下一节点' }}
1464
+ </el-button>
1465
+ </div>
1006
1466
  </div>
1007
1467
  <div
1008
1468
  class="appium-flow-branch appium-flow-branch--no"
@@ -1027,7 +1487,11 @@ watch(() => props.steps.length, () => {
1027
1487
  class="appium-flow-node appium-flow-branch-step"
1028
1488
  :class="[
1029
1489
  `appium-flow-node--${defaultKind(item.step)}`,
1030
- { 'appium-flow-node--expanded': expandedStepIndex === item.index },
1490
+ {
1491
+ 'appium-flow-node--expanded': expandedStepIndex === item.index,
1492
+ 'appium-flow-node--selected': isCopySelected(item.index),
1493
+ 'appium-flow-node--copying': copyMode,
1494
+ },
1031
1495
  ]"
1032
1496
  >
1033
1497
  <button
@@ -1036,7 +1500,6 @@ watch(() => props.steps.length, () => {
1036
1500
  :aria-expanded="expandedStepIndex === item.index"
1037
1501
  @click="handleNodeClick(item.index)"
1038
1502
  >
1039
- <span class="appium-flow-node__index">{{ item.index + 1 }}</span>
1040
1503
  <span class="appium-flow-node__body">
1041
1504
  <span class="appium-flow-node__title">{{ item.step.label }}</span>
1042
1505
  <span class="appium-flow-node__meta">
@@ -1046,6 +1509,24 @@ watch(() => props.steps.length, () => {
1046
1509
  <span v-if="item.step.note" class="appium-flow-node__note" :title="item.step.note">{{ item.step.note }}</span>
1047
1510
  </span>
1048
1511
  </button>
1512
+ <span class="appium-flow-node__actions" @click.stop>
1513
+ <el-button
1514
+ v-if="!copyMode"
1515
+ text
1516
+ size="small"
1517
+ :icon="CopyDocument"
1518
+ title="复制节点"
1519
+ @click="copySingleNode(item.index)"
1520
+ />
1521
+ <el-button
1522
+ text
1523
+ size="small"
1524
+ :icon="Delete"
1525
+ :disabled="disabled"
1526
+ title="删除节点"
1527
+ @click="$emit('remove', item.index)"
1528
+ />
1529
+ </span>
1049
1530
  </div>
1050
1531
  <FlowStepEditor
1051
1532
  v-if="expandedStepIndex === item.index"
@@ -1054,6 +1535,27 @@ watch(() => props.steps.length, () => {
1054
1535
  :disabled="disabled"
1055
1536
  @update="updateStep"
1056
1537
  />
1538
+ <NestedConditionBranches
1539
+ v-if="defaultKind(item.step) === 'condition'"
1540
+ :steps="steps"
1541
+ :condition="item.step"
1542
+ :condition-index="item.index"
1543
+ :expanded-step-index="expandedStepIndex"
1544
+ :copy-mode="copyMode"
1545
+ :selected-copy-indexes="selectedCopyIndexes"
1546
+ :disabled="disabled"
1547
+ :allowed-locked-actions="allowedLockedActions"
1548
+ :clipboard-count="clipboardCount"
1549
+ @node-click="handleNodeClick"
1550
+ @copy="$emit('copy', $event)"
1551
+ @remove="$emit('remove', $event)"
1552
+ @paste="(childIndex, childBranch) => $emit('paste', childIndex, childBranch)"
1553
+ @insert-branch-action="(childIndex, childBranch, action) => $emit('insertBranchAction', childIndex, childBranch, action)"
1554
+ @connect-next="(childIndex, childBranch) => $emit('connectNext', childIndex, childBranch)"
1555
+ @disconnect-next="(childIndex, childBranch) => $emit('disconnectNext', childIndex, childBranch)"
1556
+ @edit-input="$emit('editInput', $event)"
1557
+ @update-step="(childIndex, childStep) => $emit('updateStep', childIndex, childStep)"
1558
+ />
1057
1559
  </div>
1058
1560
  </div>
1059
1561
  <span
@@ -1062,6 +1564,53 @@ watch(() => props.steps.length, () => {
1062
1564
  >
1063
1565
  {{ targetLabel(step.flow?.noTargetId) }}
1064
1566
  </span>
1567
+ <div class="appium-flow-branch__actions">
1568
+ <el-dropdown
1569
+ trigger="click"
1570
+ :disabled="!canOpenInsertMenu()"
1571
+ max-height="320px"
1572
+ popper-class="appium-action-dropdown"
1573
+ @command="insertBranchAction(index, 'no', $event)"
1574
+ >
1575
+ <el-button
1576
+ size="small"
1577
+ class="appium-flow-branch__insert"
1578
+ :disabled="!canOpenInsertMenu()"
1579
+ :icon="Timer"
1580
+ @click.stop
1581
+ >
1582
+ 插入操作
1583
+ </el-button>
1584
+ <template #dropdown>
1585
+ <el-dropdown-menu>
1586
+ <el-dropdown-item v-if="clipboardCount" :command="PASTE_COMMAND" divided>
1587
+ 粘贴 {{ clipboardCount }} 个节点
1588
+ </el-dropdown-item>
1589
+ <template v-for="group in mainActionGroups" :key="group.title">
1590
+ <div class="appium-action-dropdown__group">{{ group.title }}</div>
1591
+ <el-dropdown-item
1592
+ v-for="action in group.actions"
1593
+ :key="action.type"
1594
+ :command="action.type"
1595
+ :disabled="isInsertActionDisabled(action.type)"
1596
+ >
1597
+ {{ action.label }}
1598
+ </el-dropdown-item>
1599
+ </template>
1600
+ </el-dropdown-menu>
1601
+ </template>
1602
+ </el-dropdown>
1603
+ <el-button
1604
+ v-if="shouldShowBranchConnect(index, step, 'no')"
1605
+ size="small"
1606
+ class="appium-flow-branch__connect"
1607
+ :icon="Link"
1608
+ :disabled="disabled"
1609
+ @click.stop="toggleBranchConnection(index, step, 'no')"
1610
+ >
1611
+ {{ branchConnectionTargetId(step, 'no') ? '取消连接' : '连接到下一节点' }}
1612
+ </el-button>
1613
+ </div>
1065
1614
  </div>
1066
1615
  </div>
1067
1616
  <div v-if="hasBranchConnection(step)" class="appium-flow-merge">
@@ -1078,6 +1627,41 @@ watch(() => props.steps.length, () => {
1078
1627
  />
1079
1628
  </svg>
1080
1629
  </div>
1630
+ <el-dropdown
1631
+ v-if="defaultKind(step) !== 'condition'"
1632
+ trigger="click"
1633
+ :disabled="!canOpenInsertMenu()"
1634
+ max-height="320px"
1635
+ popper-class="appium-action-dropdown"
1636
+ @command="insertAction(index, $event)"
1637
+ >
1638
+ <button
1639
+ type="button"
1640
+ class="appium-flow-insert"
1641
+ :disabled="!canOpenInsertMenu()"
1642
+ >
1643
+ <el-icon><Timer /></el-icon>
1644
+ <span>插入操作</span>
1645
+ </button>
1646
+ <template #dropdown>
1647
+ <el-dropdown-menu>
1648
+ <el-dropdown-item v-if="clipboardCount" :command="PASTE_COMMAND" divided>
1649
+ 粘贴 {{ clipboardCount }} 个节点
1650
+ </el-dropdown-item>
1651
+ <template v-for="group in mainActionGroups" :key="group.title">
1652
+ <div class="appium-action-dropdown__group">{{ group.title }}</div>
1653
+ <el-dropdown-item
1654
+ v-for="action in group.actions"
1655
+ :key="action.type"
1656
+ :command="action.type"
1657
+ :disabled="isInsertActionDisabled(action.type)"
1658
+ >
1659
+ {{ action.label }}
1660
+ </el-dropdown-item>
1661
+ </template>
1662
+ </el-dropdown-menu>
1663
+ </template>
1664
+ </el-dropdown>
1081
1665
  </div>
1082
1666
  </div>
1083
1667
  </div>