android-midscene-automation 0.1.19 → 0.1.21

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.
@@ -0,0 +1,373 @@
1
+ <script setup lang="ts">
2
+ import { computed } from 'vue';
3
+ import { CopyDocument, Delete, Edit, Link, Timer } from '@element-plus/icons-vue';
4
+ import type { AppiumRecordedStep } from '../types';
5
+ import { buildConditionLayouts, FLOW_BRANCH_GAP, FLOW_NODE_WIDTH } from '../flow-layout';
6
+ import FlowStepEditor from './FlowStepEditor.vue';
7
+
8
+ type BranchName = 'yes' | 'no';
9
+ type FlowKind = 'action' | 'condition' | 'assertion';
10
+ type InsertAction =
11
+ | 'delay'
12
+ | 'tap'
13
+ | 'input'
14
+ | 'clearInput'
15
+ | 'coordinateTap'
16
+ | 'longPress'
17
+ | 'keyBack'
18
+ | 'keyHome'
19
+ | 'keyRecent'
20
+ | 'keyPower'
21
+ | 'swipe'
22
+ | 'pinch'
23
+ | 'launchApp'
24
+ | 'popupCondition'
25
+ | 'tapIfExists'
26
+ | 'inputIfExists'
27
+ | 'clearIfExists'
28
+ | 'backIfExists'
29
+ | 'waitFor'
30
+ | 'assertExists'
31
+ | 'assertText'
32
+ | 'waitDisappear'
33
+ | 'waitActivity'
34
+ | 'runScript';
35
+
36
+ const PASTE_COMMAND = '__paste_flow_nodes__';
37
+
38
+ const props = defineProps<{
39
+ steps: AppiumRecordedStep[];
40
+ condition: AppiumRecordedStep;
41
+ conditionIndex: number;
42
+ expandedStepIndex: number | null;
43
+ copyMode?: boolean;
44
+ selectedCopyIndexes?: number[];
45
+ disabled?: boolean;
46
+ allowedLockedActions?: InsertAction[];
47
+ clipboardCount?: number;
48
+ }>();
49
+
50
+ const emit = defineEmits<{
51
+ nodeClick: [index: number];
52
+ copy: [indexes: number[]];
53
+ remove: [index: number];
54
+ paste: [index: number, branch: BranchName];
55
+ insertBranchAction: [index: number, branch: BranchName, action: InsertAction];
56
+ connectNext: [index: number, branch: BranchName];
57
+ disconnectNext: [index: number, branch: BranchName];
58
+ editInput: [index: number];
59
+ updateStep: [index: number, step: AppiumRecordedStep];
60
+ }>();
61
+
62
+ const stepItems = computed(() => props.steps.map((step, index) => ({ step, index })));
63
+ const layout = computed(() => buildConditionLayouts(props.steps).get(props.condition.id) || {
64
+ yesWidth: FLOW_NODE_WIDTH,
65
+ noWidth: FLOW_NODE_WIDTH,
66
+ totalWidth: FLOW_NODE_WIDTH * 2 + FLOW_BRANCH_GAP,
67
+ yesAxis: 25,
68
+ noAxis: 75,
69
+ });
70
+ const actionGroups: Array<{ title: string; actions: Array<{ type: InsertAction; label: string }> }> = [
71
+ {
72
+ title: '组件操作',
73
+ actions: [
74
+ { type: 'tap', label: '录制点击' },
75
+ { type: 'input', label: '录制输入' },
76
+ { type: 'clearInput', label: '清空输入' },
77
+ { type: 'coordinateTap', label: '点击坐标' },
78
+ { type: 'longPress', label: '长按' },
79
+ ],
80
+ },
81
+ {
82
+ title: '设备操作',
83
+ actions: [
84
+ { type: 'keyBack', label: '系统返回' },
85
+ { type: 'keyHome', label: 'Home 键' },
86
+ { type: 'keyRecent', label: '最近任务' },
87
+ { type: 'keyPower', label: '电源键' },
88
+ { type: 'swipe', label: '滑动' },
89
+ { type: 'pinch', label: '双指缩放' },
90
+ ],
91
+ },
92
+ {
93
+ title: '等待断言',
94
+ actions: [
95
+ { type: 'delay', label: '添加延时' },
96
+ { type: 'popupCondition', label: '判断存在' },
97
+ { type: 'tapIfExists', label: '存在则点击' },
98
+ { type: 'inputIfExists', label: '存在则输入' },
99
+ { type: 'clearIfExists', label: '存在则清空' },
100
+ { type: 'backIfExists', label: '存在则返回' },
101
+ { type: 'waitFor', label: '等待出现' },
102
+ { type: 'assertExists', label: '断言存在' },
103
+ { type: 'assertText', label: '断言文本' },
104
+ { type: 'waitDisappear', label: '等待元素消失' },
105
+ { type: 'waitActivity', label: '等待 Activity' },
106
+ ],
107
+ },
108
+ { title: '流程控制', actions: [{ type: 'runScript', label: '连接脚本' }] },
109
+ ];
110
+
111
+ function defaultKind(step: AppiumRecordedStep): FlowKind {
112
+ if (step.flow?.nodeKind) return step.flow.nodeKind;
113
+ if (step.type === 'assertExists' || step.type === 'assertText') return 'assertion';
114
+ return 'action';
115
+ }
116
+
117
+ function kindLabel(kind: FlowKind) {
118
+ return { action: '操作', condition: '判断', assertion: '校验' }[kind];
119
+ }
120
+
121
+ function typeLabel(step: AppiumRecordedStep) {
122
+ const labels: Partial<Record<AppiumRecordedStep['type'], string>> = {
123
+ tap: '点击', input: '输入', tapIfExists: '存在则点击', inputIfExists: '存在则输入',
124
+ clearIfExists: '存在则清空', backIfExists: '存在则返回', clearInput: '清空',
125
+ waitFor: '等待出现', waitDisappear: '等待消失', assertExists: '断言存在',
126
+ assertText: '断言文本', key: '按键', waitActivity: '等待 Activity', delay: '延时',
127
+ coordinateTap: '坐标点击', swipe: '滑动', launchApp: '启动 APP', longPress: '长按',
128
+ pinch: '双指缩放', runScript: '连接脚本', screenshot: '截图',
129
+ };
130
+ return labels[step.type] || step.type;
131
+ }
132
+
133
+ function stepMeta(step: AppiumRecordedStep) {
134
+ if (step.type === 'delay') return `${step.timeoutMs || 1000}ms`;
135
+ if (step.type === 'input' || step.type === 'inputIfExists') return `输入内容:${step.value || '空'}`;
136
+ if (defaultKind(step) === 'condition' && step.value) {
137
+ return `${step.flow?.textMatch === 'exact' ? '精准匹配' : '模糊匹配'}:${step.value}`;
138
+ }
139
+ if (step.contextSelector && step.selector) {
140
+ return `父级 ${step.contextSelector.strategy} ${step.contextSelector.value || ''} + 子级 ${step.selector.strategy} ${step.selector.value || ''}`;
141
+ }
142
+ if (step.type === 'key') return `keyCode ${step.keyCode || ''}`;
143
+ if (step.type === 'runScript') return step.value ? `脚本 ${step.value}` : '';
144
+ return `${step.selector?.strategy || ''} ${step.selector?.value || ''}`.trim();
145
+ }
146
+
147
+ function branchItems(branch: BranchName) {
148
+ return stepItems.value.filter(({ step }) => (
149
+ step.flow?.parentConditionId === props.condition.id && step.flow.parentBranch === branch
150
+ ));
151
+ }
152
+
153
+ function branchConnectionTargetId(branch: BranchName) {
154
+ const items = branchItems(branch);
155
+ if (items.length) return items[items.length - 1]?.step.flow?.successTargetId || '';
156
+ const targetId = branch === 'yes' ? props.condition.flow?.yesTargetId : props.condition.flow?.noTargetId;
157
+ const target = props.steps.find((step) => step.id === targetId);
158
+ return target && !target.flow?.parentConditionId ? target.id : '';
159
+ }
160
+
161
+ function targetLabel(branch: BranchName) {
162
+ const id = branch === 'yes' ? props.condition.flow?.yesTargetId : props.condition.flow?.noTargetId;
163
+ if (!id) return '结束';
164
+ const index = props.steps.findIndex((step) => step.id === id);
165
+ return index < 0 ? '未找到目标' : props.steps[index].label;
166
+ }
167
+
168
+ function isDescendant(descendantIndex: number, ancestorIndex: number) {
169
+ const ancestorId = props.steps[ancestorIndex]?.id;
170
+ let parentId = props.steps[descendantIndex]?.flow?.parentConditionId;
171
+ while (ancestorId && parentId) {
172
+ if (parentId === ancestorId) return true;
173
+ parentId = props.steps.find((step) => step.id === parentId)?.flow?.parentConditionId;
174
+ }
175
+ return false;
176
+ }
177
+
178
+ function isSelected(index: number) {
179
+ return Boolean(props.selectedCopyIndexes?.includes(index)
180
+ || props.selectedCopyIndexes?.some((selectedIndex) => isDescendant(index, selectedIndex)));
181
+ }
182
+
183
+ function canOpenInsertMenu() {
184
+ return !props.disabled || Boolean(props.allowedLockedActions?.length);
185
+ }
186
+
187
+ function isActionDisabled(action: InsertAction) {
188
+ return Boolean(props.disabled && !props.allowedLockedActions?.includes(action));
189
+ }
190
+
191
+ function insert(branch: BranchName, command: string | number | object) {
192
+ if (command === PASTE_COMMAND) emit('paste', props.conditionIndex, branch);
193
+ else emit('insertBranchAction', props.conditionIndex, branch, command as InsertAction);
194
+ }
195
+
196
+ function toggleConnection(branch: BranchName) {
197
+ if (branchConnectionTargetId(branch)) emit('disconnectNext', props.conditionIndex, branch);
198
+ else emit('connectNext', props.conditionIndex, branch);
199
+ }
200
+
201
+ function updateStep(payload: { index: number; step: AppiumRecordedStep }) {
202
+ emit('updateStep', payload.index, payload.step);
203
+ }
204
+
205
+ function branchPath(branch: BranchName) {
206
+ const targetAxis = branch === 'yes' ? layout.value.yesAxis : layout.value.noAxis;
207
+ return `M50 0 C50 16 ${targetAxis} 16 ${targetAxis} 31 L${targetAxis} 48`;
208
+ }
209
+ </script>
210
+
211
+ <template>
212
+ <div class="appium-flow-nested-condition" :style="{ width: `${layout.totalWidth}px`, minWidth: `${layout.totalWidth}px` }">
213
+ <div
214
+ class="appium-flow-branches"
215
+ :style="{
216
+ width: `${layout.totalWidth}px`,
217
+ minWidth: `${layout.totalWidth}px`,
218
+ gridTemplateColumns: `${layout.yesWidth}px ${layout.noWidth}px`,
219
+ }"
220
+ >
221
+ <svg class="appium-flow-branch-lines" viewBox="0 0 100 48" preserveAspectRatio="none" aria-hidden="true">
222
+ <path :d="branchPath('yes')" />
223
+ <path :d="branchPath('no')" />
224
+ </svg>
225
+ <div
226
+ v-for="branch in (['yes', 'no'] as const)"
227
+ :key="branch"
228
+ class="appium-flow-branch"
229
+ :class="[`appium-flow-branch--${branch}`, { 'appium-flow-branch--connected': branchConnectionTargetId(branch) }]"
230
+ >
231
+ <svg class="appium-flow-line appium-flow-branch__line" viewBox="0 0 10 100" preserveAspectRatio="none" aria-hidden="true">
232
+ <path d="M5 0 V100" />
233
+ </svg>
234
+ <strong class="appium-flow-branch__label">{{ branch === 'yes' ? '是' : '否' }}</strong>
235
+ <div v-if="branchItems(branch).length" class="appium-flow-branch-steps">
236
+ <div v-for="item in branchItems(branch)" :key="item.step.id" class="appium-flow-branch-step-group">
237
+ <div
238
+ class="appium-flow-node appium-flow-branch-step"
239
+ :class="[
240
+ `appium-flow-node--${defaultKind(item.step)}`,
241
+ {
242
+ 'appium-flow-node--expanded': expandedStepIndex === item.index,
243
+ 'appium-flow-node--selected': isSelected(item.index),
244
+ 'appium-flow-node--copying': copyMode,
245
+ },
246
+ ]"
247
+ >
248
+ <button
249
+ type="button"
250
+ class="appium-flow-node__toggle"
251
+ :aria-expanded="expandedStepIndex === item.index"
252
+ @click.stop="$emit('nodeClick', item.index)"
253
+ >
254
+ <span class="appium-flow-node__body">
255
+ <span class="appium-flow-node__title">{{ item.step.label }}</span>
256
+ <span class="appium-flow-node__meta">
257
+ {{ kindLabel(defaultKind(item.step)) }} · {{ typeLabel(item.step) }}
258
+ <template v-if="stepMeta(item.step)"> · {{ stepMeta(item.step) }}</template>
259
+ </span>
260
+ <span v-if="item.step.note" class="appium-flow-node__note">{{ item.step.note }}</span>
261
+ </span>
262
+ </button>
263
+ <span class="appium-flow-node__actions" @click.stop>
264
+ <el-button
265
+ v-if="!copyMode"
266
+ text
267
+ size="small"
268
+ :icon="CopyDocument"
269
+ title="复制节点"
270
+ @click="$emit('copy', [item.index])"
271
+ />
272
+ <el-button
273
+ v-if="item.step.type === 'input' || item.step.type === 'inputIfExists'"
274
+ text
275
+ size="small"
276
+ :icon="Edit"
277
+ :disabled="disabled"
278
+ title="修改输入内容"
279
+ @click="$emit('editInput', item.index)"
280
+ />
281
+ <el-button
282
+ text
283
+ size="small"
284
+ :icon="Delete"
285
+ :disabled="disabled"
286
+ title="删除节点"
287
+ @click="$emit('remove', item.index)"
288
+ />
289
+ </span>
290
+ </div>
291
+ <FlowStepEditor
292
+ v-if="expandedStepIndex === item.index"
293
+ :step="item.step"
294
+ :index="item.index"
295
+ :disabled="disabled"
296
+ @update="updateStep"
297
+ />
298
+ <NestedConditionBranches
299
+ v-if="defaultKind(item.step) === 'condition'"
300
+ :steps="steps"
301
+ :condition="item.step"
302
+ :condition-index="item.index"
303
+ :expanded-step-index="expandedStepIndex"
304
+ :copy-mode="copyMode"
305
+ :selected-copy-indexes="selectedCopyIndexes"
306
+ :disabled="disabled"
307
+ :allowed-locked-actions="allowedLockedActions"
308
+ :clipboard-count="clipboardCount"
309
+ @node-click="$emit('nodeClick', $event)"
310
+ @copy="$emit('copy', $event)"
311
+ @remove="$emit('remove', $event)"
312
+ @paste="(index, childBranch) => $emit('paste', index, childBranch)"
313
+ @insert-branch-action="(index, childBranch, action) => $emit('insertBranchAction', index, childBranch, action)"
314
+ @connect-next="(index, childBranch) => $emit('connectNext', index, childBranch)"
315
+ @disconnect-next="(index, childBranch) => $emit('disconnectNext', index, childBranch)"
316
+ @edit-input="$emit('editInput', $event)"
317
+ @update-step="(index, step) => $emit('updateStep', index, step)"
318
+ />
319
+ </div>
320
+ </div>
321
+ <span v-else class="appium-flow-branch__target">{{ targetLabel(branch) }}</span>
322
+ <div class="appium-flow-branch__actions">
323
+ <el-dropdown
324
+ trigger="click"
325
+ :disabled="!canOpenInsertMenu()"
326
+ max-height="320px"
327
+ popper-class="appium-action-dropdown"
328
+ @command="insert(branch, $event)"
329
+ >
330
+ <el-button
331
+ size="small"
332
+ class="appium-flow-branch__insert"
333
+ :disabled="!canOpenInsertMenu()"
334
+ :icon="Timer"
335
+ @click.stop
336
+ >
337
+ 插入操作
338
+ </el-button>
339
+ <template #dropdown>
340
+ <el-dropdown-menu>
341
+ <el-dropdown-item v-if="clipboardCount" :command="PASTE_COMMAND" divided>
342
+ 粘贴 {{ clipboardCount }} 个节点
343
+ </el-dropdown-item>
344
+ <template v-for="group in actionGroups" :key="group.title">
345
+ <div class="appium-action-dropdown__group">{{ group.title }}</div>
346
+ <el-dropdown-item
347
+ v-for="action in group.actions"
348
+ :key="action.type"
349
+ :command="action.type"
350
+ :disabled="isActionDisabled(action.type)"
351
+ >
352
+ {{ action.label }}
353
+ </el-dropdown-item>
354
+ </template>
355
+ </el-dropdown-menu>
356
+ </template>
357
+ </el-dropdown>
358
+ <el-button
359
+ v-if="branchConnectionTargetId(branch)"
360
+ size="small"
361
+ class="appium-flow-branch__connect"
362
+ :icon="Link"
363
+ :disabled="disabled"
364
+ title="取消当前分支与后续流程的连接"
365
+ @click.stop="toggleConnection(branch)"
366
+ >
367
+ 取消连接
368
+ </el-button>
369
+ </div>
370
+ </div>
371
+ </div>
372
+ </div>
373
+ </template>