android-midscene-automation 0.1.13 → 0.1.14

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,10 +1,12 @@
1
1
  <script setup lang="ts">
2
2
  import { computed, reactive, shallowRef, watch } from 'vue';
3
- import { Delete, Edit, FullScreen, Timer } from '@element-plus/icons-vue';
4
- import type { AppiumRecordedStep, AppiumSelector } from '../types';
3
+ import { Delete, Edit, FullScreen, Link, Timer, VideoPlay } from '@element-plus/icons-vue';
4
+ import type { AppiumRecordedStep } from '../types';
5
+ import FlowStepEditor from './FlowStepEditor.vue';
5
6
 
6
7
  type FlowKind = 'action' | 'condition' | 'assertion';
7
8
  type BranchName = 'yes' | 'no';
9
+ type FlowViewport = 'main' | 'overview';
8
10
  type StepItem = { step: AppiumRecordedStep; index: number };
9
11
  type InsertAction =
10
12
  | 'delay'
@@ -36,6 +38,7 @@ const props = defineProps<{
36
38
  steps: AppiumRecordedStep[];
37
39
  disabled?: boolean;
38
40
  allowedLockedActions?: InsertAction[];
41
+ launchingStepId?: string;
39
42
  }>();
40
43
 
41
44
  const emit = defineEmits<{
@@ -43,7 +46,10 @@ const emit = defineEmits<{
43
46
  addDelay: [index?: number];
44
47
  insertAction: [index: number, action: InsertAction];
45
48
  insertBranchAction: [index: number, branch: BranchName, action: InsertAction];
49
+ connectNext: [index: number, branch: BranchName];
50
+ disconnectNext: [index: number, branch: BranchName];
46
51
  editInput: [index: number];
52
+ executeStep: [index: number];
47
53
  updateStep: [index: number, step: AppiumRecordedStep];
48
54
  }>();
49
55
 
@@ -51,21 +57,16 @@ const expandedStepIndex = shallowRef<number | null>(null);
51
57
  const flowDialogVisible = shallowRef(false);
52
58
  const flowPan = reactive({ x: 0, y: 0 });
53
59
  const flowScale = shallowRef(1);
54
- const flowDragging = shallowRef(false);
60
+ const overviewPan = reactive({ x: 0, y: 0 });
61
+ const overviewScale = shallowRef(1);
62
+ const draggingViewport = shallowRef<FlowViewport | null>(null);
55
63
  const flowDragMoved = shallowRef(false);
56
- let dragStart = { pointerId: 0, x: 0, y: 0, panX: 0, panY: 0 };
57
-
58
- const stepOptions = computed(() => props.steps.map((step, index) => ({
59
- label: `${index + 1}. ${step.label}`,
60
- value: step.id,
61
- })));
64
+ let dragStart = { pointerId: 0, x: 0, y: 0, panX: 0, panY: 0, viewport: 'main' as FlowViewport };
62
65
 
63
66
  const branchTargetIds = computed(() => {
64
67
  const ids = new Set<string>();
65
68
  props.steps.forEach((step) => {
66
69
  if (step.flow?.parentConditionId) ids.add(step.id);
67
- if (step.flow?.yesTargetId) ids.add(step.flow.yesTargetId);
68
- if (step.flow?.noTargetId) ids.add(step.flow.noTargetId);
69
70
  });
70
71
  return ids;
71
72
  });
@@ -78,6 +79,23 @@ const visibleStepItems = computed(() => stepItems.value
78
79
  .filter(({ step }) => !branchTargetIds.value.has(step.id)));
79
80
  const hasFlowSteps = computed(() => visibleStepItems.value.length > 0);
80
81
  const hasLaunchAppStep = computed(() => props.steps.some((step) => step.type === 'launchApp'));
82
+ const flowAxisByStepId = computed(() => {
83
+ const axes = new Map<string, number>();
84
+ let inheritedAxis = 50;
85
+ visibleStepItems.value.forEach(({ step }) => {
86
+ const incomingAxes: number[] = [];
87
+ visibleStepItems.value.forEach(({ step: condition }) => {
88
+ 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);
91
+ });
92
+ if (incomingAxes.length) {
93
+ inheritedAxis = incomingAxes.reduce((total, axis) => total + axis, 0) / incomingAxes.length;
94
+ }
95
+ axes.set(step.id, inheritedAxis);
96
+ });
97
+ return axes;
98
+ });
81
99
 
