android-midscene-automation 0.1.6 → 0.1.9

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,14 +1,13 @@
1
1
  <script setup lang="ts">
2
2
  import { computed, onMounted, reactive, shallowRef, watch } from 'vue';
3
3
  import { ElMessage, ElMessageBox } from 'element-plus';
4
- import { Check, Delete, Refresh, VideoPlay } from '@element-plus/icons-vue';
4
+ import { Check, CopyDocument, Delete, FullScreen, Refresh, VideoPlay } from '@element-plus/icons-vue';
5
5
  import type { AndroidDevice, AppPreset, DeviceAction } from '../types';
6
6
  import DevicePreviewPanel from '../components/device/DevicePreviewPanel.vue';
7
7
  import {
8
8
  deleteAppiumScript,
9
9
  getAppiumScripts,
10
10
  getAppiumTree,
11
- pressAppiumDeviceKey,
12
11
  replayAppiumScript,
13
12
  saveAppiumScript,
14
13
  tapAppiumDevice,
@@ -19,10 +18,23 @@ import RecordedSteps from './components/RecordedSteps.vue';
19
18
  import { findSmallestNodeAtPoint, flattenNodes, parseWindowHierarchy } from './tree';
20
19
  import type { AppiumNode, AppiumRecordedScript, AppiumRecordedStep } from './types';
21
20
 
22
- type NodeStepType = 'tap' | 'input' | 'assertExists';
21
+ type NodeStepType = 'tap' | 'input' | 'assertExists' | 'waitFor';
22
+ type BranchName = 'yes' | 'no';
23
+ type StepScope = 'pre' | 'main' | 'post';
24
+ type BranchTarget = {
25
+ stepId: string;
26
+ branch: BranchName;
27
+ updateTarget?: boolean;
28
+ };
23
29
  type RecorderAction =
24
30
  | 'delay'
25
31
  | NodeStepType
32
+ | 'tapIfExists'
33
+ | 'inputIfExists'
34
+ | 'clearIfExists'
35
+ | 'backIfExists'
36
+ | 'popupCondition'
37
+ | 'runScript'
26
38
  | 'keyBack'
27
39
  | 'keyHome'
28
40
  | 'keyRecent'
@@ -71,18 +83,21 @@ const steps = shallowRef<AppiumRecordedStep[]>([]);
71
83
  const rawXml = shallowRef('');
72
84
  const currentActivity = shallowRef('');
73
85
  const replayOutput = shallowRef('');
86
+ const replayOutputDialogVisible = shallowRef(false);
87
+ const linkedScriptDialogVisible = shallowRef(false);
88
+ const linkedScriptTargetId = shallowRef('');
89
+ const linkedScriptInsertIndex = shallowRef<number | undefined>();
90
+ const linkedScriptBranchTarget = shallowRef<BranchTarget | null>(null);
91
+ const linkedScriptExpectedActivity = shallowRef('');
74
92
  const loadingTree = shallowRef(false);
75
93
  const saving = shallowRef(false);
76
94
  const deletingScriptId = shallowRef('');
77
95
  const replaying = shallowRef(false);
78
96
  const recordingTap = shallowRef(false);
79
97
  const resolvingNavigation = shallowRef(false);
80
- const maxReplayOutputLength = 20_000;
81
98
  const pendingNavigation = shallowRef<{
82
99
  beforeActivity: string;
83
100
  afterActivity: string;
84
- beforeSignature: string;
85
- afterSignature: string;
86
101
  } | null>(null);
87
102
 
88
103
  const form = reactive({
@@ -92,9 +107,29 @@ const form = reactive({
92
107
  });
93
108
 
94
109
  const selectedScript = computed(() => scripts.value.find((script) => script.id === selectedScriptId.value) || null);
95
- const visibleReplayOutput = computed(() => replayOutput.value);
110
+ const linkableScripts = computed(() => scripts.value.filter((script) => script.id !== selectedScriptId.value));
111
+ const compatibleLinkableScripts = computed(() => linkableScripts.value.filter((script) => (
112
+ Boolean(linkedScriptExpectedActivity.value)
113
+ && script.appActivity === linkedScriptExpectedActivity.value
114
+ )));
115
+ const visibleReplayOutput = computed(() => {
116
+ const lines = replayOutput.value.split(/\r?\n/);
117
+ const maxLines = 80;
118
+ if (lines.length <= maxLines) return replayOutput.value;
119
+ return `... 仅显示最近 ${maxLines} 行,点击展开查看完整日志\n${lines.slice(-maxLines).join('\n')}`;
120
+ });
96
121
  const hasSelectedAppPackage = computed(() => props.appPresets.some((app) => app.packageName === form.appPackage));
97
- const recordingLocked = computed(() => recordingTap.value || resolvingNavigation.value || Boolean(pendingNavigation.value));
122
+ const scriptActivityMismatch = computed(() => {
123
+ const expectedActivity = selectedScript.value?.appActivity?.trim();
124
+ if (!expectedActivity) return false;
125
+ return currentActivity.value !== expectedActivity;
126
+ });
127
+ const recordingBusy = computed(() => (
128
+ recordingTap.value
129
+ || resolvingNavigation.value
130
+ || Boolean(pendingNavigation.value)
131
+ ));
132
+ const recordingLocked = computed(() => recordingBusy.value || scriptActivityMismatch.value);
98
133
  const overlayBounds = computed(() => flattenNodes(tree.value).flatMap((node) => (
99
134
  node.bounds ? [{ id: node.id, ...node.bounds }] : []
100
135
  )));
@@ -103,19 +138,103 @@ const selectedBounds = computed(() => {
103
138
  return bounds && selectedNode.value ? { id: selectedNode.value.id, ...bounds } : undefined;
104
139
  });
105
140
 
141
+ function stepScope(step?: AppiumRecordedStep): StepScope {
142
+ return step?.flow?.scope || 'main';
143
+ }
144
+
145
+ function insertionScope(index?: number): StepScope {
146
+ if (index === -2) return 'pre';
147
+ if (index === -3) return 'post';
148
+ return typeof index === 'number' && index >= 0 ? stepScope(steps.value[index]) : 'main';
149
+ }
150
+
151
+ function withStepScope(step: AppiumRecordedStep, scope: StepScope): AppiumRecordedStep {
152
+ if (scope === 'main') return step;
153
+ return {
154
+ ...step,
155
+ flow: {
156
+ ...(step.flow || {}),
157
+ scope,
158
+ },
159
+ };
160
+ }
161
+
162
+ function insertStep(step: AppiumRecordedStep, index?: number) {
163
+ const nextSteps = [...steps.value];
164
+ if (index === -2) {
165
+ const firstMainIndex = nextSteps.findIndex((item) => stepScope(item) !== 'pre');
166
+ const insertAt = firstMainIndex < 0 ? nextSteps.length : firstMainIndex;
167
+ nextSteps.splice(insertAt, 0, withStepScope(step, 'pre'));
168
+ } else if (index === -3) {
169
+ nextSteps.push(withStepScope(step, 'post'));
170
+ } else if (index === -1) {
171
+ const firstMainIndex = nextSteps.findIndex((item) => stepScope(item) !== 'pre');
172
+ nextSteps.splice(firstMainIndex < 0 ? nextSteps.length : firstMainIndex, 0, step);
173
+ } else {
174
+ const scope = typeof index === 'number' ? stepScope(nextSteps[index]) : 'main';
175
+ nextSteps.splice(typeof index === 'number' ? index + 1 : nextSteps.length, 0, withStepScope(step, scope));
176
+ }
177
+ steps.value = nextSteps;
178
+ return step;
179
+ }
180
+
181
+ function updateBranchTarget(stepId: string, branch: BranchName, targetId: string) {
182
+ const targetKey = branch === 'yes' ? 'yesTargetId' : 'noTargetId';
183
+ steps.value = steps.value.map((step) => (
184
+ step.id === stepId
185
+ ? {
186
+ ...step,
187
+ flow: {
188
+ ...(step.flow || {}),
189
+ nodeKind: 'condition',
190
+ [targetKey]: targetId,
191
+ },
192
+ }
193
+ : step
194
+ ));
195
+ }
196
+
197
+ function attachStepToBranch(stepId: string, conditionId: string, branch: BranchName) {
198
+ steps.value = steps.value.map((step) => (
199
+ step.id === stepId
200
+ ? {
201
+ ...step,
202
+ flow: {
203
+ ...(step.flow || {}),
204
+ parentConditionId: conditionId,
205
+ parentBranch: branch,
206
+ },
207
+ }
208
+ : step
209
+ ));
210
+ }
211
+
212
+ function branchInsertIndex(conditionId: string, branch: BranchName, fallbackIndex: number) {
213
+ const ownedIndexes = steps.value.flatMap((step, index) => (
214
+ step.flow?.parentConditionId === conditionId && step.flow?.parentBranch === branch ? [index] : []
215
+ ));
216
+ return ownedIndexes.length ? ownedIndexes[ownedIndexes.length - 1] : fallbackIndex;
217
+ }
218
+
106
219
  function createStep(type: NodeStepType, node: AppiumNode): AppiumRecordedStep {
107
220
  const labelMap = {
108
221
  tap: '点击',
109
222
  input: '输入',
110
223
  assertExists: '断言存在',
224
+ waitFor: '等待出现',
111
225
  };
112
226
  return {
113
227
  id: `step_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`,
114
228
  type,
115
229
  label: `${labelMap[type]} ${node.label}`,
116
230
  selector: node.selector,
231
+ contextSelector: node.contextSelector,
232
+ selectorChain: node.xpath && node.selector.value !== node.xpath
233
+ ? [{ strategy: 'xpath', value: node.xpath, unique: true, matchCount: 1 }]
234
+ : undefined,
117
235
  fallback: node.bounds ? { strategy: 'bounds', centerX: node.bounds.centerX, centerY: node.bounds.centerY } : undefined,
118
- timeoutMs: type === 'assertExists' ? 10000 : undefined,
236
+ timeoutMs: type === 'assertExists' || type === 'waitFor' ? 10000 : undefined,
237
+ pageBefore: currentPageSnapshot(node),
119
238
  snapshot: {
120
239
  text: node.text,
121
240
  resourceId: node.resourceId,
@@ -125,9 +244,15 @@ function createStep(type: NodeStepType, node: AppiumNode): AppiumRecordedStep {
125
244
  };
126
245
  }
127
246
 
128
- function trimReplayOutput(output: string) {
129
- if (output.length <= maxReplayOutputLength) return output;
130
- return `... 已截断前 ${output.length - maxReplayOutputLength} 个字符,仅显示最后 ${maxReplayOutputLength} 个字符\n${output.slice(-maxReplayOutputLength)}`;
247
+ async function copyReplayOutput() {
248
+ if (!replayOutput.value) return;
249
+ await navigator.clipboard.writeText(replayOutput.value);
250
+ ElMessage.success('回放输出已复制');
251
+ }
252
+
253
+ function clearReplayOutput() {
254
+ replayOutput.value = '';
255
+ replayOutputDialogVisible.value = false;
131
256
  }
132
257
 
133
258
  function createDelayStep(timeoutMs: number): AppiumRecordedStep {
@@ -150,8 +275,13 @@ function createNodeActionStep(
150
275
  type,
151
276
  label: `${label} ${node.label}`,
152
277
  selector: node.selector,
278
+ contextSelector: node.contextSelector,
279
+ selectorChain: node.xpath && node.selector.value !== node.xpath
280
+ ? [{ strategy: 'xpath', value: node.xpath, unique: true, matchCount: 1 }]
281
+ : undefined,
153
282
  fallback: node.bounds ? { strategy: 'bounds', centerX: node.bounds.centerX, centerY: node.bounds.centerY } : undefined,
154
283
  ...options,
284
+ pageBefore: currentPageSnapshot(node),
155
285
  snapshot: {
156
286
  text: node.text,
157
287
  resourceId: node.resourceId,
@@ -203,6 +333,37 @@ function createWaitActivityStep(activity: string): AppiumRecordedStep {
203
333
  };
204
334
  }
205
335
 
336
+ function createRunScriptStep(script: AppiumRecordedScript): AppiumRecordedStep {
337
+ return {
338
+ id: `step_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`,
339
+ type: 'runScript',
340
+ label: `连接脚本 ${script.name}`,
341
+ value: script.id,
342
+ flow: { nodeKind: 'action' },
343
+ };
344
+ }
345
+
346
+ function linkedScriptExitActivity(script: AppiumRecordedScript) {
347
+ for (let index = script.steps.length - 1; index >= 0; index -= 1) {
348
+ const activity = script.steps[index]?.pageAfter?.activity;
349
+ if (activity) return activity;
350
+ }
351
+ return script.appActivity;
352
+ }
353
+
354
+ function expectedActivityAfterStep(index?: number) {
355
+ const scriptActivity = selectedScript.value?.appActivity || form.appActivity || currentActivity.value;
356
+ if (typeof index !== 'number' || index < 0) return scriptActivity;
357
+
358
+ const previousStep = steps.value[index];
359
+ if (!previousStep) return scriptActivity;
360
+ if (previousStep.type === 'runScript') {
361
+ const script = scripts.value.find((item) => item.id === previousStep.value);
362
+ if (script) return linkedScriptExitActivity(script);
363
+ }
364
+ return previousStep.pageAfter?.activity || previousStep.pageBefore?.activity || scriptActivity;
365
+ }
366
+
206
367
  function wait(ms: number) {
207
368
  return new Promise((resolve) => setTimeout(resolve, ms));
208
369
  }
@@ -220,6 +381,15 @@ function treeSignature(root: AppiumNode | null) {
220
381
  .join('\n');
221
382
  }
222
383
 
384
+ function currentPageSnapshot(node = selectedNode.value) {
385
+ return {
386
+ activity: currentActivity.value,
387
+ packageName: node?.packageName || '',
388
+ treeSignature: treeSignature(tree.value),
389
+ selectedNodePath: node?.xpath || '',
390
+ };
391
+ }
392
+
223
393
  function ensureAppPackageSelected() {
224
394
  if (hasSelectedAppPackage.value) return true;
225
395
  ElMessage.warning('请先从预设 App 参数中选择 App 包名');
@@ -252,9 +422,6 @@ async function loadScripts() {
252
422
  if (selectedScriptId.value && !scripts.value.some((script) => script.id === selectedScriptId.value)) {
253
423
  selectedScriptId.value = '';
254
424
  }
255
- if (!selectedScriptId.value) {
256
- selectedScriptId.value = scripts.value[0]?.id || '';
257
- }
258
425
  }
259
426
 
260
427
  async function switchDevice(deviceId: string) {
@@ -320,7 +487,7 @@ async function swipePreview(gesture: {
320
487
  }
321
488
  }
322
489
 
323
- async function recordTapStep() {
490
+ async function recordTapStep(index?: number) {
324
491
  if (!selectedNode.value || recordingTap.value) return;
325
492
  if (!ensureAppPackageSelected()) return;
326
493
  if (!selectedDeviceId.value) {
@@ -334,40 +501,40 @@ async function recordTapStep() {
334
501
  return;
335
502
  }
336
503
  const beforeActivity = currentActivity.value;
337
- const beforeSignature = treeSignature(tree.value);
338
504
  const step = createStep('tap', node);
339
- steps.value = [...steps.value, step];
505
+ insertStep(step, index);
340
506
  recordingTap.value = true;
341
507
  try {
342
508
  await tapAppiumDevice({ deviceId: selectedDeviceId.value, x: bounds.centerX, y: bounds.centerY });
343
509
  await wait(1200);
344
510
  const after = await loadTreeSnapshot();
511
+ steps.value = steps.value.map((item) => (
512
+ item.id === step.id ? { ...item, pageAfter: currentPageSnapshot() } : item
513
+ ));
345
514
  const activityChanged = Boolean(beforeActivity && after.activity && beforeActivity !== after.activity);
346
- const treeChanged = Boolean(beforeSignature && after.signature && beforeSignature !== after.signature);
347
- if (activityChanged || treeChanged) {
515
+ if (activityChanged) {
348
516
  pendingNavigation.value = {
349
517
  beforeActivity,
350
518
  afterActivity: after.activity,
351
- beforeSignature,
352
- afterSignature: after.signature,
353
519
  };
354
- return;
520
+ return step;
355
521
  }
356
522
  ElMessage.success('已录制点击');
523
+ return step;
357
524
  } catch (error) {
358
525
  ElMessage.error(error instanceof Error ? error.message : '录制点击失败');
526
+ return undefined;
359
527
  } finally {
360
528
  recordingTap.value = false;
361
529
  }
362
530
  }
363
531
 
364
- async function addStep(type: NodeStepType) {
532
+ async function addStep(type: NodeStepType, index?: number) {
365
533
  if (recordingLocked.value) return;
366
534
  if (!selectedNode.value) return;
367
535
  if (!ensureAppPackageSelected()) return;
368
536
  if (type === 'tap') {
369
- await recordTapStep();
370
- return;
537
+ return recordTapStep(index);
371
538
  }
372
539
  const step = createStep(type, selectedNode.value);
373
540
  if (type === 'input') {
@@ -380,7 +547,7 @@ async function addStep(type: NodeStepType) {
380
547
  if (!input) return;
381
548
  step.value = input.value;
382
549
  }
383
- steps.value = [...steps.value, step];
550
+ return insertStep(step, index);
384
551
  }
385
552
 
386
553
  async function addDelayStep(index?: number) {
@@ -395,37 +562,106 @@ async function addDelayStep(index?: number) {
395
562
  if (!input) return;
396
563
  const timeoutMs = Number(input.value);
397
564
  const step = createDelayStep(timeoutMs);
398
- const nextSteps = [...steps.value];
399
- nextSteps.splice(typeof index === 'number' ? index + 1 : nextSteps.length, 0, step);
400
- steps.value = nextSteps;
565
+ return insertStep(step, index);
401
566
  }
402
567
 
403
- async function addAction(action: RecorderAction) {
404
- if (recordingLocked.value) return;
568
+ function openLinkedScriptDialog(index?: number, branchTarget?: BranchTarget) {
569
+ if (!linkableScripts.value.length) {
570
+ ElMessage.warning('暂无可连接的已保存脚本');
571
+ return;
572
+ }
573
+ linkedScriptExpectedActivity.value = expectedActivityAfterStep(index);
574
+ if (!linkedScriptExpectedActivity.value) {
575
+ ElMessage.warning('无法确定当前插入点的 Activity,请先刷新组件树');
576
+ return;
577
+ }
578
+ if (!compatibleLinkableScripts.value.length) {
579
+ ElMessage.warning(`没有入口 Activity 为 ${linkedScriptExpectedActivity.value} 的可连接脚本`);
580
+ linkedScriptExpectedActivity.value = '';
581
+ return;
582
+ }
583
+ linkedScriptTargetId.value = compatibleLinkableScripts.value[0]?.id || '';
584
+ linkedScriptInsertIndex.value = index;
585
+ linkedScriptBranchTarget.value = branchTarget || null;
586
+ linkedScriptDialogVisible.value = true;
587
+ }
588
+
589
+ function addLinkedScriptStep() {
590
+ const script = scripts.value.find((item) => item.id === linkedScriptTargetId.value);
591
+ if (!script) {
592
+ ElMessage.warning('请选择要连接的脚本');
593
+ return;
594
+ }
595
+ if (!linkedScriptExpectedActivity.value || script.appActivity !== linkedScriptExpectedActivity.value) {
596
+ ElMessage.error(
597
+ `无法连接:插入点 Activity 为 ${linkedScriptExpectedActivity.value || '-'},`
598
+ + `目标脚本入口 Activity 为 ${script.appActivity || '-'}`,
599
+ );
600
+ return;
601
+ }
602
+ const step = insertStep(createRunScriptStep(script), linkedScriptInsertIndex.value);
603
+ if (linkedScriptBranchTarget.value) {
604
+ attachStepToBranch(step.id, linkedScriptBranchTarget.value.stepId, linkedScriptBranchTarget.value.branch);
605
+ if (linkedScriptBranchTarget.value.updateTarget) {
606
+ updateBranchTarget(
607
+ linkedScriptBranchTarget.value.stepId,
608
+ linkedScriptBranchTarget.value.branch,
609
+ step.id,
610
+ );
611
+ }
612
+ }
613
+ linkedScriptInsertIndex.value = undefined;
614
+ linkedScriptBranchTarget.value = null;
615
+ linkedScriptExpectedActivity.value = '';
616
+ linkedScriptDialogVisible.value = false;
617
+ ElMessage.success('已添加连接脚本步骤');
618
+ }
619
+
620
+ function closeLinkedScriptDialog() {
621
+ linkedScriptDialogVisible.value = false;
622
+ linkedScriptInsertIndex.value = undefined;
623
+ linkedScriptBranchTarget.value = null;
624
+ linkedScriptExpectedActivity.value = '';
625
+ }
626
+
627
+ async function addAction(
628
+ action: RecorderAction,
629
+ index?: number,
630
+ branchTarget?: BranchTarget,
631
+ ) {
632
+ if (recordingBusy.value) return;
633
+ if (scriptActivityMismatch.value && !['keyBack', 'runScript', 'launchApp', 'delay'].includes(action)) return;
405
634
  if (!ensureAppPackageSelected()) return;
406
- if (action === 'delay') {
407
- await addDelayStep();
635
+ const scope = insertionScope(index);
636
+ if (scope !== 'main' && action !== 'launchApp' && action !== 'delay') {
637
+ ElMessage.warning('前置和后置操作仅支持启动 APP 和添加延时');
408
638
  return;
409
639
  }
410
- if (action === 'tap' || action === 'input' || action === 'assertExists') {
411
- await addStep(action);
640
+ if (scope === 'main' && action === 'launchApp') {
641
+ ElMessage.warning('启动 APP 只能添加到前置或后置操作');
412
642
  return;
413
643
  }
414
- if (action === 'keyBack') {
415
- steps.value = [...steps.value, createKeyStep(4, '返回键')];
644
+ if (action === 'runScript') {
645
+ openLinkedScriptDialog(index, branchTarget);
416
646
  return;
417
647
  }
648
+ if (action === 'delay') {
649
+ return addDelayStep(index);
650
+ }
651
+ if (action === 'tap' || action === 'input' || action === 'assertExists' || action === 'waitFor') {
652
+ return addStep(action, index);
653
+ }
654
+ if (action === 'keyBack') {
655
+ return insertStep(createKeyStep(4, '系统返回'), index);
656
+ }
418
657
  if (action === 'keyHome') {
419
- steps.value = [...steps.value, createKeyStep(3, 'Home 键')];
420
- return;
658
+ return insertStep(createKeyStep(3, 'Home 键'), index);
421
659
  }
422
660
  if (action === 'keyRecent') {
423
- steps.value = [...steps.value, createKeyStep(187, '最近任务')];
424
- return;
661
+ return insertStep(createKeyStep(187, '最近任务'), index);
425
662
  }
426
663
  if (action === 'keyPower') {
427
- steps.value = [...steps.value, createKeyStep(26, '电源键')];
428
- return;
664
+ return insertStep(createKeyStep(26, '电源键'), index);
429
665
  }
430
666
  if (action === 'waitActivity') {
431
667
  const input = await ElMessageBox.prompt('请输入目标 Activity', '等待 Activity', {
@@ -433,7 +669,7 @@ async function addAction(action: RecorderAction) {
433
669
  confirmButtonText: '添加',
434
670
  cancelButtonText: '取消',
435
671
  }).catch(() => null);
436
- if (input?.value) steps.value = [...steps.value, createWaitActivityStep(input.value)];
672
+ if (input?.value) return insertStep(createWaitActivityStep(input.value), index);
437
673
  return;
438
674
  }
439
675
  if (action === 'swipe') {
@@ -444,7 +680,7 @@ async function addAction(action: RecorderAction) {
444
680
  confirmButtonText: '添加',
445
681
  cancelButtonText: '取消',
446
682
  }).catch(() => null);
447
- if (input?.value) steps.value = [...steps.value, createSwipeStep(input.value)];
683
+ if (input?.value) return insertStep(createSwipeStep(input.value), index);
448
684
  return;
449
685
  }
450
686
  if (action === 'pinch') {
@@ -457,7 +693,7 @@ async function addAction(action: RecorderAction) {
457
693
  }).catch(() => null);
458
694
  if (!input?.value) return;
459
695
  const direction = input.value === '缩小' || input.value === 'in' ? 'in' : 'out';
460
- steps.value = [...steps.value, {
696
+ return insertStep({
461
697
  id: `step_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`,
462
698
  type: 'pinch',
463
699
  label: direction === 'out' ? '双指放大' : '双指缩小',
@@ -467,54 +703,73 @@ async function addAction(action: RecorderAction) {
467
703
  centerY: Math.round((props.deviceHeight || 1920) / 2),
468
704
  percent: 0.5,
469
705
  },
470
- }];
471
- return;
706
+ }, index);
472
707
  }
473
708
  if (action === 'launchApp') {
474
- steps.value = [...steps.value, {
709
+ return insertStep({
475
710
  id: `step_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`,
476
711
  type: 'launchApp',
477
- label: `启动 App ${form.appPackage}`,
712
+ label: `启动 APP ${form.appPackage}`,
478
713
  value: form.appPackage,
479
- }];
480
- return;
714
+ }, index);
481
715
  }
482
716
  const node = getSelectedNode();
483
717
  if (!node) return;
718
+ if (action === 'popupCondition') {
719
+ const step = createNodeActionStep('assertExists', '判断存在', node, { timeoutMs: 2000 });
720
+ const inserted = insertStep({ ...step, flow: { nodeKind: 'condition' } }, index);
721
+ ElMessage.success('已添加判断节点,请在节点面板配置是/否分支');
722
+ return inserted;
723
+ }
724
+ if (action === 'tapIfExists') {
725
+ return insertStep(createNodeActionStep('tapIfExists', '存在则点击', node, { timeoutMs: 2000 }), index);
726
+ }
727
+ if (action === 'inputIfExists') {
728
+ const input = await ElMessageBox.prompt('', '存在则输入', {
729
+ inputValue: '',
730
+ confirmButtonText: '添加',
731
+ cancelButtonText: '取消',
732
+ center: true,
733
+ }).catch(() => null);
734
+ if (!input) return;
735
+ return insertStep(createNodeActionStep('inputIfExists', '存在则输入', node, { timeoutMs: 2000, value: input.value }), index);
736
+ }
737
+ if (action === 'clearIfExists') {
738
+ return insertStep(createNodeActionStep('clearIfExists', '存在则清空', node, { timeoutMs: 2000 }), index);
739
+ }
740
+ if (action === 'backIfExists') {
741
+ return insertStep(createNodeActionStep('backIfExists', '存在则返回', node, { timeoutMs: 2000 }), index);
742
+ }
484
743
  if (action === 'clearInput') {
485
- steps.value = [...steps.value, createNodeActionStep('clearInput', '清空输入', node)];
486
- return;
744
+ return insertStep(createNodeActionStep('clearInput', '清空输入', node), index);
487
745
  }
488
746
  if (action === 'coordinateTap') {
489
747
  if (!node.bounds) {
490
748
  ElMessage.warning('当前组件没有可点击坐标');
491
749
  return;
492
750
  }
493
- steps.value = [...steps.value, {
751
+ return insertStep({
494
752
  id: `step_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`,
495
753
  type: 'coordinateTap',
496
754
  label: `点击坐标 ${node.bounds.centerX},${node.bounds.centerY}`,
497
755
  fallback: { strategy: 'bounds', centerX: node.bounds.centerX, centerY: node.bounds.centerY },
498
- }];
499
- return;
756
+ }, index);
500
757
  }
501
758
  if (action === 'longPress') {
502
759
  if (!node.bounds) {
503
760
  ElMessage.warning('当前组件没有可长按坐标');
504
761
  return;
505
762
  }
506
- steps.value = [...steps.value, {
763
+ return insertStep({
507
764
  id: `step_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`,
508
765
  type: 'longPress',
509
766
  label: `长按 ${node.label}`,
510
767
  fallback: { strategy: 'bounds', centerX: node.bounds.centerX, centerY: node.bounds.centerY },
511
768
  timeoutMs: 800,
512
- }];
513
- return;
769
+ }, index);
514
770
  }
515
771
  if (action === 'waitDisappear') {
516
- steps.value = [...steps.value, createNodeActionStep('waitDisappear', '等待元素消失', node, { timeoutMs: 10000 })];
517
- return;
772
+ return insertStep(createNodeActionStep('waitDisappear', '等待元素消失', node, { timeoutMs: 10000 }), index);
518
773
  }
519
774
  if (action === 'assertText') {
520
775
  const input = await ElMessageBox.prompt('请输入要断言的文本', '断言文本', {
@@ -522,82 +777,96 @@ async function addAction(action: RecorderAction) {
522
777
  confirmButtonText: '添加',
523
778
  cancelButtonText: '取消',
524
779
  }).catch(() => null);
525
- if (input?.value) steps.value = [...steps.value, createNodeActionStep('assertText', '断言文本', node, { value: input.value })];
526
- }
527
- }
528
-
529
- async function returnToPreviousPage() {
530
- const pending = pendingNavigation.value;
531
- if (!pending || resolvingNavigation.value) return;
532
- resolvingNavigation.value = true;
533
- try {
534
- await pressAppiumDeviceKey({ deviceId: selectedDeviceId.value, keyCode: 4 });
535
- let returned = false;
536
- for (let index = 0; index < 6; index += 1) {
537
- await wait(700);
538
- const snapshot = await loadTreeSnapshot();
539
- const activityReturned = !pending.beforeActivity || snapshot.activity === pending.beforeActivity;
540
- const leftDestination = !pending.afterSignature || snapshot.signature !== pending.afterSignature;
541
- if (activityReturned && leftDestination) {
542
- returned = true;
543
- break;
544
- }
545
- }
546
- if (!returned) throw new Error('设备页面未返回,请确认当前页面支持系统返回键');
547
- steps.value = [
548
- ...steps.value,
549
- createKeyStep(4),
550
- ...(pending.beforeActivity ? [createWaitActivityStep(pending.beforeActivity)] : []),
551
- ];
552
- pendingNavigation.value = null;
553
- ElMessage.success('已返回原页面,可继续录制');
554
- } catch (error) {
555
- ElMessage.error(error instanceof Error ? error.message : '返回原页面失败');
556
- } finally {
557
- resolvingNavigation.value = false;
780
+ if (input?.value) return insertStep(createNodeActionStep('assertText', '断言文本', node, { value: input.value }), index);
558
781
  }
559
782
  }
560
783
 
561
- function stayOnCurrentPage() {
562
- if (resolvingNavigation.value) return;
563
- pendingNavigation.value = null;
564
- ElMessage.success('继续在当前页面录制');
784
+ async function addBranchAction(index: number, branch: BranchName, action: RecorderAction) {
785
+ const source = steps.value[index];
786
+ if (!source) return;
787
+ const currentTargetId = branch === 'yes' ? source.flow?.yesTargetId : source.flow?.noTargetId;
788
+ const insertIndex = branchInsertIndex(source.id, branch, index);
789
+ const inserted = await addAction(action, insertIndex, {
790
+ stepId: source.id,
791
+ branch,
792
+ updateTarget: !currentTargetId,
793
+ });
794
+ if (!inserted) return;
795
+ attachStepToBranch(inserted.id, source.id, branch);
796
+ if (!currentTargetId) updateBranchTarget(source.id, branch, inserted.id);
565
797
  }
566
798
 
567
- async function saveAsBranchScript() {
799
+ async function saveOnActivityChange() {
568
800
  const pending = pendingNavigation.value;
569
801
  if (!pending || resolvingNavigation.value) return;
570
802
  if (!ensureAppPackageSelected()) return;
571
803
  resolvingNavigation.value = true;
572
- const activityName = (pending.afterActivity || 'branch').split(/[/.]/).filter(Boolean).pop() || 'branch';
573
- const branchName = `${form.name || 'Appium脚本'}-${activityName}-${Date.now().toString(36).slice(-4)}`;
804
+ const activityName = (pending.beforeActivity || 'Activity').split(/[/.]/).filter(Boolean).pop() || 'Activity';
805
+ const scriptName = form.name.trim() || `${activityName}-${Date.now().toString(36).slice(-4)}`;
574
806
  try {
575
807
  const payload = await saveAppiumScript({
576
- name: branchName,
808
+ id: selectedScript.value?.id,
809
+ name: scriptName,
577
810
  appPackage: form.appPackage,
578
- appActivity: form.appActivity,
811
+ appActivity: pending.beforeActivity || form.appActivity,
579
812
  deviceId: selectedDeviceId.value,
580
813
  steps: steps.value,
581
814
  });
582
815
  await loadScripts();
583
- selectedScriptId.value = payload.script.id;
584
- form.name = payload.script.name;
585
816
  pendingNavigation.value = null;
586
- ElMessage.success('已保存为新用例');
817
+ resetCurrentScript();
818
+ form.appActivity = pending.afterActivity;
819
+ ElMessage.success(`脚本「${payload.script.name}」已保存,可开始录制新 Activity`);
587
820
  } catch (error) {
588
- ElMessage.error(error instanceof Error ? error.message : '保存新用例失败');
821
+ ElMessage.error(error instanceof Error ? error.message : '保存脚本失败');
589
822
  } finally {
590
823
  resolvingNavigation.value = false;
591
824
  }
592
825
  }
593
826
 
594
827
  function removeStep(index: number) {
595
- steps.value = steps.value.filter((_step, stepIndex) => stepIndex !== index);
828
+ if (recordingLocked.value) return;
829
+ const removed = steps.value[index];
830
+ if (!removed) return;
831
+ const nextBranchStep = removed.flow?.parentConditionId && removed.flow.parentBranch
832
+ ? steps.value.slice(index + 1).find((step) => (
833
+ step.flow?.parentConditionId === removed.flow?.parentConditionId
834
+ && step.flow?.parentBranch === removed.flow?.parentBranch
835
+ ))
836
+ : undefined;
837
+ const replacementId = removed.flow?.parentConditionId
838
+ ? nextBranchStep?.id || ''
839
+ : steps.value[index + 1]?.id || '';
840
+ steps.value = steps.value
841
+ .filter((_step, stepIndex) => stepIndex !== index)
842
+ .map((step) => {
843
+ if (!step.flow) return step;
844
+ const flow = { ...step.flow };
845
+ let changed = false;
846
+ (['yesTargetId', 'noTargetId', 'successTargetId', 'failureTargetId'] as const).forEach((key) => {
847
+ if (flow[key] !== removed.id) return;
848
+ flow[key] = replacementId;
849
+ changed = true;
850
+ });
851
+ if (flow.parentConditionId === removed.id) {
852
+ delete flow.parentConditionId;
853
+ delete flow.parentBranch;
854
+ changed = true;
855
+ }
856
+ return changed ? { ...step, flow } : step;
857
+ });
858
+ }
859
+
860
+ function updateStep(index: number, step: AppiumRecordedStep) {
861
+ if (recordingLocked.value) return;
862
+ const nextSteps = [...steps.value];
863
+ nextSteps[index] = step;
864
+ steps.value = nextSteps;
596
865
  }
597
866
 
598
867
  async function editInputStep(index: number) {
599
868
  const step = steps.value[index];
600
- if (!step || step.type !== 'input' || recordingLocked.value) return;
869
+ if (!step || (step.type !== 'input' && step.type !== 'inputIfExists') || recordingLocked.value) return;
601
870
  const input = await ElMessageBox.prompt('请输入新的录制文本', '修改输入内容', {
602
871
  inputValue: step.value || '',
603
872
  confirmButtonText: '保存',
@@ -623,7 +892,11 @@ function loadScript(script: AppiumRecordedScript) {
623
892
  }
624
893
 
625
894
  function loadSelectedScript() {
626
- if (selectedScript.value) loadScript(selectedScript.value);
895
+ if (selectedScript.value) {
896
+ loadScript(selectedScript.value);
897
+ return;
898
+ }
899
+ resetCurrentScript();
627
900
  }
628
901
 
629
902
  function resetCurrentScript() {
@@ -699,10 +972,10 @@ async function replayScript() {
699
972
  replayOutput.value = '';
700
973
  try {
701
974
  const result = await replayAppiumScript({ id: selectedScript.value.id, deviceId: selectedDeviceId.value });
702
- replayOutput.value = trimReplayOutput(result.output);
975
+ replayOutput.value = result.output || '';
703
976
  if (result.success) ElMessage.success('回放完成');
704
977
  } catch (error) {
705
- replayOutput.value = trimReplayOutput(error instanceof Error ? error.message : '回放失败');
978
+ replayOutput.value = error instanceof Error ? error.message : '回放失败';
706
979
  ElMessage.error('回放失败,详情见回放输出');
707
980
  } finally {
708
981
  replaying.value = false;
@@ -711,6 +984,15 @@ async function replayScript() {
711
984
 
712
985
  onMounted(loadScripts);
713
986
 
987
+ watch(() => form.appPackage, (packageName) => {
988
+ if (!packageName) return;
989
+ steps.value = steps.value.map((step) => (
990
+ step.type === 'launchApp'
991
+ ? { ...step, label: `启动 APP ${packageName}`, value: packageName }
992
+ : step
993
+ ));
994
+ });
995
+
714
996
  watch(
715
997
  () => [props.active, props.playgroundDeviceId] as const,
716
998
  ([active, deviceId]) => {
@@ -725,6 +1007,7 @@ watch(
725
1007
  <div v-if="active" class="appium-header-actions">
726
1008
  <el-select
727
1009
  v-model="selectedScriptId"
1010
+ clearable
728
1011
  placeholder="选择脚本"
729
1012
  class="appium-header-actions__script"
730
1013
  @change="loadSelectedScript"
@@ -803,11 +1086,30 @@ watch(
803
1086
  />
804
1087
  </el-select>
805
1088
  </div>
1089
+ <el-alert
1090
+ v-if="scriptActivityMismatch"
1091
+ class="appium-activity-lock-alert"
1092
+ title="当前脚本已锁定"
1093
+ type="warning"
1094
+ show-icon
1095
+ :closable="false"
1096
+ >
1097
+ <div class="appium-activity-lock-alert__details">
1098
+ <div>
1099
+ <strong>脚本绑定</strong>
1100
+ <code>{{ selectedScript?.appActivity || '-' }}</code>
1101
+ </div>
1102
+ <div>
1103
+ <strong>当前 Activity</strong>
1104
+ <code>{{ currentActivity || '正在获取' }}</code>
1105
+ </div>
1106
+ <p>当前仅允许插入“系统返回”或连接与插入点 Activity 匹配的脚本,其他流程编辑已锁定。</p>
1107
+ </div>
1108
+ </el-alert>
806
1109
  <NodeDetail
807
1110
  :node="selectedNode"
808
- :disabled="recordingLocked"
1111
+ :current-activity="currentActivity"
809
1112
  :loading="recordingTap"
810
- @add-action="addAction"
811
1113
  />
812
1114
  </section>
813
1115
 
@@ -816,14 +1118,46 @@ watch(
816
1118
  <RecordedSteps
817
1119
  :steps="steps"
818
1120
  :disabled="recordingLocked"
1121
+ :allowed-locked-actions="scriptActivityMismatch && !recordingBusy ? ['keyBack', 'runScript', 'launchApp', 'delay'] : []"
819
1122
  @remove="removeStep"
820
1123
  @add-delay="addDelayStep"
1124
+ @insert-action="(index, action) => addAction(action, index)"
1125
+ @insert-branch-action="addBranchAction"
821
1126
  @edit-input="editInputStep"
1127
+ @update-step="updateStep"
822
1128
  />
823
1129
  </section>
824
1130
 
825
1131
  <section class="appium-workbench__section">
826
- <h3>回放输出</h3>
1132
+ <div class="appium-section-title">
1133
+ <h3>回放输出</h3>
1134
+ <div class="appium-section-title__actions">
1135
+ <el-button
1136
+ size="small"
1137
+ :icon="CopyDocument"
1138
+ :disabled="!replayOutput"
1139
+ @click="copyReplayOutput"
1140
+ >
1141
+ 复制
1142
+ </el-button>
1143
+ <el-button
1144
+ size="small"
1145
+ :icon="FullScreen"
1146
+ :disabled="!replayOutput"
1147
+ @click="replayOutputDialogVisible = true"
1148
+ >
1149
+ 展开查看
1150
+ </el-button>
1151
+ <el-button
1152
+ size="small"
1153
+ :icon="Delete"
1154
+ :disabled="!replayOutput"
1155
+ @click="clearReplayOutput"
1156
+ >
1157
+ 清除
1158
+ </el-button>
1159
+ </div>
1160
+ </div>
827
1161
  <pre class="appium-replay-output">{{ visibleReplayOutput }}</pre>
828
1162
  </section>
829
1163
  </div>
@@ -864,13 +1198,14 @@ watch(
864
1198
 
865
1199
  <el-dialog
866
1200
  :model-value="Boolean(pendingNavigation)"
867
- title="检测到页面变化"
868
- width="680px"
1201
+ title="检测到 Activity 跳转"
1202
+ width="560px"
869
1203
  :close-on-click-modal="false"
870
- @close="stayOnCurrentPage"
1204
+ :close-on-press-escape="false"
1205
+ :show-close="false"
871
1206
  >
872
1207
  <div class="appium-navigation-dialog">
873
- <p>录制点击后检测到页面已变化。</p>
1208
+ <p>当前录制仅支持单个 Activity。检测到 Activity 已跳转,请先保存当前脚本。</p>
874
1209
  <dl>
875
1210
  <div>
876
1211
  <dt>点击前</dt>
@@ -884,11 +1219,56 @@ watch(
884
1219
  </div>
885
1220
  <template #footer>
886
1221
  <div class="appium-navigation-footer">
887
- <el-button :disabled="resolvingNavigation" @click="stayOnCurrentPage">留在当前页面继续录制</el-button>
888
- <el-button :loading="resolvingNavigation" @click="saveAsBranchScript">保存为新用例</el-button>
889
- <el-button type="primary" :loading="resolvingNavigation" @click="returnToPreviousPage">添加返回键继续录制</el-button>
1222
+ <el-button type="primary" :loading="resolvingNavigation" @click="saveOnActivityChange">保存脚本</el-button>
890
1223
  </div>
891
1224
  </template>
892
1225
  </el-dialog>
1226
+
1227
+ <el-dialog
1228
+ v-model="linkedScriptDialogVisible"
1229
+ title="连接脚本"
1230
+ width="520px"
1231
+ align-center
1232
+ @closed="closeLinkedScriptDialog"
1233
+ >
1234
+ <el-form label-position="top">
1235
+ <el-form-item label="插入点 Activity">
1236
+ <code class="appium-linked-script-activity">{{ linkedScriptExpectedActivity || '-' }}</code>
1237
+ </el-form-item>
1238
+ <el-form-item label="选择入口 Activity 匹配的脚本">
1239
+ <el-select
1240
+ v-model="linkedScriptTargetId"
1241
+ filterable
1242
+ placeholder="选择要连接的脚本"
1243
+ class="appium-linked-script-select"
1244
+ >
1245
+ <el-option
1246
+ v-for="script in compatibleLinkableScripts"
1247
+ :key="script.id"
1248
+ :label="`${script.name} · ${script.steps.length} 步`"
1249
+ :value="script.id"
1250
+ />
1251
+ </el-select>
1252
+ </el-form-item>
1253
+ </el-form>
1254
+ <template #footer>
1255
+ <el-button @click="closeLinkedScriptDialog">取消</el-button>
1256
+ <el-button type="primary" @click="addLinkedScriptStep">添加连接</el-button>
1257
+ </template>
1258
+ </el-dialog>
1259
+
1260
+ <el-dialog
1261
+ v-model="replayOutputDialogVisible"
1262
+ title="回放输出"
1263
+ width="82vw"
1264
+ top="6vh"
1265
+ >
1266
+ <pre class="appium-replay-output appium-replay-output--dialog">{{ replayOutput }}</pre>
1267
+ <template #footer>
1268
+ <el-button :icon="CopyDocument" :disabled="!replayOutput" @click="copyReplayOutput">复制</el-button>
1269
+ <el-button :icon="Delete" :disabled="!replayOutput" @click="clearReplayOutput">清除</el-button>
1270
+ <el-button type="primary" @click="replayOutputDialogVisible = false">关闭</el-button>
1271
+ </template>
1272
+ </el-dialog>
893
1273
  </section>
894
1274
  </template>