android-midscene-automation 0.1.30 → 0.1.32

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.
Files changed (43) hide show
  1. package/CHANGELOG.md +16 -26
  2. package/README.md +16 -26
  3. package/bin/sync-readme-docs.mjs +49 -0
  4. package/index.html +1 -1
  5. package/package.json +8 -8
  6. package/remote-agent/index.ts +1 -1
  7. package/server/appium-recorder/appium-runner.ts +97 -43
  8. package/server/appium-recorder/bounded-loop.ts +118 -0
  9. package/server/appium-recorder/history-capture.ts +34 -0
  10. package/server/appium-recorder/report.ts +1 -1
  11. package/server/appium-recorder/repository.ts +9 -0
  12. package/server/appium-recorder/routes.ts +29 -5
  13. package/server/appium-recorder/run-history.ts +34 -0
  14. package/src/appium-recorder/AppiumPage.vue +114 -54
  15. package/src/appium-recorder/RunHistoryDialog.vue +153 -0
  16. package/src/appium-recorder/bounded-loop.ts +66 -0
  17. package/src/appium-recorder/components/BreakLoopSettings.vue +29 -0
  18. package/src/appium-recorder/components/ComponentInspector.vue +26 -0
  19. package/src/appium-recorder/components/ComponentTree.vue +24 -4
  20. package/src/appium-recorder/components/FlowCanvas.vue +19 -1
  21. package/src/appium-recorder/components/FlowNodeCard.vue +6 -3
  22. package/src/appium-recorder/components/FlowNodeSearch.vue +62 -0
  23. package/src/appium-recorder/components/FlowStepEditor.vue +9 -4
  24. package/src/appium-recorder/components/LoopSettings.vue +37 -0
  25. package/src/appium-recorder/components/NestedConditionBranches.vue +11 -3
  26. package/src/appium-recorder/components/NodeDetail.vue +30 -6
  27. package/src/appium-recorder/components/RecordedSteps.vue +24 -4
  28. package/src/appium-recorder/components/RecorderWorkspace.vue +132 -0
  29. package/src/appium-recorder/components/VisualChangeEndPicker.vue +21 -0
  30. package/src/appium-recorder/flow-copy.ts +1 -0
  31. package/src/appium-recorder/flow-graph.ts +4 -9
  32. package/src/appium-recorder/flow-labels.ts +7 -0
  33. package/src/appium-recorder/flow-merge.ts +3 -2
  34. package/src/appium-recorder/flow-normalize.ts +1 -0
  35. package/src/appium-recorder/flow-search.ts +29 -0
  36. package/src/appium-recorder/node-locators.ts +25 -0
  37. package/src/appium-recorder/run-history.ts +50 -0
  38. package/src/appium-recorder/tree.ts +2 -5
  39. package/src/appium-recorder/types.ts +7 -0
  40. package/src/appium-recorder/visual-change-pairs.ts +16 -10
  41. package/src/components/device/DevicePreviewPanel.vue +62 -10
  42. package/src/style.css +24 -7
  43. package/src/ui-refresh.css +40 -0
@@ -1,6 +1,7 @@
1
1
  <script setup lang="ts">
2
- import { nextTick, ref, watch } from 'vue';
2
+ import { computed, nextTick, ref, watch } from 'vue';
3
3
  import type { AppiumNode } from '../types';
4
+ import { Search } from '@element-plus/icons-vue';
4
5
 