82
100
  const insertActionGroups: Array<{ title: string; actions: Array<{ type: InsertAction; label: string }> }> = [
83
101
  {
@@ -133,13 +151,12 @@ const mainActionGroups = insertActionGroups.map((group) => ({
133
151
 
134
152
  const startActionGroups = insertActionGroups;
135
153
 
136
- function scopedStepOptions() {
137
- return stepOptions.value;
138
- }
139
-
140
154
  function stepMeta(step: AppiumRecordedStep) {
141
155
  if (step.type === 'delay') return `${step.timeoutMs || 1000}ms`;
142
156
  if (step.type === 'input' || step.type === 'inputIfExists') return `输入内容:${step.value || '空'}`;
157
+ if (defaultKind(step) === 'condition' && step.value) {
158
+ return `${step.flow?.textMatch === 'exact' ? '精准匹配' : '模糊匹配'}:${step.value}`;
159
+ }
143
160
  if (step.contextSelector && step.selector) {
144
161
  return `父级 ${step.contextSelector.strategy} ${step.contextSelector.value || ''} + 子级 ${step.selector.strategy} ${step.selector.value || ''}`;
145
162
  }
@@ -207,49 +224,80 @@ function targetLabel(id?: string) {
207
224
  return `${index + 1}. ${props.steps[index].label}`;
208
225
  }
209
226
 
210
- function targetStep(id?: string) {
211
- if (!id) return null;
212
- const index = props.steps.findIndex((step) => step.id === id);
213
- if (index < 0) return null;
214
- return { step: props.steps[index], index };
215
- }
216
-
217
227
  function branchStepItems(step: AppiumRecordedStep, branch: BranchName) {
218
- const ownedItems = stepItems.value.filter((item) => (
228
+ return stepItems.value.filter((item) => (
219
229
  item.step.flow?.parentConditionId === step.id
220
230
  && item.step.flow?.parentBranch === branch
221
231
  ));
222
- if (ownedItems.length) return ownedItems;
232
+ }
223
233
 
234
+ function branchConnectionTargetId(step: AppiumRecordedStep, branch: BranchName) {
235
+ const branchItems = branchStepItems(step, branch);
236
+ if (branchItems.length) {
237
+ return branchItems[branchItems.length - 1]?.step.flow?.successTargetId || '';
238
+ }
224
239
  const targetId = branch === 'yes' ? step.flow?.yesTargetId : step.flow?.noTargetId;
225
- const target = targetStep(targetId);
226
- return target ? [target] : [];
240
+ const target = props.steps.find((item) => item.id === targetId);
241
+ return target && !target.flow?.parentConditionId ? target.id : '';
227
242
  }
228
243
 
229
- function nextStepId(index: number) {
230
- return props.steps[index + 1]?.id || '';
244
+ function hasBranchConnection(step: AppiumRecordedStep) {
245
+ return Boolean(
246
+ branchConnectionTargetId(step, 'yes')
247
+ || branchConnectionTargetId(step, 'no'),
248
+ );
231
249
  }
232
250
 
233
- function toggleExpanded(index: number) {
234
- expandedStepIndex.value = expandedStepIndex.value === index ? null : index;
251
+ function hasFollowingMainStep(index: number) {
252
+ return props.steps.slice(index + 1).some((step) => !step.flow?.parentConditionId);
235
253
  }
236
254
 
237
- function patchStep(index: number, patch: Partial<AppiumRecordedStep>) {
238
- const current = props.steps[index];
239
- if (!current) return;
240
- emit('updateStep', index, { ...current, ...patch });
255
+ function hasFollowingVisibleStep(step: AppiumRecordedStep) {
256
+ const index = visibleStepItems.value.findIndex((item) => item.step.id === step.id);
257
+ return index >= 0 && index < visibleStepItems.value.length - 1;
241
258
  }
242
259
 
243
- function patchFlow(index: number, patch: NonNullable<AppiumRecordedStep['flow']>) {
244
- const current = props.steps[index];
245
- if (!current) return;
246
- emit('updateStep', index, {
247
- ...current,
248
- flow: {
249
- ...(current.flow || {}),
250
- ...patch,
251
- },
252
- });
260
+ function shouldShowBranchConnect(index: number, step: AppiumRecordedStep, branch: BranchName) {
261
+ return Boolean(branchConnectionTargetId(step, branch)) || hasFollowingMainStep(index);
262
+ }
263
+
264
+ function flowAxis(step: AppiumRecordedStep) {
265
+ return flowAxisByStepId.value.get(step.id) || 50;
266
+ }
267
+
268
+ function flowAxisStyle(step: AppiumRecordedStep) {
269
+ return { '--flow-axis': `${flowAxis(step)}%` };
270
+ }
271
+
272
+ 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`;
275
+ }
276
+
277
+ function mergeTargetAxis(step: AppiumRecordedStep) {
278
+ const axes = [
279
+ branchConnectionTargetId(step, 'yes') ? 25 : 0,
280
+ branchConnectionTargetId(step, 'no') ? 75 : 0,
281
+ ].filter(Boolean);
282
+ return axes.reduce((total, axis) => total + axis, 0) / axes.length;
283
+ }
284
+
285
+ function mergePath(step: AppiumRecordedStep, branch: BranchName) {
286
+ const sourceAxis = branch === 'yes' ? 25 : 75;
287
+ const targetAxis = mergeTargetAxis(step);
288
+ return `M${sourceAxis} 0 C${sourceAxis} 20 ${targetAxis} 20 ${targetAxis} 44`;
289
+ }
290
+
291
+ function toggleBranchConnection(index: number, step: AppiumRecordedStep, branch: BranchName) {
292
+ if (branchConnectionTargetId(step, branch)) {
293
+ emit('disconnectNext', index, branch);
294
+ } else {
295
+ emit('connectNext', index, branch);
296
+ }
297
+ }
298
+
299
+ function toggleExpanded(index: number) {
300
+ expandedStepIndex.value = expandedStepIndex.value === index ? null : index;
253
301
  }
254
302
 
255
303
  function insertAction(index: number, command: string | number | object) {
@@ -264,6 +312,10 @@ function insertBranchAction(index: number, branch: BranchName, command: string |
264
312
  emit('insertBranchAction', index, branch, command as InsertAction);
265
313
  }
266
314
 
315
+ function updateStep(payload: { index: number; step: AppiumRecordedStep }) {
316
+ emit('updateStep', payload.index, payload.step);
317
+ }
318
+
267
319
  function isInsertActionDisabled(action: InsertAction) {
268
320
  return Boolean(props.disabled && !props.allowedLockedActions?.includes(action));
269
321
  }
@@ -272,13 +324,19 @@ function isStartActionDisabled(action: InsertAction) {
272
324
  return (action === 'launchApp' && hasLaunchAppStep.value) || isInsertActionDisabled(action);
273
325
  }
274
326
 
327
+ function isLaunchExecutionDisabled() {
328
+ return Boolean(props.disabled && !props.allowedLockedActions?.includes('launchApp'));
329
+ }
330
+
275
331
  function canOpenInsertMenu() {
276
332
  return !props.disabled || Boolean(props.allowedLockedActions?.length);
277
333
  }
278
334
 
279
335
  function isInteractiveTarget(target: EventTarget | null) {
280
- return target instanceof Element && Boolean(target.closest(
281
- 'button,input,textarea,select,.el-select,.el-dropdown,.el-input,.el-input-number,.appium-flow-node',
336
+ if (!(target instanceof Element)) return false;
337
+ if (target.closest('.appium-flow-node__toggle')) return false;
338
+ return Boolean(target.closest(
339
+ 'button,input,textarea,select,.el-select,.el-input,.el-input-number',
282
340
  ));
283
341
  }
284
342
 
@@ -286,54 +344,68 @@ function resetFlowView() {
286
344
  flowPan.x = 0;
287
345
  flowPan.y = 0;
288
346
  flowScale.value = 1;
347
+ overviewPan.x = 0;
348
+ overviewPan.y = 0;
349
+ overviewScale.value = 1;
289
350
  }
290
351
 
291
- function handleFlowWheel(event: WheelEvent) {
352
+ function viewportState(viewport: FlowViewport) {
353
+ return viewport === 'overview'
354
+ ? { pan: overviewPan, scale: overviewScale }
355
+ : { pan: flowPan, scale: flowScale };
356
+ }
357
+
358
+ function handleFlowWheel(event: WheelEvent, viewport: FlowViewport) {
292
359
  if (!event.ctrlKey) return;
293
360
  event.preventDefault();
361
+ const { pan, scale } = viewportState(viewport);
294
362
  const canvas = event.currentTarget as HTMLElement;
295
363
  const bounds = canvas.getBoundingClientRect();
296
364
  const cursorX = event.clientX - bounds.left;
297
365
  const cursorY = event.clientY - bounds.top;
298
- const previousScale = flowScale.value;
366
+ const previousScale = scale.value;
299
367
  const direction = event.deltaY < 0 ? 1 : -1;
300
368
  const nextScale = Math.min(2, Math.max(0.5, Math.round((previousScale + direction * 0.1) * 10) / 10));
301
369
  if (nextScale === previousScale) return;
302
- flowPan.x = cursorX - ((cursorX - flowPan.x) / previousScale) * nextScale;
303
- flowPan.y = cursorY - ((cursorY - flowPan.y) / previousScale) * nextScale;
304
- flowScale.value = nextScale;
370
+ pan.x = cursorX - ((cursorX - pan.x) / previousScale) * nextScale;
371
+ pan.y = cursorY - ((cursorY - pan.y) / previousScale) * nextScale;
372
+ scale.value = nextScale;
305
373
  }
306
374
 
307
375
  function handleNodeClick(index: number) {
376
+ if (flowDragMoved.value) return;
308
377
  toggleExpanded(index);
309
378
  }
310
379
 
311
- function startFlowDrag(event: PointerEvent) {
380
+ function startFlowDrag(event: PointerEvent, viewport: FlowViewport) {
312
381
  if (isInteractiveTarget(event.target)) return;
313
- flowDragging.value = true;
382
+ const { pan } = viewportState(viewport);
383
+ draggingViewport.value = viewport;
314
384
  flowDragMoved.value = false;
315
385
  dragStart = {
316
386
  pointerId: event.pointerId,
317
387
  x: event.clientX,
318
388
  y: event.clientY,
319
- panX: flowPan.x,
320
- panY: flowPan.y,
389
+ panX: pan.x,
390
+ panY: pan.y,
391
+ viewport,
321
392
  };
322
- (event.currentTarget as HTMLElement).setPointerCapture(event.pointerId);
323
393
  }
324
394
 
325
- function moveFlowDrag(event: PointerEvent) {
326
- if (!flowDragging.value || event.pointerId !== dragStart.pointerId) return;
327
- if (Math.abs(event.clientX - dragStart.x) + Math.abs(event.clientY - dragStart.y) > 4) {
395
+ function moveFlowDrag(event: PointerEvent, viewport: FlowViewport) {
396
+ if (draggingViewport.value !== viewport || event.pointerId !== dragStart.pointerId) return;
397
+ if (!flowDragMoved.value && Math.abs(event.clientX - dragStart.x) + Math.abs(event.clientY - dragStart.y) > 4) {
328
398
  flowDragMoved.value = true;
399
+ (event.currentTarget as HTMLElement).setPointerCapture(event.pointerId);
329
400
  }
330
- flowPan.x = dragStart.panX + event.clientX - dragStart.x;
331
- flowPan.y = dragStart.panY + event.clientY - dragStart.y;
401
+ const { pan } = viewportState(viewport);
402
+ pan.x = dragStart.panX + event.clientX - dragStart.x;
403
+ pan.y = dragStart.panY + event.clientY - dragStart.y;
332
404
  }
333
405
 
334
- function stopFlowDrag(event: PointerEvent) {
335
- if (!flowDragging.value || event.pointerId !== dragStart.pointerId) return;
336
- flowDragging.value = false;
406
+ function stopFlowDrag(event: PointerEvent, viewport: FlowViewport) {
407
+ if (draggingViewport.value !== viewport || event.pointerId !== dragStart.pointerId) return;
408
+ draggingViewport.value = null;
337
409
  const target = event.currentTarget as HTMLElement;
338
410
  if (target.hasPointerCapture(event.pointerId)) target.releasePointerCapture(event.pointerId);
339
411
  window.setTimeout(() => {
@@ -360,19 +432,31 @@ watch(() => props.steps.length, () => {
360
432
  </div>
361
433
  <div
362
434
  class="appium-flow-canvas"
363
- :class="{ 'appium-flow-canvas--dragging': flowDragging }"
364
- @pointerdown="startFlowDrag"
365
- @pointermove="moveFlowDrag"
366
- @pointerup="stopFlowDrag"
367
- @pointercancel="stopFlowDrag"
368
- @pointerleave="stopFlowDrag"
369
- @wheel="handleFlowWheel"
435
+ :class="{ 'appium-flow-canvas--dragging': draggingViewport === 'main' }"
436
+ @pointerdown="startFlowDrag($event, 'main')"
437
+ @pointermove="moveFlowDrag($event, 'main')"
438
+ @pointerup="stopFlowDrag($event, 'main')"
439
+ @pointercancel="stopFlowDrag($event, 'main')"
440
+ @pointerleave="stopFlowDrag($event, 'main')"
441
+ @wheel="handleFlowWheel($event, 'main')"
370
442
  >
371
443
  <div
372
444
  class="appium-flow-content"
373
- :style="{ transform: `translate(${flowPan.x}px, ${flowPan.y}px) scale(${flowScale})` }"
445
+ :style="{
446
+ transform: `translate(${flowPan.x}px, ${flowPan.y}px) scale(${flowScale})`,
447
+ '--appium-flow-line-width': '3px',
448
+ }"
374
449
  >
375
- <div class="appium-flow-start" :class="{ 'appium-flow-start--has-next': hasFlowSteps }">
450
+ <div class="appium-flow-start">
451
+ <svg
452
+ v-if="hasFlowSteps"
453
+ class="appium-flow-line appium-flow-start__line"
454
+ viewBox="0 0 10 100"
455
+ preserveAspectRatio="none"
456
+ aria-hidden="true"
457
+ >
458
+ <path d="M5 0 V100" />
459
+ </svg>
376
460
  <div class="appium-flow-start__node">开始</div>
377
461
  <el-dropdown
378
462
  trigger="click"
@@ -411,7 +495,17 @@ watch(() => props.steps.length, () => {
411
495
  :key="step.id"
412
496
  class="appium-flow-item"
413
497
  :class="{ 'appium-flow-item--condition': defaultKind(step) === 'condition' }"
498
+ :style="flowAxisStyle(step)"
414
499
  >
500
+ <svg
501
+ v-if="defaultKind(step) !== 'condition' && hasFollowingVisibleStep(step)"
502
+ class="appium-flow-line appium-flow-item__line"
503
+ viewBox="0 0 10 100"
504
+ preserveAspectRatio="none"
505
+ aria-hidden="true"
506
+ >
507
+ <path d="M5 0 V100" />
508
+ </svg>
415
509
  <div
416
510
  class="appium-flow-node"
417
511
  :class="[
@@ -433,9 +527,20 @@ watch(() => props.steps.length, () => {
433
527
  <template v-if="step.optional"> · 可选</template>
434
528
  <template v-if="stepMeta(step)"> · {{ stepMeta(step) }}</template>
435
529
  </span>
530
+ <span v-if="step.note" class="appium-flow-node__note" :title="step.note">{{ step.note }}</span>
436
531
  </span>
437
532
  </button>
438
533
  <span class="appium-flow-node__actions" @click.stop>
534
+ <el-button
535
+ v-if="step.type === 'launchApp'"
536
+ text
537
+ size="small"
538
+ :icon="VideoPlay"
539
+ :loading="launchingStepId === step.id"
540
+ :disabled="isLaunchExecutionDisabled()"
541
+ title="立即执行启动 App"
542
+ @click="$emit('executeStep', index)"
543
+ />
439
544
  <el-button
440
545
  v-if="step.type === 'input' || step.type === 'inputIfExists'"
441
546
  text
@@ -458,40 +563,78 @@ watch(() => props.steps.length, () => {
458
563
 
459
564
  <div v-if="defaultKind(step) === 'condition'" class="appium-flow-branches">
460
565
  <svg class="appium-flow-branch-lines" viewBox="0 0 100 48" preserveAspectRatio="none" aria-hidden="true">
461
- <path d="M50 0 C50 16 25 16 25 31 L25 48" />
462
- <path d="M50 0 C50 16 75 16 75 31 L75 48" />
566
+ <path :d="branchPath(step, 'yes')" />
567
+ <path :d="branchPath(step, 'no')" />
463
568
  </svg>
464
- <div class="appium-flow-branch appium-flow-branch--yes">
569
+ <div
570
+ class="appium-flow-branch appium-flow-branch--yes"
571
+ :class="{ 'appium-flow-branch--connected': branchConnectionTargetId(step, 'yes') }"
572
+ >
573
+ <svg
574
+ class="appium-flow-line appium-flow-branch__line"
575
+ viewBox="0 0 10 100"
576
+ preserveAspectRatio="none"
577
+ aria-hidden="true"
578
+ >
579
+ <path d="M5 0 V100" />
580
+ </svg>
465
581
  <strong class="appium-flow-branch__label">是</strong>
466
582
  <div v-if="branchStepItems(step, 'yes').length" class="appium-flow-branch-steps">
467
583
  <div
468
584
  v-for="item in branchStepItems(step, 'yes')"
469
585
  :key="item.step.id"
470
- class="appium-flow-branch-step"
586
+ class="appium-flow-branch-step-group"
471
587
  >
472
- <span class="appium-flow-branch-step__index">
473
- {{ item.index + 1 }}
474
- </span>
475
- <span class="appium-flow-branch-step__body">
476
- <span class="appium-flow-branch-step__title">
477
- {{ item.step.label }}
478
- </span>
479
- <span class="appium-flow-branch-step__meta">
480
- {{ kindLabel(defaultKind(item.step)) }} · {{ typeLabel(item.step) }}
588
+ <div
589
+ class="appium-flow-node appium-flow-branch-step"
590
+ :class="[
591
+ `appium-flow-node--${defaultKind(item.step)}`,
592
+ { 'appium-flow-node--expanded': expandedStepIndex === item.index },
593
+ ]"
594
+ >
595
+ <button
596
+ type="button"
597
+ class="appium-flow-node__toggle"
598
+ :aria-expanded="expandedStepIndex === item.index"
599
+ @click.stop="handleNodeClick(item.index)"
600
+ >
601
+ <span class="appium-flow-node__index">{{ item.index + 1 }}</span>
602
+ <span class="appium-flow-node__body">
603
+ <span class="appium-flow-node__title">{{ item.step.label }}</span>
604
+ <span class="appium-flow-node__meta">
605
+ {{ kindLabel(defaultKind(item.step)) }} · {{ typeLabel(item.step) }}
606
+ <template v-if="stepMeta(item.step)"> · {{ stepMeta(item.step) }}</template>
607
+ </span>
608
+ <span v-if="item.step.note" class="appium-flow-node__note" :title="item.step.note">{{ item.step.note }}</span>
609
+ </span>
610
+ </button>
611
+ <span class="appium-flow-node__actions" @click.stop>
612
+ <el-button
613
+ text
614
+ size="small"
615
+ :icon="Delete"
616
+ :disabled="disabled"
617
+ title="删除节点"
618
+ @click="$emit('remove', item.index)"
619
+ />
481
620
  </span>
482
- </span>
483
- <el-button
484
- class="appium-flow-branch-step__delete"
485
- text
486
- size="small"
487
- :icon="Delete"
621
+ </div>
622
+ <FlowStepEditor
623
+ v-if="expandedStepIndex === item.index"
624
+ :step="item.step"
625
+ :index="item.index"
488
626
  :disabled="disabled"
489
- title="删除节点"
490
- @click.stop="$emit('remove', item.index)"
627
+ @update="updateStep"
491
628
  />
492
629
  </div>
493
630
  </div>
494
- <span v-else class="appium-flow-branch__target">{{ targetLabel(step.flow?.yesTargetId) }}</span>
631
+ <span
632
+ v-if="!branchStepItems(step, 'yes').length && !branchConnectionTargetId(step, 'yes')"
633
+ class="appium-flow-branch__target"
634
+ >
635
+ {{ targetLabel(step.flow?.yesTargetId) }}
636
+ </span>
637
+ <div class="appium-flow-branch__actions">
495
638
  <el-dropdown
496
639
  trigger="click"
497
640
  :disabled="!canOpenInsertMenu()"
@@ -524,38 +667,88 @@ watch(() => props.steps.length, () => {
524
667
  </el-dropdown-menu>
525
668
  </template>
526
669
  </el-dropdown>
670
+ <el-button
671
+ v-if="shouldShowBranchConnect(index, step, 'yes')"
672
+ size="small"
673
+ class="appium-flow-branch__connect"
674
+ :icon="Link"
675
+ :disabled="disabled"
676
+ :title="branchConnectionTargetId(step, 'yes') ? '取消当前分支与主流程的连接' : '自动连接判断节点之后的主流程节点'"
677
+ @click.stop="toggleBranchConnection(index, step, 'yes')"
678
+ >
679
+ {{ branchConnectionTargetId(step, 'yes') ? '取消连接' : '连接到下一节点' }}
680
+ </el-button>
681
+ </div>
527
682
  </div>
528
- <div class="appium-flow-branch appium-flow-branch--no">
683
+ <div
684
+ class="appium-flow-branch appium-flow-branch--no"
685
+ :class="{ 'appium-flow-branch--connected': branchConnectionTargetId(step, 'no') }"
686
+ >
687
+ <svg
688
+ class="appium-flow-line appium-flow-branch__line"
689
+ viewBox="0 0 10 100"
690
+ preserveAspectRatio="none"
691
+ aria-hidden="true"
692
+ >
693
+ <path d="M5 0 V100" />
694
+ </svg>
529
695
  <strong class="appium-flow-branch__label">否</strong>
530
696
  <div v-if="branchStepItems(step, 'no').length" class="appium-flow-branch-steps">
531
697
  <div
532
698
  v-for="item in branchStepItems(step, 'no')"
533
699
  :key="item.step.id"
534
- class="appium-flow-branch-step"
700
+ class="appium-flow-branch-step-group"
535
701
  >
536
- <span class="appium-flow-branch-step__index">
537
- {{ item.index + 1 }}
538
- </span>
539
- <span class="appium-flow-branch-step__body">
540
- <span class="appium-flow-branch-step__title">
541
- {{ item.step.label }}
542
- </span>
543
- <span class="appium-flow-branch-step__meta">
544
- {{ kindLabel(defaultKind(item.step)) }} · {{ typeLabel(item.step) }}
702
+ <div
703
+ class="appium-flow-node appium-flow-branch-step"
704
+ :class="[
705
+ `appium-flow-node--${defaultKind(item.step)}`,
706
+ { 'appium-flow-node--expanded': expandedStepIndex === item.index },
707
+ ]"
708
+ >
709
+ <button
710
+ type="button"
711
+ class="appium-flow-node__toggle"
712
+ :aria-expanded="expandedStepIndex === item.index"
713
+ @click.stop="handleNodeClick(item.index)"
714
+ >
715
+ <span class="appium-flow-node__index">{{ item.index + 1 }}</span>
716
+ <span class="appium-flow-node__body">
717
+ <span class="appium-flow-node__title">{{ item.step.label }}</span>
718
+ <span class="appium-flow-node__meta">
719
+ {{ kindLabel(defaultKind(item.step)) }} · {{ typeLabel(item.step) }}
720
+ <template v-if="stepMeta(item.step)"> · {{ stepMeta(item.step) }}</template>
721
+ </span>
722
+ <span v-if="item.step.note" class="appium-flow-node__note" :title="item.step.note">{{ item.step.note }}</span>
723
+ </span>
724
+ </button>
725
+ <span class="appium-flow-node__actions" @click.stop>
726
+ <el-button
727
+ text
728
+ size="small"
729
+ :icon="Delete"
730
+ :disabled="disabled"
731
+ title="删除节点"
732
+ @click="$emit('remove', item.index)"
733
+ />
545
734
  </span>
546
- </span>
547
- <el-button
548
- class="appium-flow-branch-step__delete"
549
- text
550
- size="small"
551
- :icon="Delete"
735
+ </div>
736
+ <FlowStepEditor
737
+ v-if="expandedStepIndex === item.index"
738
+ :step="item.step"
739
+ :index="item.index"
552
740
  :disabled="disabled"
553
- title="删除节点"
554
- @click.stop="$emit('remove', item.index)"
741
+ @update="updateStep"
555
742
  />
556
743
  </div>
557
744
  </div>
558
- <span v-else class="appium-flow-branch__target">{{ targetLabel(step.flow?.noTargetId) }}</span>
745
+ <span
746
+ v-if="!branchStepItems(step, 'no').length && !branchConnectionTargetId(step, 'no')"
747
+ class="appium-flow-branch__target"
748
+ >
749
+ {{ targetLabel(step.flow?.noTargetId) }}
750
+ </span>
751
+ <div class="appium-flow-branch__actions">
559
752
  <el-dropdown
560
753
  trigger="click"
561
754
  :disabled="!canOpenInsertMenu()"
@@ -588,142 +781,44 @@ watch(() => props.steps.length, () => {
588
781
  </el-dropdown-menu>
589
782
  </template>
590
783
  </el-dropdown>
784
+ <el-button
785
+ v-if="shouldShowBranchConnect(index, step, 'no')"
786
+ size="small"
787
+ class="appium-flow-branch__connect"
788
+ :icon="Link"
789
+ :disabled="disabled"
790
+ :title="branchConnectionTargetId(step, 'no') ? '取消当前分支与主流程的连接' : '自动连接判断节点之后的主流程节点'"
791
+ @click.stop="toggleBranchConnection(index, step, 'no')"
792
+ >
793
+ {{ branchConnectionTargetId(step, 'no') ? '取消连接' : '连接到下一节点' }}
794
+ </el-button>
795
+ </div>
591
796
  </div>
592
797
  </div>
593
798
 
594
- <div v-if="expandedStepIndex === index" class="appium-flow-editor">
595
- <el-form label-position="top" size="small">
596
- <el-form-item label="节点名称">
597
- <el-input
598
- :model-value="step.label"
599
- :disabled="disabled"
600
- @update:model-value="patchStep(index, { label: String($event) })"
601
- />
602
- </el-form-item>
603
- <div class="appium-flow-editor__grid">
604
- <el-form-item label="节点类型">
605
- <el-select
606
- :model-value="defaultKind(step)"
607
- :disabled="disabled"
608
- @update:model-value="patchFlow(index, { nodeKind: $event as FlowKind })"
609
- >
610
- <el-option label="操作" value="action" />
611
- <el-option label="判断" value="condition" />
612
- <el-option label="校验" value="assertion" />
613
- </el-select>
614
- </el-form-item>
615
- <el-form-item label="超时时间 ms">
616
- <el-input-number
617
- :model-value="step.timeoutMs || undefined"
618
- :disabled="disabled"
619
- :min="0"
620
- :max="999999"
621
- controls-position="right"
622
- @update:model-value="patchStep(index, { timeoutMs: Number($event || 0) || undefined })"
623
- />
624
- </el-form-item>
625
- </div>
626
- <el-form-item v-if="step.type === 'input' || step.type === 'inputIfExists' || step.type === 'assertText'" label="文本内容">
627
- <el-input
628
- :model-value="step.value || ''"
629
- :disabled="disabled"
630
- @update:model-value="patchStep(index, { value: String($event) })"
631
- />
632
- </el-form-item>
633
- <div v-if="step.selector" class="appium-flow-editor__grid">
634
- <el-form-item label="Selector 类型">
635
- <el-select
636
- :model-value="step.selector.strategy"
637
- :disabled="disabled"
638
- @update:model-value="patchStep(index, { selector: { ...step.selector!, strategy: $event as AppiumSelector['strategy'] } })"
639
- >
640
- <el-option label="accessibility id" value="accessibilityId" />
641
- <el-option label="id" value="id" />
642
- <el-option label="android uiAutomator" value="androidUiAutomator" />
643
- <el-option label="xpath" value="xpath" />
644
- </el-select>
645
- </el-form-item>
646
- <el-form-item label="Selector 值">
647
- <el-input
648
- :model-value="step.selector.value || ''"
649
- :disabled="disabled"
650
- @update:model-value="patchStep(index, { selector: { ...step.selector!, value: String($event) } })"
651
- />
652
- </el-form-item>
653
- </div>
654
- <div v-if="step.selector" class="appium-flow-editor__grid">
655
- <el-form-item label="父级上下文类型">
656
- <el-select
657
- :model-value="step.contextSelector?.strategy || ''"
658
- :disabled="disabled"
659
- clearable
660
- placeholder="不使用父级"
661
- @update:model-value="patchStep(index, { contextSelector: $event ? { ...(step.contextSelector || { value: '' }), strategy: $event as AppiumSelector['strategy'] } : undefined })"
662
- >
663
- <el-option label="accessibility id" value="accessibilityId" />
664
- <el-option label="id" value="id" />
665
- <el-option label="android uiAutomator" value="androidUiAutomator" />
666
- <el-option label="xpath" value="xpath" />
667
- </el-select>
668
- </el-form-item>
669
- <el-form-item label="父级上下文值">
670
- <el-input
671
- :model-value="step.contextSelector?.value || ''"
672
- :disabled="disabled || !step.contextSelector"
673
- @update:model-value="patchStep(index, { contextSelector: { ...(step.contextSelector || { strategy: 'xpath' }), value: String($event) } })"
674
- />
675
- </el-form-item>
676
- </div>
677
- <el-form-item label="可选步骤">
678
- <el-switch
679
- :model-value="Boolean(step.optional)"
680
- :disabled="disabled"
681
- active-text="找不到时跳过"
682
- inactive-text="找不到时失败"
683
- @update:model-value="patchStep(index, { optional: Boolean($event) })"
684
- />
685
- </el-form-item>
686
- <template v-if="defaultKind(step) === 'condition'">
687
- <div class="appium-flow-editor__grid">
688
- <el-form-item label="是,进入">
689
- <el-select
690
- :model-value="step.flow?.yesTargetId || ''"
691
- :disabled="disabled"
692
- clearable
693
- placeholder="默认下一步"
694
- @update:model-value="patchFlow(index, { yesTargetId: String($event || '') })"
695
- >
696
- <el-option v-for="option in scopedStepOptions()" :key="option.value" :label="option.label" :value="option.value" />
697
- </el-select>
698
- </el-form-item>
699
- <el-form-item label="否,进入">
700
- <el-select
701
- :model-value="step.flow?.noTargetId || ''"
702
- :disabled="disabled"
703
- clearable
704
- placeholder="默认结束"
705
- @update:model-value="patchFlow(index, { noTargetId: String($event || '') })"
706
- >
707
- <el-option v-for="option in scopedStepOptions()" :key="option.value" :label="option.label" :value="option.value" />
708
- </el-select>
709
- </el-form-item>
710
- </div>
711
- </template>
712
- <div class="appium-flow-editor__actions">
713
- <el-button :icon="Timer" :disabled="disabled" @click="$emit('addDelay', index)">
714
- 插入延时
715
- </el-button>
716
- <el-button
717
- v-if="defaultKind(step) !== 'condition'"
718
- :disabled="disabled"
719
- @click="patchFlow(index, { nodeKind: 'condition', yesTargetId: '', noTargetId: '' })"
720
- >
721
- 转为判断节点
722
- </el-button>
723
- </div>
724
- </el-form>
799
+ <div v-if="hasBranchConnection(step)" class="appium-flow-merge">
800
+ <svg viewBox="0 0 100 44" preserveAspectRatio="none" aria-hidden="true">
801
+ <path
802
+ v-if="branchConnectionTargetId(step, 'yes')"
803
+ class="appium-flow-merge__yes"
804
+ :d="mergePath(step, 'yes')"
805
+ />
806
+ <path
807
+ v-if="branchConnectionTargetId(step, 'no')"
808
+ class="appium-flow-merge__no"
809
+ :d="mergePath(step, 'no')"
810
+ />
811
+ </svg>
725
812
  </div>
726
813
 
814
+ <FlowStepEditor
815
+ v-if="expandedStepIndex === index"
816
+ :step="step"
817
+ :index="index"
818
+ :disabled="disabled"
819
+ @update="updateStep"
820
+ />
821
+
727
822
  <el-dropdown
728
823
  v-if="defaultKind(step) !== 'condition'"
729
824
  trigger="click"
@@ -766,12 +861,33 @@ watch(() => props.steps.length, () => {
766
861
  class="appium-flow-dialog"
767
862
  append-to-body
768
863
  >
769
- <div class="appium-flow-dialog-canvas">
770
- <div class="appium-flow-content appium-flow-content--overview">
771
- <div
772
- class="appium-flow-start appium-flow-start--overview"
773
- :class="{ 'appium-flow-start--has-next': hasFlowSteps }"
774
- >
864
+ <div
865
+ class="appium-flow-dialog-canvas"
866
+ :class="{ 'appium-flow-dialog-canvas--dragging': draggingViewport === 'overview' }"
867
+ @pointerdown="startFlowDrag($event, 'overview')"
868
+ @pointermove="moveFlowDrag($event, 'overview')"
869
+ @pointerup="stopFlowDrag($event, 'overview')"
870
+ @pointercancel="stopFlowDrag($event, 'overview')"
871
+ @pointerleave="stopFlowDrag($event, 'overview')"
872
+ @wheel="handleFlowWheel($event, 'overview')"
873
+ >
874
+ <div
875
+ class="appium-flow-content appium-flow-content--overview"
876
+ :style="{
877
+ transform: `translate(${overviewPan.x}px, ${overviewPan.y}px) scale(${overviewScale})`,
878
+ '--appium-flow-line-width': '3px',
879
+ }"
880
+ >
881
+ <div class="appium-flow-start appium-flow-start--overview">
882
+ <svg
883
+ v-if="hasFlowSteps"
884
+ class="appium-flow-line appium-flow-start__line"
885
+ viewBox="0 0 10 100"
886
+ preserveAspectRatio="none"
887
+ aria-hidden="true"
888
+ >
889
+ <path d="M5 0 V100" />
890
+ </svg>
775
891
  <div class="appium-flow-start__node">开始</div>
776
892
  </div>
777
893
  <div
@@ -779,12 +895,30 @@ watch(() => props.steps.length, () => {
779
895
  :key="step.id"
780
896
  class="appium-flow-item"
781
897
  :class="{ 'appium-flow-item--condition': defaultKind(step) === 'condition' }"
898
+ :style="flowAxisStyle(step)"
782
899
  >
900
+ <svg
901
+ v-if="defaultKind(step) !== 'condition' && hasFollowingVisibleStep(step)"
902
+ class="appium-flow-line appium-flow-item__line"
903
+ viewBox="0 0 10 100"
904
+ preserveAspectRatio="none"
905
+ aria-hidden="true"
906
+ >
907
+ <path d="M5 0 V100" />
908
+ </svg>
783
909
  <div
784
910
  class="appium-flow-node"
785
- :class="`appium-flow-node--${defaultKind(step)}`"
911
+ :class="[
912
+ `appium-flow-node--${defaultKind(step)}`,
913
+ { 'appium-flow-node--expanded': expandedStepIndex === index },
914
+ ]"
786
915
  >
787
- <span class="appium-flow-node__toggle">
916
+ <button
917
+ type="button"
918
+ class="appium-flow-node__toggle"
919
+ :aria-expanded="expandedStepIndex === index"
920
+ @click="handleNodeClick(index)"
921
+ >
788
922
  <span class="appium-flow-node__index">{{ index + 1 }}</span>
789
923
  <span class="appium-flow-node__body">
790
924
  <span class="appium-flow-node__title" :title="step.label">{{ step.label }}</span>
@@ -793,54 +927,157 @@ watch(() => props.steps.length, () => {
793
927
  <template v-if="step.optional"> · 可选</template>
794
928
  <template v-if="stepMeta(step)"> · {{ stepMeta(step) }}</template>
795
929
  </span>
930
+ <span v-if="step.note" class="appium-flow-node__note" :title="step.note">{{ step.note }}</span>
796
931
  </span>
797
- </span>
932
+ </button>
798
933
  </div>
799
934
 
935
+ <FlowStepEditor
936
+ v-if="expandedStepIndex === index"
937
+ :step="step"
938
+ :index="index"
939
+ :disabled="disabled"
940
+ @update="updateStep"
941
+ />
942
+
800
943
  <div v-if="defaultKind(step) === 'condition'" class="appium-flow-branches">
801
944
  <svg class="appium-flow-branch-lines" viewBox="0 0 100 48" preserveAspectRatio="none" aria-hidden="true">
802
- <path d="M50 0 C50 16 25 16 25 31 L25 48" />
803
- <path d="M50 0 C50 16 75 16 75 31 L75 48" />
945
+ <path :d="branchPath(step, 'yes')" />
946
+ <path :d="branchPath(step, 'no')" />
804
947
  </svg>
805
- <div class="appium-flow-branch appium-flow-branch--yes">
948
+ <div
949
+ class="appium-flow-branch appium-flow-branch--yes"
950
+ :class="{ 'appium-flow-branch--connected': branchConnectionTargetId(step, 'yes') }"
951
+ >
952
+ <svg
953
+ class="appium-flow-line appium-flow-branch__line"
954
+ viewBox="0 0 10 100"
955
+ preserveAspectRatio="none"
956
+ aria-hidden="true"
957
+ >
958
+ <path d="M5 0 V100" />
959
+ </svg>
806
960
  <strong class="appium-flow-branch__label">是</strong>
807
961
  <div v-if="branchStepItems(step, 'yes').length" class="appium-flow-branch-steps">
808
962
  <div
809
963
  v-for="item in branchStepItems(step, 'yes')"
810
964
  :key="item.step.id"
811
- class="appium-flow-branch-step"
965
+ class="appium-flow-branch-step-group"
812
966
  >
813
- <span class="appium-flow-branch-step__index">{{ item.index + 1 }}</span>
814
- <span class="appium-flow-branch-step__body">
815
- <span class="appium-flow-branch-step__title">{{ item.step.label }}</span>
816
- <span class="appium-flow-branch-step__meta">
817
- {{ kindLabel(defaultKind(item.step)) }} · {{ typeLabel(item.step) }}
818
- </span>
819
- </span>
967
+ <div
968
+ class="appium-flow-node appium-flow-branch-step"
969
+ :class="[
970
+ `appium-flow-node--${defaultKind(item.step)}`,
971
+ { 'appium-flow-node--expanded': expandedStepIndex === item.index },
972
+ ]"
973
+ >
974
+ <button
975
+ type="button"
976
+ class="appium-flow-node__toggle"
977
+ :aria-expanded="expandedStepIndex === item.index"
978
+ @click="handleNodeClick(item.index)"
979
+ >
980
+ <span class="appium-flow-node__index">{{ item.index + 1 }}</span>
981
+ <span class="appium-flow-node__body">
982
+ <span class="appium-flow-node__title">{{ item.step.label }}</span>
983
+ <span class="appium-flow-node__meta">
984
+ {{ kindLabel(defaultKind(item.step)) }} · {{ typeLabel(item.step) }}
985
+ <template v-if="stepMeta(item.step)"> · {{ stepMeta(item.step) }}</template>
986
+ </span>
987
+ <span v-if="item.step.note" class="appium-flow-node__note" :title="item.step.note">{{ item.step.note }}</span>
988
+ </span>
989
+ </button>
990
+ </div>
991
+ <FlowStepEditor
992
+ v-if="expandedStepIndex === item.index"
993
+ :step="item.step"
994
+ :index="item.index"
995
+ :disabled="disabled"
996
+ @update="updateStep"
997
+ />
820
998
  </div>
821
999
  </div>
822
- <span v-else class="appium-flow-branch__target">{{ targetLabel(step.flow?.yesTargetId) }}</span>
1000
+ <span
1001
+ v-if="!branchStepItems(step, 'yes').length && !branchConnectionTargetId(step, 'yes')"
1002
+ class="appium-flow-branch__target"
1003
+ >
1004
+ {{ targetLabel(step.flow?.yesTargetId) }}
1005
+ </span>
823
1006
  </div>
824
- <div class="appium-flow-branch appium-flow-branch--no">
1007
+ <div
1008
+ class="appium-flow-branch appium-flow-branch--no"
1009
+ :class="{ 'appium-flow-branch--connected': branchConnectionTargetId(step, 'no') }"
1010
+ >
1011
+ <svg
1012
+ class="appium-flow-line appium-flow-branch__line"
1013
+ viewBox="0 0 10 100"
1014
+ preserveAspectRatio="none"
1015
+ aria-hidden="true"
1016
+ >
1017
+ <path d="M5 0 V100" />
1018
+ </svg>
825
1019
  <strong class="appium-flow-branch__label">否</strong>
826
1020
  <div v-if="branchStepItems(step, 'no').length" class="appium-flow-branch-steps">
827
1021
  <div
828
1022
  v-for="item in branchStepItems(step, 'no')"
829
1023
  :key="item.step.id"
830
- class="appium-flow-branch-step"
1024
+ class="appium-flow-branch-step-group"
831
1025
  >
832
- <span class="appium-flow-branch-step__index">{{ item.index + 1 }}</span>
833
- <span class="appium-flow-branch-step__body">
834
- <span class="appium-flow-branch-step__title">{{ item.step.label }}</span>
835
- <span class="appium-flow-branch-step__meta">
836
- {{ kindLabel(defaultKind(item.step)) }} · {{ typeLabel(item.step) }}
837
- </span>
838
- </span>
1026
+ <div
1027
+ class="appium-flow-node appium-flow-branch-step"
1028
+ :class="[
1029
+ `appium-flow-node--${defaultKind(item.step)}`,
1030
+ { 'appium-flow-node--expanded': expandedStepIndex === item.index },
1031
+ ]"
1032
+ >
1033
+ <button
1034
+ type="button"
1035
+ class="appium-flow-node__toggle"
1036
+ :aria-expanded="expandedStepIndex === item.index"
1037
+ @click="handleNodeClick(item.index)"
1038
+ >
1039
+ <span class="appium-flow-node__index">{{ item.index + 1 }}</span>
1040
+ <span class="appium-flow-node__body">
1041
+ <span class="appium-flow-node__title">{{ item.step.label }}</span>
1042
+ <span class="appium-flow-node__meta">
1043
+ {{ kindLabel(defaultKind(item.step)) }} · {{ typeLabel(item.step) }}
1044
+ <template v-if="stepMeta(item.step)"> · {{ stepMeta(item.step) }}</template>
1045
+ </span>
1046
+ <span v-if="item.step.note" class="appium-flow-node__note" :title="item.step.note">{{ item.step.note }}</span>
1047
+ </span>
1048
+ </button>
1049
+ </div>
1050
+ <FlowStepEditor
1051
+ v-if="expandedStepIndex === item.index"
1052
+ :step="item.step"
1053
+ :index="item.index"
1054
+ :disabled="disabled"
1055
+ @update="updateStep"
1056
+ />
839
1057
  </div>
840
1058
  </div>
841
- <span v-else class="appium-flow-branch__target">{{ targetLabel(step.flow?.noTargetId) }}</span>
1059
+ <span
1060
+ v-if="!branchStepItems(step, 'no').length && !branchConnectionTargetId(step, 'no')"
1061
+ class="appium-flow-branch__target"
1062
+ >
1063
+ {{ targetLabel(step.flow?.noTargetId) }}
1064
+ </span>
842
1065
  </div>
843
1066
  </div>
1067
+ <div v-if="hasBranchConnection(step)" class="appium-flow-merge">
1068
+ <svg viewBox="0 0 100 44" preserveAspectRatio="none" aria-hidden="true">
1069
+ <path
1070
+ v-if="branchConnectionTargetId(step, 'yes')"
1071
+ class="appium-flow-merge__yes"
1072
+ :d="mergePath(step, 'yes')"
1073
+ />
1074
+ <path
1075
+ v-if="branchConnectionTargetId(step, 'no')"
1076
+ class="appium-flow-merge__no"
1077
+ :d="mergePath(step, 'no')"
1078
+ />
1079
+ </svg>
1080
+ </div>
844
1081
  </div>
845
1082
  </div>
846
1083
  </div>