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,13 +1,16 @@
1
1
  <script setup lang="ts">
2
- import { computed, onMounted, reactive, shallowRef, watch } from 'vue';
2
+ import { computed, onMounted, onUnmounted, reactive, shallowRef, watch } from 'vue';
3
3
  import { ElMessage, ElMessageBox } from 'element-plus';
4
- import { Check, CopyDocument, Delete, FullScreen, Refresh, VideoPlay } from '@element-plus/icons-vue';
4
+ import { Check, CopyDocument, Delete, Download, FullScreen, Refresh, Upload, 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
+ appiumScriptDownloadUrl,
8
9
  deleteAppiumScript,
9
10
  getAppiumScripts,
10
11
  getAppiumTree,
12
+ importAppiumScript,
13
+ launchAppiumDeviceApp,
11
14
  replayAppiumScript,
12
15
  saveAppiumScript,
13
16
  tapAppiumDevice,
@@ -25,6 +28,8 @@ type BranchTarget = {
25
28
  stepId: string;
26
29
  branch: BranchName;
27
30
  updateTarget?: boolean;
31
+ entryTargetId?: string;
32
+ nextTargetId?: string;
28
33
  };
29
34
  type RecorderAction =
30
35
  | 'delay'
@@ -49,6 +54,36 @@ type RecorderAction =
49
54
  | 'longPress'
50
55
  | 'pinch';
51
56
 
57
+ const WORKBENCH_TAB_STORAGE_KEY = 'android-midscene-automation:appium-workbench-tab';
58
+ const AUTO_TREE_REFRESH_INTERVAL_MS = 1800;
59
+
60
+ const insertableRecorderActions: RecorderAction[] = [
61
+ 'delay',
62
+ 'tap',
63
+ 'input',
64
+ 'assertExists',
65
+ 'waitFor',
66
+ 'tapIfExists',
67
+ 'inputIfExists',
68
+ 'clearIfExists',
69
+ 'backIfExists',
70
+ 'popupCondition',
71
+ 'runScript',
72
+ 'keyBack',
73
+ 'keyHome',
74
+ 'keyRecent',
75
+ 'keyPower',
76
+ 'waitActivity',
77
+ 'swipe',
78
+ 'clearInput',
79
+ 'coordinateTap',
80
+ 'launchApp',
81
+ 'waitDisappear',
82
+ 'assertText',
83
+ 'longPress',
84
+ 'pinch',
85
+ ];
86
+
52
87
  const props = defineProps<{
53
88
  active: boolean;
54
89
  appPresets: AppPreset[];
@@ -78,7 +113,8 @@ const tree = shallowRef<AppiumNode | null>(null);
78
113
  const selectedNode = shallowRef<AppiumNode | null>(null);
79
114
  const scripts = shallowRef<AppiumRecordedScript[]>([]);
80
115
  const selectedScriptId = shallowRef('');
81
- const activeWorkbenchTab = shallowRef<'recording' | 'scripts'>('recording');
116
+ const storedWorkbenchTab = window.localStorage.getItem(WORKBENCH_TAB_STORAGE_KEY);
117
+ const activeWorkbenchTab = shallowRef<'recording' | 'scripts'>(storedWorkbenchTab === 'scripts' ? 'scripts' : 'recording');
82
118
  const steps = shallowRef<AppiumRecordedStep[]>([]);
83
119
  const rawXml = shallowRef('');
84
120
  const currentActivity = shallowRef('');
@@ -91,14 +127,21 @@ const linkedScriptBranchTarget = shallowRef<BranchTarget | null>(null);
91
127
  const linkedScriptExpectedActivity = shallowRef('');
92
128
  const loadingTree = shallowRef(false);
93
129
  const saving = shallowRef(false);
130
+ const importingScript = shallowRef(false);
131
+ const scriptImportInput = shallowRef<HTMLInputElement | null>(null);
94
132
  const deletingScriptId = shallowRef('');
95
133
  const replaying = shallowRef(false);
96
134
  const recordingTap = shallowRef(false);
97
135
  const resolvingNavigation = shallowRef(false);
136
+ const launchingApp = shallowRef(false);
98
137
  const pendingNavigation = shallowRef<{
99
138
  beforeActivity: string;
100
139
  afterActivity: string;
101
140
  } | null>(null);
141
+ type TreePayload = Awaited<ReturnType<typeof getAppiumTree>>;
142
+ let treeRequest: Promise<TreePayload> | null = null;
143
+ let autoTreeRefreshTimer: number | null = null;
144
+ let autoTreeRefreshInFlight = false;
102
145
 
103
146
  const form = reactive({
104
147
  name: '',
@@ -106,11 +149,38 @@ const form = reactive({
106
149
  appActivity: '',
107
150
  });
108
151
 
152
+ const savedDraftSnapshot = shallowRef('');
153
+ const currentDraftSnapshot = computed(() => JSON.stringify({
154
+ name: form.name,
155
+ appPackage: form.appPackage,
156
+ appActivity: form.appActivity,
157
+ steps: steps.value,
158
+ }));
159
+ const hasUnsavedChanges = computed(() => currentDraftSnapshot.value !== savedDraftSnapshot.value);
160
+
161
+ function markDraftSaved() {
162
+ savedDraftSnapshot.value = currentDraftSnapshot.value;
163
+ }
164
+
165
+ function warnBeforeUnload(event: BeforeUnloadEvent) {
166
+ if (!props.active || !hasUnsavedChanges.value) return;
167
+ event.preventDefault();
168
+ event.returnValue = '';
169
+ }
170
+
171
+ markDraftSaved();
172
+
109
173
  const selectedScript = computed(() => scripts.value.find((script) => script.id === selectedScriptId.value) || null);
110
174
  const linkableScripts = computed(() => scripts.value.filter((script) => script.id !== selectedScriptId.value));
111
175
  const compatibleLinkableScripts = computed(() => linkableScripts.value.filter((script) => (
112
- Boolean(linkedScriptExpectedActivity.value)
113
- && script.appActivity === linkedScriptExpectedActivity.value
176
+ script.appPackage === form.appPackage
177
+ && (
178
+ Boolean(linkedScriptBranchTarget.value)
179
+ || (
180
+ Boolean(linkedScriptExpectedActivity.value)
181
+ && script.appActivity === linkedScriptExpectedActivity.value
182
+ )
183
+ )
114
184
  )));
115
185
  const visibleReplayOutput = computed(() => {
116
186
  const lines = replayOutput.value.split(/\r?\n/);
@@ -142,15 +212,15 @@ function insertStep(step: AppiumRecordedStep, index?: number) {
142
212
  const nextSteps = [...steps.value];
143
213
  let insertedStep = step;
144
214
  if (index === -1) {
145
- nextSteps.unshift(step);
215
+ nextSteps.unshift(insertedStep);
146
216
  } else if (typeof index === 'number') {
147
217
  const previousStep = nextSteps[index];
148
218
  const previousTargetId = previousStep?.flow?.successTargetId;
149
219
  if (previousStep && previousTargetId) {
150
220
  insertedStep = {
151
- ...step,
221
+ ...insertedStep,
152
222
  flow: {
153
- ...(step.flow || {}),
223
+ ...(insertedStep.flow || {}),
154
224
  successTargetId: previousTargetId,
155
225
  },
156
226
  };
@@ -384,6 +454,31 @@ function treeSignature(root: AppiumNode | null) {
384
454
  .join('\n');
385
455
  }
386
456
 
457
+ async function fetchTreePayload(forceFresh = false) {
458
+ if (forceFresh && treeRequest) await treeRequest.catch(() => undefined);
459
+ if (treeRequest) return treeRequest;
460
+
461
+ const request = getAppiumTree(selectedDeviceId.value);
462
+ treeRequest = request;
463
+ try {
464
+ return await request;
465
+ } finally {
466
+ if (treeRequest === request) treeRequest = null;
467
+ }
468
+ }
469
+
470
+ function applyTreeSnapshot(payload: TreePayload, parsedTree: AppiumNode | null, clearSelection: boolean) {
471
+ const previousSelection = clearSelection ? null : selectedNode.value;
472
+ rawXml.value = payload.xml;
473
+ currentActivity.value = payload.activity || '';
474
+ tree.value = parsedTree;
475
+ selectedNode.value = previousSelection
476
+ ? flattenNodes(parsedTree).find((node) => (
477
+ node.id === previousSelection.id || node.xpath === previousSelection.xpath
478
+ )) || null
479
+ : null;
480
+ }
481
+
387
482
  function currentPageSnapshot(node = selectedNode.value) {
388
483
  return {
389
484
  activity: currentActivity.value,
@@ -405,13 +500,10 @@ function getSelectedNode() {
405
500
  return null;
406
501
  }
407
502
 
408
- async function loadTreeSnapshot(clearSelection = true) {
409
- const payload = await getAppiumTree(selectedDeviceId.value);
503
+ async function loadTreeSnapshot(clearSelection = true, forceFresh = false) {
504
+ const payload = await fetchTreePayload(forceFresh);
410
505
  const parsedTree = parseWindowHierarchy(payload.xml);
411
- rawXml.value = payload.xml;
412
- currentActivity.value = payload.activity || '';
413
- tree.value = parsedTree;
414
- if (clearSelection) selectedNode.value = null;
506
+ applyTreeSnapshot(payload, parsedTree, clearSelection);
415
507
  return {
416
508
  activity: payload.activity || '',
417
509
  signature: treeSignature(parsedTree),
@@ -419,6 +511,56 @@ async function loadTreeSnapshot(clearSelection = true) {
419
511
  };
420
512
  }
421
513
 
514
+ function stopAutoTreeRefresh() {
515
+ if (autoTreeRefreshTimer === null) return;
516
+ window.clearTimeout(autoTreeRefreshTimer);
517
+ autoTreeRefreshTimer = null;
518
+ }
519
+
520
+ function scheduleAutoTreeRefresh(delay = AUTO_TREE_REFRESH_INTERVAL_MS) {
521
+ stopAutoTreeRefresh();
522
+ if (!props.active || !selectedDeviceId.value || replaying.value) return;
523
+ autoTreeRefreshTimer = window.setTimeout(() => {
524
+ autoTreeRefreshTimer = null;
525
+ void syncTreeWhenPageChanges();
526
+ }, delay);
527
+ }
528
+
529
+ async function pauseAutoTreeRefresh() {
530
+ stopAutoTreeRefresh();
531
+ const pendingRequest = treeRequest;
532
+ if (pendingRequest) await pendingRequest.catch(() => undefined);
533
+ await wait(300);
534
+ }
535
+
536
+ async function syncTreeWhenPageChanges() {
537
+ if (
538
+ autoTreeRefreshInFlight
539
+ || loadingTree.value
540
+ || recordingBusy.value
541
+ || replaying.value
542
+ || launchingApp.value
543
+ ) {
544
+ scheduleAutoTreeRefresh();
545
+ return;
546
+ }
547
+
548
+ autoTreeRefreshInFlight = true;
549
+ try {
550
+ const payload = await fetchTreePayload();
551
+ const changed = (payload.activity || '') !== currentActivity.value
552
+ || payload.xml !== rawXml.value;
553
+ if (changed) {
554
+ applyTreeSnapshot(payload, parseWindowHierarchy(payload.xml), false);
555
+ }
556
+ } catch {
557
+ // A transient ADB/UIAutomator failure should not interrupt recording.
558
+ } finally {
559
+ autoTreeRefreshInFlight = false;
560
+ scheduleAutoTreeRefresh();
561
+ }
562
+ }
563
+
422
564
  async function loadScripts() {
423
565
  const payload = await getAppiumScripts();
424
566
  scripts.value = payload.scripts || [];
@@ -428,6 +570,7 @@ async function loadScripts() {
428
570
  }
429
571
 
430
572
  async function switchDevice(deviceId: string) {
573
+ stopAutoTreeRefresh();
431
574
  tree.value = null;
432
575
  selectedNode.value = null;
433
576
  currentActivity.value = '';
@@ -438,20 +581,21 @@ async function triggerDeviceKey(keyCode: number) {
438
581
  if (!selectedDeviceId.value) return;
439
582
  try {
440
583
  await props.triggerDeviceKey(keyCode);
584
+ scheduleAutoTreeRefresh(500);
441
585
  } catch (error) {
442
586
  ElMessage.error(error instanceof Error ? error.message : '设备操作失败');
443
587
  }
444
588
  }
445
589
 
446
590
  async function refreshTree() {
447
- if (loadingTree.value) return;
591
+ if (loadingTree.value || replaying.value) return;
448
592
  if (!selectedDeviceId.value) {
449
593
  ElMessage.warning('请选择设备');
450
594
  return;
451
595
  }
452
596
  loadingTree.value = true;
453
597
  try {
454
- await loadTreeSnapshot();
598
+ await loadTreeSnapshot(true, true);
455
599
  props.refreshDevicePreview();
456
600
  } catch (error) {
457
601
  ElMessage.error(error instanceof Error ? error.message : '刷新组件树失败');
@@ -460,6 +604,42 @@ async function refreshTree() {
460
604
  }
461
605
  }
462
606
 
607
+ async function executeFlowStep(index: number) {
608
+ const step = steps.value[index];
609
+ if (!step || step.type !== 'launchApp') return;
610
+ if (launchingApp.value) return;
611
+ if (!selectedDeviceId.value) {
612
+ ElMessage.warning('请选择设备');
613
+ return;
614
+ }
615
+ const packageName = step.value?.trim() || form.appPackage;
616
+ if (!packageName || !props.appPresets.some((app) => app.packageName === packageName)) {
617
+ ElMessage.warning('启动节点没有匹配的预设 App');
618
+ return;
619
+ }
620
+
621
+ launchingApp.value = true;
622
+ try {
623
+ await launchAppiumDeviceApp({
624
+ deviceId: selectedDeviceId.value,
625
+ packageName,
626
+ });
627
+ props.refreshDevicePreview();
628
+ await wait(800);
629
+ await loadTreeSnapshot(true, true);
630
+ props.refreshDevicePreview();
631
+ if (scriptActivityMismatch.value) {
632
+ ElMessage.warning(`App 已启动,当前页面仍为 ${currentActivity.value || '-'}`);
633
+ } else {
634
+ ElMessage.success('已进入脚本绑定页面,编辑锁定已解除');
635
+ }
636
+ } catch (error) {
637
+ ElMessage.error(error instanceof Error ? error.message : '启动 App 失败');
638
+ } finally {
639
+ launchingApp.value = false;
640
+ }
641
+ }
642
+
463
643
  function selectNodeFromPoint(point: { x: number; y: number }) {
464
644
  const node = findSmallestNodeAtPoint(tree.value, point.x, point.y);
465
645
  if (node) {
@@ -485,6 +665,7 @@ async function swipePreview(gesture: {
485
665
  gesture.endY,
486
666
  gesture.duration,
487
667
  );
668
+ scheduleAutoTreeRefresh(500);
488
669
  } catch (error) {
489
670
  ElMessage.error(error instanceof Error ? error.message : '滑动失败');
490
671
  }
@@ -510,7 +691,7 @@ async function recordTapStep(index?: number) {
510
691
  try {
511
692
  await tapAppiumDevice({ deviceId: selectedDeviceId.value, x: bounds.centerX, y: bounds.centerY });
512
693
  await wait(1200);
513
- const after = await loadTreeSnapshot();
694
+ const after = await loadTreeSnapshot(true, true);
514
695
  steps.value = steps.value.map((item) => (
515
696
  item.id === step.id ? { ...item, pageAfter: currentPageSnapshot() } : item
516
697
  ));
@@ -533,7 +714,7 @@ async function recordTapStep(index?: number) {
533
714
  }
534
715
 
535
716
  async function addStep(type: NodeStepType, index?: number) {
536
- if (recordingLocked.value) return;
717
+ if (recordingBusy.value) return;
537
718
  if (!selectedNode.value) return;
538
719
  if (!ensureAppPackageSelected()) return;
539
720
  if (type === 'tap') {
@@ -554,7 +735,7 @@ async function addStep(type: NodeStepType, index?: number) {
554
735
  }
555
736
 
556
737
  async function addDelayStep(index?: number) {
557
- if (recordingLocked.value) return;
738
+ if (recordingBusy.value) return;
558
739
  const input = await ElMessageBox.prompt('请输入延时时间,单位毫秒', '添加延时', {
559
740
  inputValue: '1000',
560
741
  inputPattern: /^[1-9]\d{0,5}$/,
@@ -573,19 +754,24 @@ function openLinkedScriptDialog(index?: number, branchTarget?: BranchTarget) {
573
754
  ElMessage.warning('暂无可连接的已保存脚本');
574
755
  return;
575
756
  }
757
+ linkedScriptBranchTarget.value = branchTarget || null;
576
758
  linkedScriptExpectedActivity.value = expectedActivityAfterStep(index);
577
- if (!linkedScriptExpectedActivity.value) {
759
+ if (!linkedScriptBranchTarget.value && !linkedScriptExpectedActivity.value) {
578
760
  ElMessage.warning('无法确定当前插入点的 Activity,请先刷新组件树');
579
761
  return;
580
762
  }
581
763
  if (!compatibleLinkableScripts.value.length) {
582
- ElMessage.warning(`没有入口 Activity 为 ${linkedScriptExpectedActivity.value} 的可连接脚本`);
764
+ ElMessage.warning(
765
+ linkedScriptBranchTarget.value
766
+ ? '没有属于当前 App 的可连接脚本'
767
+ : `没有入口 Activity 为 ${linkedScriptExpectedActivity.value} 的可连接脚本`,
768
+ );
583
769
  linkedScriptExpectedActivity.value = '';
770
+ linkedScriptBranchTarget.value = null;
584
771
  return;
585
772
  }
586
773
  linkedScriptTargetId.value = compatibleLinkableScripts.value[0]?.id || '';
587
774
  linkedScriptInsertIndex.value = index;
588
- linkedScriptBranchTarget.value = branchTarget || null;
589
775
  linkedScriptDialogVisible.value = true;
590
776
  }
591
777
 
@@ -595,7 +781,14 @@ function addLinkedScriptStep() {
595
781
  ElMessage.warning('请选择要连接的脚本');
596
782
  return;
597
783
  }
598
- if (!linkedScriptExpectedActivity.value || script.appActivity !== linkedScriptExpectedActivity.value) {
784
+ if (script.appPackage !== form.appPackage) {
785
+ ElMessage.error('连接脚本必须属于当前 App');
786
+ return;
787
+ }
788
+ if (
789
+ !linkedScriptBranchTarget.value
790
+ && (!linkedScriptExpectedActivity.value || script.appActivity !== linkedScriptExpectedActivity.value)
791
+ ) {
599
792
  ElMessage.error(
600
793
  `无法连接:插入点 Activity 为 ${linkedScriptExpectedActivity.value || '-'},`
601
794
  + `目标脚本入口 Activity 为 ${script.appActivity || '-'}`,
@@ -605,11 +798,24 @@ function addLinkedScriptStep() {
605
798
  const step = insertStep(createRunScriptStep(script), linkedScriptInsertIndex.value);
606
799
  if (linkedScriptBranchTarget.value) {
607
800
  attachStepToBranch(step.id, linkedScriptBranchTarget.value.stepId, linkedScriptBranchTarget.value.branch);
801
+ if (linkedScriptBranchTarget.value.nextTargetId) {
802
+ steps.value = steps.value.map((item) => (
803
+ item.id === step.id
804
+ ? {
805
+ ...item,
806
+ flow: {
807
+ ...(item.flow || {}),
808
+ successTargetId: linkedScriptBranchTarget.value?.nextTargetId,
809
+ },
810
+ }
811
+ : item
812
+ ));
813
+ }
608
814
  if (linkedScriptBranchTarget.value.updateTarget) {
609
815
  updateBranchTarget(
610
816
  linkedScriptBranchTarget.value.stepId,
611
817
  linkedScriptBranchTarget.value.branch,
612
- step.id,
818
+ linkedScriptBranchTarget.value.entryTargetId || step.id,
613
819
  );
614
820
  }
615
821
  }
@@ -633,7 +839,6 @@ async function addAction(
633
839
  branchTarget?: BranchTarget,
634
840
  ) {
635
841
  if (recordingBusy.value) return;
636
- if (scriptActivityMismatch.value && !['keyBack', 'runScript', 'launchApp', 'delay'].includes(action)) return;
637
842
  if (!ensureAppPackageSelected()) return;
638
843
  if (action === 'launchApp' && steps.value.some((step) => step.type === 'launchApp')) {
639
844
  ElMessage.warning('启动 APP 节点只能添加一个');
@@ -786,16 +991,91 @@ async function addAction(
786
991
  async function addBranchAction(index: number, branch: BranchName, action: RecorderAction) {
787
992
  const source = steps.value[index];
788
993
  if (!source) return;
994
+ const existingBranchSteps = steps.value.filter((step) => (
995
+ step.flow?.parentConditionId === source.id
996
+ && step.flow.parentBranch === branch
997
+ ));
789
998
  const currentTargetId = branch === 'yes' ? source.flow?.yesTargetId : source.flow?.noTargetId;
999
+ const currentTarget = steps.value.find((step) => step.id === currentTargetId);
1000
+ const targetIsBranchStep = currentTarget?.flow?.parentConditionId === source.id
1001
+ && currentTarget.flow.parentBranch === branch;
790
1002
  const insertIndex = branchInsertIndex(source.id, branch, index);
791
1003
  const inserted = await addAction(action, insertIndex, {
792
1004
  stepId: source.id,
793
1005
  branch,
794
- updateTarget: !currentTargetId,
1006
+ updateTarget: !currentTargetId || !targetIsBranchStep,
1007
+ entryTargetId: existingBranchSteps[0]?.id,
1008
+ nextTargetId: !targetIsBranchStep ? currentTargetId : undefined,
795
1009
  });
796
1010
  if (!inserted) return;
797
1011
  attachStepToBranch(inserted.id, source.id, branch);
798
- if (!currentTargetId) updateBranchTarget(source.id, branch, inserted.id);
1012
+ if (!currentTargetId || !targetIsBranchStep) {
1013
+ steps.value = steps.value.map((step) => (
1014
+ step.id === inserted.id && currentTargetId
1015
+ ? {
1016
+ ...step,
1017
+ flow: { ...(step.flow || {}), successTargetId: currentTargetId },
1018
+ }
1019
+ : step
1020
+ ));
1021
+ updateBranchTarget(source.id, branch, existingBranchSteps[0]?.id || inserted.id);
1022
+ }
1023
+ }
1024
+
1025
+ function connectBranchToNext(index: number, branch: BranchName) {
1026
+ if (recordingLocked.value) return;
1027
+ const condition = steps.value[index];
1028
+ if (!condition) return;
1029
+ const nextMainStep = steps.value.slice(index + 1).find((step) => !step.flow?.parentConditionId);
1030
+ if (!nextMainStep) {
1031
+ ElMessage.warning('当前判断节点后没有可连接的主流程节点');
1032
+ return;
1033
+ }
1034
+
1035
+ const branchSteps = steps.value.filter((step) => (
1036
+ step.flow?.parentConditionId === condition.id
1037
+ && step.flow.parentBranch === branch
1038
+ ));
1039
+ if (!branchSteps.length) {
1040
+ updateBranchTarget(condition.id, branch, nextMainStep.id);
1041
+ } else {
1042
+ const firstBranchStep = branchSteps[0];
1043
+ const lastBranchStep = branchSteps[branchSteps.length - 1];
1044
+ updateBranchTarget(condition.id, branch, firstBranchStep.id);
1045
+ steps.value = steps.value.map((step) => (
1046
+ step.id === lastBranchStep.id
1047
+ ? {
1048
+ ...step,
1049
+ flow: { ...(step.flow || {}), successTargetId: nextMainStep.id },
1050
+ }
1051
+ : step
1052
+ ));
1053
+ }
1054
+ ElMessage.success(`${branch === 'yes' ? '是' : '否'}分支已连接到「${nextMainStep.label}」`);
1055
+ }
1056
+
1057
+ function disconnectBranchFromNext(index: number, branch: BranchName) {
1058
+ if (recordingLocked.value) return;
1059
+ const condition = steps.value[index];
1060
+ if (!condition) return;
1061
+ const branchSteps = steps.value.filter((step) => (
1062
+ step.flow?.parentConditionId === condition.id
1063
+ && step.flow.parentBranch === branch
1064
+ ));
1065
+ if (!branchSteps.length) {
1066
+ updateBranchTarget(condition.id, branch, '');
1067
+ } else {
1068
+ const lastBranchStep = branchSteps[branchSteps.length - 1];
1069
+ steps.value = steps.value.map((step) => (
1070
+ step.id === lastBranchStep.id
1071
+ ? {
1072
+ ...step,
1073
+ flow: { ...(step.flow || {}), successTargetId: '' },
1074
+ }
1075
+ : step
1076
+ ));
1077
+ }
1078
+ ElMessage.success(`${branch === 'yes' ? '是' : '否'}分支已取消连接`);
799
1079
  }
800
1080
 
801
1081
  async function saveOnActivityChange() {
@@ -818,6 +1098,7 @@ async function saveOnActivityChange() {
818
1098
  pendingNavigation.value = null;
819
1099
  resetCurrentScript();
820
1100
  form.appActivity = pending.afterActivity;
1101
+ markDraftSaved();
821
1102
  ElMessage.success(`脚本「${payload.script.name}」已保存,可开始录制新 Activity`);
822
1103
  } catch (error) {
823
1104
  ElMessage.error(error instanceof Error ? error.message : '保存脚本失败');
@@ -891,6 +1172,7 @@ function loadScript(script: AppiumRecordedScript) {
891
1172
  }
892
1173
  steps.value = (script.steps || []).map(normalizeLegacyFlowScope);
893
1174
  activeWorkbenchTab.value = 'recording';
1175
+ markDraftSaved();
894
1176
  }
895
1177
 
896
1178
  function loadSelectedScript() {
@@ -907,6 +1189,7 @@ function resetCurrentScript() {
907
1189
  form.appActivity = '';
908
1190
  steps.value = [];
909
1191
  replayOutput.value = '';
1192
+ markDraftSaved();
910
1193
  }
911
1194
 
912
1195
  function formatScriptTime(value: string) {
@@ -943,6 +1226,38 @@ async function removeScript(script: AppiumRecordedScript) {
943
1226
  }
944
1227
  }
945
1228
 
1229
+ function downloadScript(script: AppiumRecordedScript) {
1230
+ const link = document.createElement('a');
1231
+ link.href = appiumScriptDownloadUrl(script.id);
1232
+ link.download = `${script.name}.json`;
1233
+ document.body.appendChild(link);
1234
+ link.click();
1235
+ link.remove();
1236
+ }
1237
+
1238
+ function chooseScriptImportFile() {
1239
+ scriptImportInput.value?.click();
1240
+ }
1241
+
1242
+ async function importScriptFile(event: Event) {
1243
+ const input = event.currentTarget as HTMLInputElement;
1244
+ const file = input.files?.[0];
1245
+ input.value = '';
1246
+ if (!file) return;
1247
+ importingScript.value = true;
1248
+ try {
1249
+ const payload = JSON.parse(await file.text()) as unknown;
1250
+ const result = await importAppiumScript(payload);
1251
+ await loadScripts();
1252
+ loadScript(result.script);
1253
+ ElMessage.success(`脚本「${result.script.name}」已导入`);
1254
+ } catch (error) {
1255
+ ElMessage.error(error instanceof Error ? error.message : '导入脚本失败');
1256
+ } finally {
1257
+ importingScript.value = false;
1258
+ }
1259
+ }
1260
+
946
1261
  async function saveScript() {
947
1262
  if (!ensureAppPackageSelected()) return;
948
1263
  saving.value = true;
@@ -957,6 +1272,7 @@ async function saveScript() {
957
1272
  });
958
1273
  await loadScripts();
959
1274
  selectedScriptId.value = payload.script.id;
1275
+ markDraftSaved();
960
1276
  ElMessage.success('Appium 脚本已保存');
961
1277
  } catch (error) {
962
1278
  ElMessage.error(error instanceof Error ? error.message : '保存失败');
@@ -973,18 +1289,42 @@ async function replayScript() {
973
1289
  replaying.value = true;
974
1290
  replayOutput.value = '';
975
1291
  try {
976
- const result = await replayAppiumScript({ id: selectedScript.value.id, deviceId: selectedDeviceId.value });
1292
+ await pauseAutoTreeRefresh();
1293
+ const result = await replayAppiumScript(
1294
+ { id: selectedScript.value.id, deviceId: selectedDeviceId.value },
1295
+ (line) => {
1296
+ replayOutput.value += `${replayOutput.value ? '\n' : ''}${line}`;
1297
+ },
1298
+ );
977
1299
  replayOutput.value = result.output || '';
978
- if (result.success) ElMessage.success('回放完成');
1300
+ if (result.success) {
1301
+ ElMessage.success('回放完成');
1302
+ } else {
1303
+ ElMessage.error('回放失败,详情见回放输出');
1304
+ }
979
1305
  } catch (error) {
980
- replayOutput.value = error instanceof Error ? error.message : '回放失败';
1306
+ const message = error instanceof Error ? error.message : '回放失败';
1307
+ replayOutput.value += `${replayOutput.value ? '\n' : ''}${message}`;
981
1308
  ElMessage.error('回放失败,详情见回放输出');
982
1309
  } finally {
983
1310
  replaying.value = false;
1311
+ scheduleAutoTreeRefresh(800);
984
1312
  }
985
1313
  }
986
1314
 
987
- onMounted(loadScripts);
1315
+ onMounted(() => {
1316
+ window.addEventListener('beforeunload', warnBeforeUnload);
1317
+ void loadScripts();
1318
+ });
1319
+
1320
+ onUnmounted(() => {
1321
+ window.removeEventListener('beforeunload', warnBeforeUnload);
1322
+ stopAutoTreeRefresh();
1323
+ });
1324
+
1325
+ watch(activeWorkbenchTab, (tab) => {
1326
+ window.localStorage.setItem(WORKBENCH_TAB_STORAGE_KEY, tab);
1327
+ });
988
1328
 
989
1329
  watch(() => form.appPackage, (packageName) => {
990
1330
  if (!packageName) return;
@@ -998,7 +1338,10 @@ watch(() => form.appPackage, (packageName) => {
998
1338
  watch(
999
1339
  () => [props.active, props.playgroundDeviceId] as const,
1000
1340
  ([active, deviceId]) => {
1001
- if (active && deviceId) void refreshTree();
1341
+ stopAutoTreeRefresh();
1342
+ if (active && deviceId) {
1343
+ void refreshTree().finally(() => scheduleAutoTreeRefresh());
1344
+ }
1002
1345
  },
1003
1346
  { immediate: true },
1004
1347
  );
@@ -1033,7 +1376,7 @@ watch(
1033
1376
  <template #header>
1034
1377
  <div class="panel-header">
1035
1378
  <span>App 组件树</span>
1036
- <el-button :icon="Refresh" :loading="loadingTree" @click="refreshTree">
1379
+ <el-button :icon="Refresh" :loading="loadingTree" :disabled="replaying" @click="refreshTree">
1037
1380
  刷新组件树
1038
1381
  </el-button>
1039
1382
  </div>
@@ -1105,7 +1448,7 @@ watch(
1105
1448
  <strong>当前 Activity</strong>
1106
1449
  <code>{{ currentActivity || '正在获取' }}</code>
1107
1450
  </div>
1108
- <p>当前仅允许插入“系统返回”或连接与插入点 Activity 匹配的脚本,其他流程编辑已锁定。</p>
1451
+ <p>请在录制流程中添加或执行“启动 APP”节点;Activity 匹配后会自动解除编辑锁定。</p>
1109
1452
  </div>
1110
1453
  </el-alert>
1111
1454
  <NodeDetail
@@ -1120,12 +1463,16 @@ watch(
1120
1463
  <RecordedSteps
1121
1464
  :steps="steps"
1122
1465
  :disabled="recordingLocked"
1123
- :allowed-locked-actions="scriptActivityMismatch && !recordingBusy ? ['keyBack', 'runScript', 'launchApp', 'delay'] : []"
1466
+ :allowed-locked-actions="scriptActivityMismatch && !recordingBusy ? insertableRecorderActions : []"
1467
+ :launching-step-id="launchingApp ? steps.find((step) => step.type === 'launchApp')?.id : ''"
1124
1468
  @remove="removeStep"
1125
1469
  @add-delay="addDelayStep"
1126
1470
  @insert-action="(index, action) => addAction(action, index)"
1127
1471
  @insert-branch-action="addBranchAction"
1472
+ @connect-next="connectBranchToNext"
1473
+ @disconnect-next="disconnectBranchFromNext"
1128
1474
  @edit-input="editInputStep"
1475
+ @execute-step="executeFlowStep"
1129
1476
  @update-step="updateStep"
1130
1477
  />
1131
1478
  </section>
@@ -1167,6 +1514,18 @@ watch(
1167
1514
 
1168
1515
  <el-tab-pane label="脚本列表" name="scripts">
1169
1516
  <div class="appium-script-list">
1517
+ <div class="appium-script-list__toolbar">
1518
+ <el-button :icon="Upload" :loading="importingScript" @click="chooseScriptImportFile">
1519
+ 导入脚本
1520
+ </el-button>
1521
+ <input
1522
+ ref="scriptImportInput"
1523
+ class="appium-script-list__file-input"
1524
+ type="file"
1525
+ accept="application/json,.json"
1526
+ @change="importScriptFile"
1527
+ >
1528
+ </div>
1170
1529
  <el-empty v-if="!scripts.length" description="暂无录制脚本" />
1171
1530
  <template v-else>
1172
1531
  <article
@@ -1182,6 +1541,12 @@ watch(
1182
1541
  </div>
1183
1542
  <div class="appium-script-list__actions">
1184
1543
  <el-button size="small" @click="loadScript(script)">加载</el-button>
1544
+ <el-button
1545
+ size="small"
1546
+ :icon="Download"
1547
+ title="下载脚本"
1548
+ @click="downloadScript(script)"
1549
+ />
1185
1550
  <el-button
1186
1551
  size="small"
1187
1552
  type="danger"
@@ -1234,10 +1599,10 @@ watch(
1234
1599
  @closed="closeLinkedScriptDialog"
1235
1600
  >
1236
1601
  <el-form label-position="top">
1237
- <el-form-item label="插入点 Activity">
1602
+ <el-form-item :label="linkedScriptBranchTarget ? '分支录制 Activity' : '插入点 Activity'">
1238
1603
  <code class="appium-linked-script-activity">{{ linkedScriptExpectedActivity || '-' }}</code>
1239
1604
  </el-form-item>
1240
- <el-form-item label="选择入口 Activity 匹配的脚本">
1605
+ <el-form-item :label="linkedScriptBranchTarget ? '选择同一 App 的脚本(回放时等待入口 Activity)' : '选择入口 Activity 匹配的脚本'">
1241
1606
  <el-select
1242
1607
  v-model="linkedScriptTargetId"
1243
1608
  filterable
@@ -1247,7 +1612,7 @@ watch(
1247
1612
  <el-option
1248
1613
  v-for="script in compatibleLinkableScripts"
1249
1614
  :key="script.id"
1250
- :label="`${script.name} · ${script.steps.length} 步`"
1615
+ :label="`${script.name} · ${script.appActivity || '未绑定 Activity'} · ${script.steps.length} 步`"
1251
1616
  :value="script.id"
1252
1617
  />
1253
1618
  </el-select>