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.
- package/CHANGELOG.md +16 -26
- package/README.md +16 -26
- package/bin/sync-readme-docs.mjs +49 -0
- package/index.html +1 -1
- package/package.json +8 -8
- package/remote-agent/index.ts +1 -1
- package/server/appium-recorder/appium-runner.ts +97 -43
- package/server/appium-recorder/bounded-loop.ts +118 -0
- package/server/appium-recorder/history-capture.ts +34 -0
- package/server/appium-recorder/report.ts +1 -1
- package/server/appium-recorder/repository.ts +9 -0
- package/server/appium-recorder/routes.ts +29 -5
- package/server/appium-recorder/run-history.ts +34 -0
- package/src/appium-recorder/AppiumPage.vue +114 -54
- package/src/appium-recorder/RunHistoryDialog.vue +153 -0
- package/src/appium-recorder/bounded-loop.ts +66 -0
- package/src/appium-recorder/components/BreakLoopSettings.vue +29 -0
- package/src/appium-recorder/components/ComponentInspector.vue +26 -0
- package/src/appium-recorder/components/ComponentTree.vue +24 -4
- package/src/appium-recorder/components/FlowCanvas.vue +19 -1
- package/src/appium-recorder/components/FlowNodeCard.vue +6 -3
- package/src/appium-recorder/components/FlowNodeSearch.vue +62 -0
- package/src/appium-recorder/components/FlowStepEditor.vue +9 -4
- package/src/appium-recorder/components/LoopSettings.vue +37 -0
- package/src/appium-recorder/components/NestedConditionBranches.vue +11 -3
- package/src/appium-recorder/components/NodeDetail.vue +30 -6
- package/src/appium-recorder/components/RecordedSteps.vue +24 -4
- package/src/appium-recorder/components/RecorderWorkspace.vue +132 -0
- package/src/appium-recorder/components/VisualChangeEndPicker.vue +21 -0
- package/src/appium-recorder/flow-copy.ts +1 -0
- package/src/appium-recorder/flow-graph.ts +4 -9
- package/src/appium-recorder/flow-labels.ts +7 -0
- package/src/appium-recorder/flow-merge.ts +3 -2
- package/src/appium-recorder/flow-normalize.ts +1 -0
- package/src/appium-recorder/flow-search.ts +29 -0
- package/src/appium-recorder/node-locators.ts +25 -0
- package/src/appium-recorder/run-history.ts +50 -0
- package/src/appium-recorder/tree.ts +2 -5
- package/src/appium-recorder/types.ts +7 -0
- package/src/appium-recorder/visual-change-pairs.ts +16 -10
- package/src/components/device/DevicePreviewPanel.vue +62 -10
- package/src/style.css +24 -7
- 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
|
|
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="
|
|
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="
|
|
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
|
|
33
|
-
|
|
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 !== '
|
|
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 === '
|
|
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"
|