5
6
  const props = defineProps<{
6
7
  tree: AppiumNode | null;
@@ -12,11 +13,18 @@ defineEmits<{
12
13
  }>();
13
14
 
14
15
  const containerRef = ref<HTMLElement | null>(null);
15
- const treeRef = ref<{ setCurrentKey: (key: string) => void } | null>(null);
16
+ const query = ref('');
17
+ // Keep the tree data identity stable while typing so filtering is not reset.
18
+ const treeData = computed(() => props.tree ? [props.tree] : []);
19
+ const treeRef = ref<{ setCurrentKey: (key: string) => void; filter: (value: string) => void } | null>(null);
20
+ const filterNode = (value: string, node: AppiumNode) => !value || [node.label, node.resourceId, node.text, node.contentDesc, node.className].some((text) => text?.toLowerCase().includes(value.toLowerCase()));
21
+ watch(query, (value) => treeRef.value?.filter(value));
22
+ watch(() => props.tree, async () => { await nextTick(); treeRef.value?.filter(query.value); });
16
23
 
17
24
  watch(
18
25
  () => props.selectedId,
19
26
  async () => {
27
+ query.value = '';
20
28
  treeRef.value?.setCurrentKey(props.selectedId);
21
29
  await nextTick();
22
30
  containerRef.value
@@ -27,22 +35,25 @@ watch(
27
35
  </script>
28
36
 
29
37
  <template>
38
+ <div class="appium-tree-container">
39
+ <el-input v-model="query" size="small" :prefix-icon="Search" clearable placeholder="搜索文字、ID、组件类型" aria-label="搜索组件" />
30
40
  <div ref="containerRef" class="appium-tree">
31
41
  <el-empty v-if="!tree" description="暂无组件树" />
32
42
  <el-tree
33
43
  v-else
34
44
  ref="treeRef"
35
- :data="[tree]"
45
+ :data="treeData"
36
46
  node-key="id"
37
47
  :props="{ label: 'label', children: 'children' }"
38
48
  :current-node-key="selectedId"
39
49
  highlight-current
40
50
  default-expand-all
51
+ :filter-node-method="filterNode"
41
52
  @node-click="$emit('select', $event)"
42
53
  >
43
54
  <template #default="{ data }">
44
55
  <span class="appium-tree-node">
45
- <span class="appium-tree-node__label">{{ data.label }}</span>
56
+ <span class="appium-tree-node__label" :title="`${data.label}\n${data.resourceId || data.className}`">{{ data.label }}</span>
46
57
  <span
47
58
  v-if="data.selector?.matchCount"
48
59
  class="appium-tree-node__badge"
@@ -54,4 +65,13 @@ watch(
54
65
  </template>
55
66
  </el-tree>
56
67
  </div>
68
+ </div>
57
69
  </template>
70
+
71
+ <style scoped>
72
+ .appium-tree-container { display: flex; flex-direction: column; gap: 8px; min-height: 0; }
73
+ .appium-tree { flex: 1; height: auto; overflow-x: hidden; }
74
+ .appium-tree :deep(.el-tree) { min-width: 0; }
75
+ .appium-tree-node { flex: 1; min-width: 0; overflow: hidden; }
76
+ .appium-tree-node__label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
77
+ </style>
@@ -30,6 +30,9 @@ const props = defineProps<{
30
30
  launchingStepId?: string;
31
31
  clipboardCount?: number;
32
32
  resetViewToken?: number;
33
+ searchTargetId?: string;
34
+ searchFocusToken?: number;
35
+ searchActive?: boolean;
33
36
  startActionGroups: FlowActionGroup[];
34
37
  mainActionGroups: FlowActionGroup[];
35
38
  canOpenInsertMenu: boolean;
@@ -55,11 +58,23 @@ const emit = defineEmits<{
55
58
  }>();
56
59
 
57
60
  const measuredNodeHeights = shallowRef<Record<string, number>>({});
58
- const { fitView, updateNodeInternals } = useVueFlow(props.id);
61
+ const { fitView, updateNodeInternals, findNode, setCenter, viewport } = useVueFlow(props.id);
59
62
  const paneReady = shallowRef(false);
60
63
  let internalsFrame = 0;
61
64
  let resetViewFrame = 0;
62
65
  let pendingResetView = false;
66
+ let searchFrame = 0;
67
+ watch(() => [props.searchFocusToken, props.searchTargetId, props.searchActive, paneReady.value], async () => {
68
+ if (searchFrame) cancelAnimationFrame(searchFrame);
69
+ if (!props.searchActive || !props.searchTargetId || !paneReady.value) return;
70
+ await nextTick();
71
+ searchFrame = requestAnimationFrame(() => {
72
+ const node = findNode(`step:${props.searchTargetId}`);
73
+ if (!node || !props.searchActive) return;
74
+ // Move only the viewport; search never rewrites graph positions or selection state.
75
+ void setCenter(node.position.x + node.dimensions.width / 2, node.position.y + node.dimensions.height / 2, { zoom: viewport.value.zoom, duration: 0 });
76
+ });
77
+ });
63
78
 
64
79
  const graph = computed(() => buildFlowGraph(props.steps, {
65
80
  deleteMode: props.deleteMode,
@@ -154,6 +169,7 @@ watch(() => props.resetViewToken, () => {
154
169
  });
155
170
 
156
171
  onBeforeUnmount(() => {
172
+ if (searchFrame) cancelAnimationFrame(searchFrame);
157
173
  if (internalsFrame) window.cancelAnimationFrame(internalsFrame);
158
174
  if (resetViewFrame) window.cancelAnimationFrame(resetViewFrame);
159
175
  pendingResetView = false;
@@ -202,9 +218,11 @@ function handleInsert(payload: {
202
218
  >
203
219
  <template #node-flow-node="{ id: nodeId, data }">
204
220
  <FlowNodeCard
221
+ :steps="steps"
205
222
  :node-id="nodeId"
206
223
  :data="data"
207
224
  :readonly="props.readonly"
225
+ :search-highlighted="nodeId === `step:${searchTargetId}`"
208
226
  @node-click="emit('nodeClick', $event)"
209
227
  @copy="emit('copy', [$event])"
210
228
  @remove="emit('remove', $event)"
@@ -16,7 +16,9 @@ import FlowActionMenu from './FlowActionMenu.vue';
16
16
  const props = defineProps<{
17
17
  nodeId: string;
18
18
  data: FlowGraphNodeData;
19
+ steps: AppiumRecordedStep[];
19
20
  readonly?: boolean;
21
+ searchHighlighted?: boolean;
20
22
  }>();
21
23
 
22
24
  const emit = defineEmits<{
@@ -88,7 +90,7 @@ watch(() => props.data, () => {
88
90
  <div
89
91
  ref="rootRef"
90
92
  class="appium-flow-graph-node nodrag nopan"
91
- :class="`appium-flow-graph-node--${data.kind}`"
93
+ :class="[`appium-flow-graph-node--${data.kind}`, { 'appium-flow-graph-node--search-highlighted': searchHighlighted }]"
92
94
  :style="data.kind === 'branch' ? { width: `${data.width}px` } : undefined"
93
95
  @pointerdown.stop
94
96
  @mousedown.stop
@@ -177,13 +179,13 @@ watch(() => props.data, () => {
177
179
  <WarningFilled />
178
180
  </el-icon>
179
181
  </el-tooltip>
180
- <strong :title="data.title">{{ data.title }}</strong>
182
+ <strong :title="`${data.index + 1}. ${data.title}`"><span class="appium-flow-node-number">{{ data.index + 1 }}.</span> {{ data.title }}</strong>
181
183
  <small :title="data.meta">{{ data.meta }}</small>
182
184
  <em v-if="data.note" :title="data.note">{{ data.note }}</em>
183
185
  </span>
184
186
  </button>
185
187
  <span v-if="!props.readonly && !data.deleteMode" class="appium-flow-step-card__actions nodrag nopan" @click.stop>
186
- <el-tooltip v-if="data.flowKind === 'condition' && !data.copyMode" content="合并分支:将两侧后续节点汇入公共流程" placement="top" :show-after="200">
188
+ <el-tooltip v-if="data.flowKind === 'condition' && data.step.type !== 'loop' && !data.copyMode" content="合并分支:将两侧后续节点汇入公共流程" placement="top" :show-after="200">
187
189
  <span class="appium-node-action-tooltip"><el-button text size="small" :icon="Connection"
188
190
  title="合并分支" aria-label="合并分支" :disabled="data.mergeDisabled || Boolean(data.step.flow?.successTargetId)"
189
191
  @click="emit('merge', data.index)" />
@@ -248,6 +250,7 @@ watch(() => props.data, () => {
248
250
  v-if="!props.readonly && data.expanded"
249
251
  class="appium-flow-step-editor nodrag nopan"
250
252
  :step="data.step"
253
+ :steps="steps"
251
254
  :index="data.index"
252
255
  :disabled="data.disabled"
253
256
  @update="updateStep"
@@ -0,0 +1,62 @@
1
+ <script setup lang="ts">
2
+ import { computed, nextTick, ref } from 'vue';
3
+ import { ArrowDown, ArrowUp, Search } from '@element-plus/icons-vue';
4
+ import type { FlowSearchResult } from '../flow-search';
5
+
6
+ const props = defineProps<{ query: string; results: FlowSearchResult[]; selectedId: string }>();
7
+ const emit = defineEmits<{ 'update:query': [value: string]; select: [id: string] }>();
8
+ const input = ref<{ focus: () => void }>();
9
+ const list = ref<HTMLElement>();
10
+ const currentIndex = computed(() => props.results.findIndex((result) => result.id === props.selectedId));
11
+ async function choose(id: string) {
12
+ emit('select', id);
13
+ await nextTick();
14
+ list.value?.querySelector('[aria-current="true"]')?.scrollIntoView({ block: 'nearest' });
15
+ }
16
+ function move(direction: number) {
17
+ if (!props.results.length) return;
18
+ const index = (currentIndex.value + direction + props.results.length) % props.results.length;
19
+ void choose(props.results[index]!.id);
20
+ }
21
+ </script>
22
+
23
+ <template>
24
+ <el-popover trigger="click" placement="bottom-start" :width="360" popper-class="flow-node-search-popover" @after-enter="input?.focus()">
25
+ <template #reference><span class="flow-node-search-trigger"><el-tooltip content="搜索节点" placement="top" :show-after="200"><el-button :icon="Search" aria-label="搜索节点" size="small" /></el-tooltip></span></template>
26
+ <div class="flow-node-search">
27
+ <el-input ref="input" :model-value="query" placeholder="搜索名称、操作、定位内容或日志" aria-label="搜索流程节点" clearable @update:model-value="emit('update:query', $event)" @keydown.enter.prevent="move($event.shiftKey ? -1 : 1)" @keydown.down.prevent="move(1)" @keydown.up.prevent="move(-1)" />
28
+ <div class="flow-node-search__navigation">
29
+ <span aria-live="polite">{{ currentIndex + 1 }} / {{ results.length }}</span>
30
+ <el-tooltip content="上一个结果" placement="top"><span><el-button :icon="ArrowUp" size="small" aria-label="上一个结果" :disabled="!results.length" @click="move(-1)" /></span></el-tooltip>
31
+ <el-tooltip content="下一个结果" placement="top"><span><el-button :icon="ArrowDown" size="small" aria-label="下一个结果" :disabled="!results.length" @click="move(1)" /></span></el-tooltip>
32
+ </div>
33
+ <div ref="list" class="flow-node-search__results">
34
+ <button v-for="result in results" :key="result.id" type="button" class="flow-node-search__result" :aria-current="result.id === selectedId" @click="choose(result.id)">
35
+ <strong>{{ result.index + 1 }}. {{ result.title }}</strong>
36
+ <span>{{ result.meta }}</span>
37
+ <small>{{ result.branch }}</small>
38
+ </button>
39
+ <div v-if="query.trim() && !results.length" class="flow-node-search__empty">未找到匹配节点</div>
40
+ </div>
41
+ </div>
42
+ </el-popover>
43
+ </template>
44
+
45
+ <style scoped>
46
+ .flow-node-search-trigger { display: inline-flex; }
47
+ .flow-node-search-trigger .el-button { width: 30px; height: 30px; min-height: 30px; padding: 0; }
48
+ .flow-node-search__navigation { display: flex; align-items: center; justify-content: flex-end; gap: 6px; margin: 8px 0; }
49
+ .flow-node-search__navigation > span:first-child { margin-right: auto; font-size: 12px; }
50
+ .flow-node-search__navigation .el-button { width: 26px; height: 26px; min-height: 26px; padding: 0; }
51
+ .flow-node-search__results { max-height: min(320px, 45vh); overflow: auto; }
52
+ .flow-node-search__result { display: flex; flex-direction: column; text-align: left; gap: 4px; width: 100%; border: 0; border-bottom: 1px solid #e4e7ed; padding: 10px 8px; background: #fff; color: #303133; cursor: pointer; overflow-wrap: anywhere; }
53
+ .flow-node-search__result[aria-current="true"] { background: #e6f4ff; }
54
+ .flow-node-search__result:hover { background: #f0f7fc; }
55
+ .flow-node-search__result strong { font-size: 13px; }
56
+ .flow-node-search__result span, .flow-node-search__result small { font-size: 12px; color: #606266; }
57
+ .flow-node-search__empty { padding: 16px 0; text-align: center; color: #606266; }
58
+ </style>
59
+
60
+ <style>
61
+ .flow-node-search-popover.el-popover { max-width: calc(100vw - 24px); box-sizing: border-box; }
62
+ </style>
@@ -8,6 +8,8 @@ import TextClickSettings from './TextClickSettings.vue';
8
8
  import { isNativeStateCondition } from '../native-control-state';
9
9
  import LongPressSettings from './LongPressSettings.vue';
10
10
  import StageLogSettings from './StageLogSettings.vue';
11
+ import LoopSettings from './LoopSettings.vue';
12
+ import BreakLoopSettings from './BreakLoopSettings.vue';
11
13
 
12
14
  type FlowKind = 'action' | 'condition' | 'assertion';
13
15
  type SwipeGesture = NonNullable<AppiumRecordedStep['swipe']>;
@@ -15,6 +17,7 @@ type VisualChangeConfig = NonNullable<AppiumRecordedStep['visualChange']>;
15
17
 
16
18
  const props = defineProps<{
17
19
  step: AppiumRecordedStep;
20
+ steps: AppiumRecordedStep[];
18
21
  index: number;
19
22
  disabled?: boolean;
20
23
  }>();
@@ -98,7 +101,7 @@ function patchTimeout(value: unknown) {
98
101
  patchStep({ timeoutMs: timeout || undefined });
99
102
  }
100
103
 
101
- const showSelector = computed(() => props.step.selector && (
104
+ const showSelector = computed(() => props.step.type !== 'loop' && props.step.selector && (
102
105
  props.step.type !== 'longPress' || longPressMode(props.step) === 'element'
103
106
  ));
104
107
 
@@ -134,7 +137,7 @@ function patchSelector(patch: Partial<AppiumSelector>) {
134
137
  <el-form-item label="节点类型">
135
138
  <el-select
136
139
  :model-value="defaultKind()"
137
- :disabled="disabled || isBooleanCondition(step) || step.type === 'log' || step.type === 'openGallery' || step.type === 'endFlow'"
140
+ :disabled="disabled || isBooleanCondition(step) || step.type === 'log' || step.type === 'openGallery' || step.type === 'endFlow' || step.type === 'loop' || step.type === 'breakLoop'"
138
141
  @update:model-value="patchFlow({ nodeKind: $event as FlowKind })"
139
142
  >
140
143
  <el-option label="操作" value="action" />
@@ -142,7 +145,7 @@ function patchSelector(patch: Partial<AppiumSelector>) {
142
145
  <el-option label="校验" value="assertion" />
143
146
  </el-select>
144
147
  </el-form-item>
145
- <el-form-item v-if="step.type !== 'longPress' && step.type !== 'log' && step.type !== 'openGallery' && step.type !== 'endFlow'" label="超时时间 ms">
148
+ <el-form-item v-if="!['longPress', 'log', 'openGallery', 'endFlow', 'loop', 'breakLoop'].includes(step.type)" label="超时时间 ms">
146
149
  <el-input-number
147
150
  :model-value="step.timeoutMs || undefined"
148
151
  :disabled="disabled"
@@ -155,6 +158,8 @@ function patchSelector(patch: Partial<AppiumSelector>) {
155
158
  </div>
156
159
  <LongPressSettings v-if="step.type === 'longPress'" :step="step" :disabled="disabled" @update="patchStep" />
157
160
  <StageLogSettings v-if="step.type === 'log'" :step="step" :disabled="disabled" @update="patchStep" />
161
+ <LoopSettings v-if="step.type === 'loop'" :step="step" :disabled="disabled" @update="patchStep" />
162
+ <BreakLoopSettings v-if="step.type === 'breakLoop'" :step="step" :steps="steps" :disabled="disabled" @update="patchStep" />
158
163
  <TextClickSettings v-if="step.type === 'textClick'" :step="step" :disabled="disabled" @update="patchStep" />
159
164
  <el-form-item v-if="step.type === 'aiRecognition'" label="识别内容">
160
165
  <el-input
@@ -358,7 +363,7 @@ function patchSelector(patch: Partial<AppiumSelector>) {
358
363
  />
359
364
  </el-form-item>
360
365
  </div>
361
- <div v-if="defaultKind() === 'condition' && !isBooleanCondition(step)" class="appium-flow-editor__grid">
366
+ <div v-if="defaultKind() === 'condition' && step.type !== 'loop' && !isBooleanCondition(step)" class="appium-flow-editor__grid">
362
367
  <el-form-item label="指定文本(可选)">
363
368
  <el-input
364
369
  :model-value="step.value || ''"
@@ -0,0 +1,37 @@
1
+ <script setup lang="ts">
2
+ import type { AppiumRecordedStep, AppiumSelector } from '../types';
3
+ import { defaultLoopConfig, MAX_LOOP_ITERATIONS } from '../bounded-loop';
4
+
5
+ const props = defineProps<{ step: AppiumRecordedStep; disabled?: boolean }>();
6
+ const emit = defineEmits<{ update: [patch: Partial<AppiumRecordedStep>] }>();
7
+ function patch(config: Partial<NonNullable<AppiumRecordedStep['loop']>>) {
8
+ emit('update', { loop: { ...defaultLoopConfig(), ...props.step.loop, ...config } });
9
+ }
10
+ function selector(patch: Partial<AppiumSelector>) {
11
+ emit('update', { selector: { strategy: 'id', value: '', ...props.step.selector, ...patch } });
12
+ }
13
+ </script>
14
+
15
+ <template>
16
+ <el-form-item label="循环方式">
17
+ <el-select :model-value="step.loop?.exitWhen || 'never'" :disabled="disabled" @update:model-value="patch({ exitWhen: $event })">
18
+ <el-option value="never" label="固定次数" />
19
+ <el-option value="exists" label="元素出现时退出" />
20
+ <el-option value="notExists" label="元素消失时退出" />
21
+ </el-select>
22
+ </el-form-item>
23
+ <el-form-item :label="step.loop?.exitWhen === 'never' ? '执行次数' : '最大执行次数'" required>
24
+ <el-input-number :model-value="step.loop?.maxIterations" :disabled="disabled" :min="1" :max="MAX_LOOP_ITERATIONS" :precision="0" controls-position="right" @update:model-value="patch({ maxIterations: Number($event) })" />
25
+ </el-form-item>
26
+ <template v-if="step.loop?.exitWhen && step.loop.exitWhen !== 'never'">
27
+ <el-form-item label="退出条件定位方式">
28
+ <el-select :model-value="step.selector?.strategy || 'id'" :disabled="disabled" @update:model-value="selector({ strategy: $event })">
29
+ <el-option value="id" label="Resource ID" /><el-option value="accessibilityId" label="Accessibility ID" />
30
+ <el-option value="xpath" label="XPath" /><el-option value="androidUiAutomator" label="UiAutomator" />
31
+ </el-select>
32
+ </el-form-item>
33
+ <el-form-item label="退出条件定位内容" required>
34
+ <el-input type="textarea" :autosize="{ minRows: 3, maxRows: 8 }" :model-value="step.selector?.value || ''" :disabled="disabled" @update:model-value="selector({ value: $event })" />
35
+ </el-form-item>
36
+ </template>
37
+ </template>
@@ -25,6 +25,8 @@ type InsertAction =
25
25
  | 'launchApp'
26
26
  | 'openGallery'
27
27
  | 'endFlow'
28
+ | 'loop'
29
+ | 'breakLoop'
28
30
  | 'clearAppData'
29
31
  | 'popupCondition'
30
32
  | 'checkboxState'
@@ -97,6 +99,7 @@ const actionGroups: Array<{ title: string; actions: Array<{ type: InsertAction;
97
99
  {
98
100
  title: '设备操作',
99
101
  actions: [
102
+ { type: 'launchApp', label: '启动 App' },
100
103
  { type: 'keyBack', label: '系统返回' },
101
104
  { type: 'keyHome', label: 'Home 键' },
102
105
  { type: 'openGallery', label: '启动相册' },
@@ -121,8 +124,8 @@ const actionGroups: Array<{ title: string; actions: Array<{ type: InsertAction;
121
124
  { type: 'assertText', label: '断言文本' },
122
125
  { type: 'waitDisappear', label: '等待元素消失' },
123
126
  { type: 'waitActivity', label: '等待 Activity' },
124
- { type: 'visualChangeStart', label: '检测画面变化开始节点' },
125
- { type: 'visualChangeEnd', label: '检测画面变化结束节点' },
127
+ { type: 'visualChangeStart', label: '检测画面变化开始' },
128
+ { type: 'visualChangeEnd', label: '检测画面变化结束' },
126
129
  ],
127
130
  },
128
131
  {
@@ -130,6 +133,8 @@ const actionGroups: Array<{ title: string; actions: Array<{ type: InsertAction;
130
133
  actions: [
131
134
  { type: 'noop', label: '空节点' },
132
135
  { type: 'endFlow', label: '终止流程' },
136
+ { type: 'loop', label: '有界循环' },
137
+ { type: 'breakLoop', label: '退出循环' },
133
138
  { type: 'log', label: '输出日志' },
134
139
  { type: 'runScript', label: '连接脚本' },
135
140
  ],
@@ -150,6 +155,8 @@ function typeLabel(step: AppiumRecordedStep) {
150
155
  pinch: '双指缩放', runScript: '连接脚本', screenshot: '截图', visualChange: '检测画面变化',
151
156
  noop: '空节点',
152
157
  endFlow: '终止流程',
158
+ loop: '有界循环',
159
+ breakLoop: '退出循环',
153
160
  log: '输出日志',
154
161
  checkboxState: 'Checkbox 状态',
155
162
  checkedState: '判断勾选',
@@ -310,7 +317,7 @@ function branchSplitPath() {
310
317
  @click.stop="$emit('nodeClick', item.index)"
311
318
  >
312
319
  <span class="appium-flow-node__body">
313
- <span class="appium-flow-node__title">{{ item.step.label }}</span>
320
+ <span class="appium-flow-node__title"><span class="appium-flow-node-number">{{ item.index + 1 }}.</span> {{ item.step.label }}</span>
314
321
  <span class="appium-flow-node__meta">
315
322
  {{ kindLabel(defaultKind(item.step)) }} · {{ typeLabel(item.step) }}
316
323
  <template v-if="stepMeta(item.step)"> · {{ stepMeta(item.step) }}</template>
@@ -356,6 +363,7 @@ function branchSplitPath() {
356
363
  <FlowStepEditor
357
364
  v-if="expandedStepIndex === item.index"
358
365
  :step="item.step"
366
+ :steps="steps"
359
367
  :index="item.index"
360
368
  :disabled="disabled"
361
369
  @update="updateStep"
@@ -1,14 +1,22 @@
1
1
  <script setup lang="ts">
2
- import { ref } from 'vue';
3
- import { View } from '@element-plus/icons-vue';
2
+ import { computed, ref } from 'vue';
3
+ import { View, CopyDocument } from '@element-plus/icons-vue';
4
+ import { ElMessage } from 'element-plus';
4
5
  import type { AppiumNode } from '../types';
6
+ import { nodeLocators } from '../node-locators';
5
7
 
6
- defineProps<{
8
+ const props = defineProps<{
7
9
  node: AppiumNode | null;
8
10
  currentActivity?: string;
9
11
  loading?: boolean;
10
12
  }>();
11
13
  const detailsVisible = ref(false);
14
+ const locators = computed(() => props.node ? nodeLocators(props.node) : []);
15
+ async function copyLocator(value: string) {
16
+ if (!value) return;
17
+ try { await navigator.clipboard.writeText(value); ElMessage.success('定位内容已复制'); }
18
+ catch { ElMessage.error('复制失败,请手动选择定位内容复制'); }
19
+ }
12
20
  </script>
13
21
 
14
22
  <template>
@@ -28,9 +36,17 @@ const detailsVisible = ref(false);
28
36
  <el-drawer v-model="detailsVisible" title="组件详情" size="520px" style="max-width: 94vw" append-to-body>
29
37
  <template v-if="node">
30
38
  <div class="appium-node-detail__title">{{ node.label }}</div>
31
- <el-descriptions :column="1" border size="small">
32
- <el-descriptions-item label="resource-id">{{ node.resourceId || '-' }}</el-descriptions-item>
33
- <el-descriptions-item label="content-desc">{{ node.contentDesc || '-' }}</el-descriptions-item>
39
+ <el-descriptions class="appium-node-properties" :column="1" border size="small">
40
+ <el-descriptions-item v-for="locator in locators" :key="locator.name">
41
+ <template #label><span class="appium-node-locator-label">{{ locator.name }}<small v-if="locator.attribute">{{ locator.attribute }}</small></span></template>
42
+ <div class="appium-node-locator">
43
+ <code>{{ locator.value || '未提供' }}</code>
44
+ <el-tooltip :content="`复制 ${locator.name}`" placement="top" :show-after="200">
45
+ <el-button class="appium-node-locator-copy" size="small" :icon="CopyDocument" :disabled="!locator.value" :aria-label="`复制 ${locator.name}`" @click="copyLocator(locator.value)" />
46
+ </el-tooltip>
47
+ </div>
48
+ <small v-if="locator.warning" class="appium-node-locator-warning">{{ locator.warning }}</small>
49
+ </el-descriptions-item>
34
50
  <el-descriptions-item label="text">{{ node.text || '-' }}</el-descriptions-item>
35
51
  <el-descriptions-item label="class">{{ node.className || '-' }}</el-descriptions-item>
36
52
  <el-descriptions-item v-if="node.checkable !== undefined" label="checkable">{{ String(node.checkable) }}</el-descriptions-item>
@@ -75,4 +91,12 @@ const detailsVisible = ref(false);
75
91
  .appium-node-summary__text strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 13px; }
76
92
  .appium-node-summary__text span, .appium-node-summary__empty { color: var(--el-text-color-secondary); font-size: 12px; }
77
93
  :deep(.el-descriptions__content) { overflow-wrap: anywhere; word-break: break-word; }
94
+ .appium-node-properties :deep(.el-descriptions__table) { table-layout: fixed; width: 100%; }
95
+ .appium-node-properties :deep(.el-descriptions__label) { width: 118px; overflow-wrap: anywhere; }
96
+ .appium-node-locator-label { display: grid; gap: 2px; }
97
+ .appium-node-locator-label small { font-weight: 400; color: var(--el-text-color-secondary); }
98
+ .appium-node-locator { display: flex; align-items: flex-start; gap: 8px; }
99
+ .appium-node-locator code { flex: 1; min-width: 0; white-space: pre-wrap; overflow-wrap: anywhere; user-select: text; }
100
+ .appium-node-locator .appium-node-locator-copy { flex: none; width: 26px; height: 26px; min-height: 26px; padding: 0; }
101
+ .appium-node-locator-warning { display: block; margin-top: 4px; color: var(--el-color-warning); }
78
102
  </style>
@@ -7,6 +7,8 @@ import type { AppiumRecordedStep } from '../types';
7
7
  import { createFlowClipboard } from '../flow-copy';
8
8
  import FlowCanvas from './FlowCanvas.vue';
9
9
  import MergeBranchesDialog from './MergeBranchesDialog.vue';
10
+ import FlowNodeSearch from './FlowNodeSearch.vue';
11
+ import { searchFlowNodes } from '../flow-search';
10
12
  import { labelFlowStep } from '../flow-labels';
11
13
  import type { FlowActionGroup, FlowBranch, InsertAction } from '../flow-graph';
12
14
 
@@ -44,6 +46,14 @@ const selectedCopyIndexes = shallowRef<number[]>([]);
44
46
  const flowDialogVisible = shallowRef(false);
45
47
  const mainResetViewToken = shallowRef(0);
46
48
  const dialogResetViewToken = shallowRef(0);
49
+ const searchQuery = shallowRef('');
50
+ const searchTargetId = shallowRef('');
51
+ const searchFocusToken = shallowRef(0);
52
+ const searchResults = computed(() => searchFlowNodes(props.steps, searchQuery.value));
53
+ function focusSearchResult(id: string) { searchTargetId.value = id; searchFocusToken.value++; }
54
+ watch(searchResults, (results) => {
55
+ if (!results.some((result) => result.id === searchTargetId.value)) focusSearchResult(results[0]?.id || '');
56
+ });
47
57
 
48
58
  const insertActionGroups: FlowActionGroup[] = [
49
59
  {
@@ -96,6 +106,8 @@ const insertActionGroups: FlowActionGroup[] = [
96
106
  actions: [
97
107
  { type: 'noop', label: '空节点' },
98
108
  { type: 'endFlow', label: '终止流程' },
109
+ { type: 'loop', label: '有界循环' },
110
+ { type: 'breakLoop', label: '退出循环' },
99
111
  { type: 'log', label: '输出日志' },
100
112
  { type: 'runScript', label: '连接脚本' },
101
113
  ],
@@ -104,10 +116,9 @@ const insertActionGroups: FlowActionGroup[] = [
104
116
 
105
117
  const mainActionGroups = insertActionGroups.map((group) => ({
106
118
  ...group,
107
- actions: group.actions.filter((action) => action.type !== 'launchApp' && action.type !== 'clearAppData'),
119
+ actions: group.actions.filter((action) => action.type !== 'clearAppData'),
108
120
  }));
109
121
  const startActionGroups = insertActionGroups;
110
- const hasLaunchAppStep = computed(() => props.steps.some((step) => step.type === 'launchApp'));
111
122
  const hasClearAppDataStep = computed(() => props.steps.some((step) => step.type === 'clearAppData'));
112
123
 
113
124
  function isDescendantStep(descendantIndex: number, ancestorIndex: number) {
@@ -235,8 +246,7 @@ function isInsertActionDisabled(action: InsertAction) {
235
246
  }
236
247
 
237
248
  function isStartActionDisabled(action: InsertAction) {
238
- return (action === 'launchApp' && hasLaunchAppStep.value)
239
- || (action === 'clearAppData' && hasClearAppDataStep.value)
249
+ return (action === 'clearAppData' && hasClearAppDataStep.value)
240
250
  || isInsertActionDisabled(action);
241
251
  }
242
252
 
@@ -252,6 +262,7 @@ function updateStep(index: number, step: AppiumRecordedStep) {
252
262
  <template>
253
263
  <div class="appium-recorded-steps-panel">
254
264
  <div class="appium-flow-toolbar">
265
+ <FlowNodeSearch v-model:query="searchQuery" :results="searchResults" :selected-id="searchTargetId" @select="focusSearchResult" />
255
266
  <template v-if="copyMode">
256
267
  <span class="appium-flow-toolbar__status">已选择 {{ selectedCopyIndexes.length }} 个节点</span>
257
268
  <el-tooltip :content="deleteMode ? '删除选中' : '复制选中'" placement="top" :show-after="200"><span class="flow-toolbar-icon">
@@ -295,6 +306,9 @@ function updateStep(index: number, step: AppiumRecordedStep) {
295
306
 
296
307
  <FlowCanvas
297
308
  id="appium-flow-main"
309
+ :search-target-id="searchTargetId"
310
+ :search-focus-token="searchFocusToken"
311
+ :search-active="!flowDialogVisible"
298
312
  :steps="steps"
299
313
  :expanded-step-index="expandedStepIndex"
300
314
  :copy-mode="copyMode"
@@ -331,11 +345,14 @@ function updateStep(index: number, step: AppiumRecordedStep) {
331
345
  <el-dialog
332
346
  v-model="flowDialogVisible"
333
347
  title="流程总览"
348
+ @opened="searchTargetId && focusSearchResult(searchTargetId)"
334
349
  width="86vw"
335
350
  class="appium-flow-dialog"
351
+ align-center
336
352
  append-to-body
337
353
  >
338
354
  <div class="appium-flow-toolbar appium-flow-dialog__toolbar">
355
+ <FlowNodeSearch v-model:query="searchQuery" :results="searchResults" :selected-id="searchTargetId" @select="focusSearchResult" />
339
356
  <template v-if="copyMode">
340
357
  <span class="appium-flow-toolbar__status">已选择 {{ selectedCopyIndexes.length }} 个节点</span>
341
358
  <el-tooltip :content="deleteMode ? '删除选中' : '复制选中'" placement="top" :show-after="200"><span class="flow-toolbar-icon">
@@ -364,6 +381,9 @@ function updateStep(index: number, step: AppiumRecordedStep) {
364
381
  </div>
365
382
  <FlowCanvas
366
383
  id="appium-flow-dialog"
384
+ :search-target-id="searchTargetId"
385
+ :search-focus-token="searchFocusToken"
386
+ :search-active="flowDialogVisible"
367
387
  class="appium-flow-canvas--dialog"
368
388
  :steps="steps"
369
389
  :expanded-step-index="expandedStepIndex"