android-midscene-automation 0.1.42 → 0.2.1
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/AI/350/257/206/345/210/253/346/223/215/344/275/234/350/257/264/346/230/216.md +442 -0
- package/CHANGELOG.md +12 -2
- package/FAQ.md +8 -8
- package/README.md +156 -19
- package/USAGE.md +134 -9
- package/package.json +3 -2
- package/server/appium-auto-save.test.ts +2 -1
- package/server/appium-config.test.ts +48 -1
- package/server/appium-recorder/ai-recognition.test.ts +27 -0
- package/server/appium-recorder/ai-recognition.ts +16 -4
- package/server/appium-recorder/appium-runner.ts +32 -2
- package/server/appium-recorder/bounded-loop.test.ts +55 -0
- package/server/appium-recorder/bounded-loop.ts +12 -1
- package/server/appium-recorder/prompt-optimizer.ts +57 -0
- package/server/appium-recorder/report-summary.test.ts +78 -0
- package/server/appium-recorder/report-summary.ts +60 -0
- package/server/appium-recorder/report.ts +31 -2
- package/server/appium-recorder/repository.ts +8 -2
- package/server/appium-recorder/routes.ts +53 -3
- package/server/config.ts +17 -2
- package/server/http-api.ts +8 -4
- package/src/App.vue +34 -5
- package/src/api.ts +2 -2
- package/src/appium-recorder/AppiumPage.vue +126 -62
- package/src/appium-recorder/action-descriptions.ts +1 -0
- package/src/appium-recorder/ai-recognition.ts +7 -0
- package/src/appium-recorder/api.ts +22 -2
- package/src/appium-recorder/bounded-loop.ts +9 -1
- package/src/appium-recorder/components/AiRecognitionSettings.vue +57 -6
- package/src/appium-recorder/components/BreakLoopSettings.vue +19 -3
- package/src/appium-recorder/components/ComponentTree.vue +5 -5
- package/src/appium-recorder/components/CoordinatePointPicker.vue +23 -0
- package/src/appium-recorder/components/FlowActionMenu.vue +50 -20
- package/src/appium-recorder/components/FlowStepEditor.vue +10 -2
- package/src/appium-recorder/components/NestedConditionBranches.vue +18 -49
- package/src/appium-recorder/components/RecordedSteps.vue +16 -5
- package/src/appium-recorder/coordinate-preview.ts +37 -0
- package/src/appium-recorder/flow-copy.ts +1 -0
- package/src/appium-recorder/flow-graph.ts +5 -2
- package/src/appium-recorder/flow-labels.ts +4 -2
- package/src/appium-recorder/flow-merge.ts +1 -1
- package/src/appium-recorder/flow-unmerge.ts +2 -2
- package/src/appium-recorder/report-summary.ts +60 -0
- package/src/appium-recorder/types.ts +4 -1
- package/src/components/config/AiDeduplicationSettings.vue +5 -4
- package/src/components/config/AiPromptPresetsSettings.vue +64 -17
- package/src/components/config/AppiumConfigPanel.vue +66 -38
- package/src/components/config/AppiumModelSettings.vue +45 -0
- package/src/components/config/FlowAppearanceSettings.vue +0 -1
- package/src/components/config/ReplayReportSummarySettings.vue +118 -0
- package/src/components/device/DevicePreviewPanel.vue +18 -3
- package/src/components/help/HelpCenterDialog.vue +160 -0
- package/src/components/help/MarkdownDocument.vue +139 -0
- package/src/config/use-appium-auto-save.ts +12 -4
- package/src/pages/ConfigPage.vue +4 -2
- package/src/style.css +37 -45
- package/src/types.ts +2 -1
- package//346/265/201/347/250/213/350/212/202/347/202/271/346/223/215/344/275/234/350/257/264/346/230/216.md +408 -0
|
@@ -42,8 +42,15 @@ export function breakLoopTarget(steps: readonly AppiumRecordedStep[], step: Appi
|
|
|
42
42
|
return target;
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
+
export function continueLoopTarget(steps: readonly AppiumRecordedStep[], step: AppiumRecordedStep) {
|
|
46
|
+
const loops = enclosingLoops(steps, step);
|
|
47
|
+
const target = step.continueLoopTargetId ? loops.find((loop) => loop.id === step.continueLoopTargetId) : loops[0];
|
|
48
|
+
if (!target) throw new Error('继续循环目标必须是当前节点所属的循环体,目标可能已删除或不在当前分支');
|
|
49
|
+
return target;
|
|
50
|
+
}
|
|
51
|
+
|
|
45
52
|
export function validateLoopSteps(steps: readonly AppiumRecordedStep[]) {
|
|
46
|
-
if (!steps.some((step) =>
|
|
53
|
+
if (!steps.some((step) => ['loop', 'breakLoop', 'continueLoop'].includes(step.type))) return;
|
|
47
54
|
const byId = new Map(steps.map((step) => [step.id, step]));
|
|
48
55
|
if (byId.size !== steps.length) throw new Error('流程节点 ID 重复');
|
|
49
56
|
for (const step of steps) {
|
|
@@ -58,6 +65,7 @@ export function validateLoopSteps(steps: readonly AppiumRecordedStep[]) {
|
|
|
58
65
|
}
|
|
59
66
|
if (step.type === 'loop') validateLoop(step);
|
|
60
67
|
if (step.type === 'breakLoop') breakLoopTarget(steps, step);
|
|
68
|
+
if (step.type === 'continueLoop') continueLoopTarget(steps, step);
|
|
61
69
|
if (step.type === 'loop' && step.flow?.yesTargetId) {
|
|
62
70
|
const target = byId.get(step.flow.yesTargetId);
|
|
63
71
|
if (!target || target.flow?.parentConditionId !== step.id || target.flow.parentBranch !== 'yes') throw new Error('循环入口必须指向所属循环体');
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
-
import { computed, inject, onBeforeUnmount, ref, watch } from 'vue';
|
|
2
|
+
import { computed, inject, onBeforeUnmount, ref, shallowRef, watch } from 'vue';
|
|
3
3
|
import { Loading, QuestionFilled } from '@element-plus/icons-vue';
|
|
4
4
|
import { isAiBranchEnabled, validateAiRecognitionPrompt, DEFAULT_AI_OBSERVATION, validateAiObservation, type AiObservationConfig } from '../ai-recognition';
|
|
5
|
-
import { validateAiBranchQuestion } from '../api';
|
|
5
|
+
import { optimizeAppiumPrompt, validateAiBranchQuestion } from '../api';
|
|
6
|
+
import { flowBranchLabel } from '../flow-labels';
|
|
6
7
|
import type { AppiumRecordedStep } from '../types';
|
|
7
8
|
import { aiPromptPresetsKey, DEFAULT_AI_PROMPT_PRESETS } from '../ai-prompt-presets';
|
|
8
9
|
import AiRecognitionPromptInput from './AiRecognitionPromptInput.vue';
|
|
@@ -12,14 +13,18 @@ const emit = defineEmits<{ update: [patch: Partial<AppiumRecordedStep>] }>();
|
|
|
12
13
|
const prompt = ref(props.step.value || '');
|
|
13
14
|
const promptPresets = inject(aiPromptPresetsKey, ref(DEFAULT_AI_PROMPT_PRESETS));
|
|
14
15
|
const busy = ref(false);
|
|
16
|
+
const optimizingPrompt = shallowRef(false);
|
|
15
17
|
const error = ref('');
|
|
16
18
|
const branch = computed(() => isAiBranchEnabled(props.step));
|
|
17
19
|
const untilMatch = computed(() => props.step.aiObservation?.mode === 'untilMatch');
|
|
18
20
|
const branchChoice = ref(branch.value);
|
|
21
|
+
const fallbackEnabled = shallowRef(Boolean(props.step.aiInvalidResultBranch));
|
|
19
22
|
watch(branch, value => { branchChoice.value = value; });
|
|
23
|
+
watch(() => props.step.aiInvalidResultBranch, value => { fallbackEnabled.value = Boolean(value); });
|
|
20
24
|
let controller: AbortController | undefined;
|
|
25
|
+
let optimizeController: AbortController | undefined;
|
|
21
26
|
watch(() => props.step.value, value => { prompt.value = value || ''; });
|
|
22
|
-
onBeforeUnmount(() => controller?.abort());
|
|
27
|
+
onBeforeUnmount(() => { controller?.abort(); optimizeController?.abort(); });
|
|
23
28
|
|
|
24
29
|
function updateObservation(patch: Partial<AiObservationConfig>) {
|
|
25
30
|
try {
|
|
@@ -35,6 +40,33 @@ async function toggleBranch(enabled: boolean) {
|
|
|
35
40
|
branchChoice.value = branch.value;
|
|
36
41
|
}
|
|
37
42
|
|
|
43
|
+
function toggleFallback(enabled: boolean) {
|
|
44
|
+
fallbackEnabled.value = enabled;
|
|
45
|
+
error.value = '';
|
|
46
|
+
if (!enabled) emit('update', { aiInvalidResultBranch: undefined });
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function optimizePrompt() {
|
|
50
|
+
if (busy.value || optimizingPrompt.value || props.disabled) return;
|
|
51
|
+
error.value = '';
|
|
52
|
+
optimizeController = new AbortController();
|
|
53
|
+
optimizingPrompt.value = true;
|
|
54
|
+
let optimized = '';
|
|
55
|
+
try {
|
|
56
|
+
optimized = (await optimizeAppiumPrompt({
|
|
57
|
+
kind: 'aiRecognition',
|
|
58
|
+
prompt: prompt.value,
|
|
59
|
+
condition: branch.value || untilMatch.value,
|
|
60
|
+
}, optimizeController.signal)).prompt;
|
|
61
|
+
prompt.value = optimized;
|
|
62
|
+
} catch (cause) {
|
|
63
|
+
if (!optimizeController.signal.aborted) error.value = cause instanceof Error ? cause.message : '提示词优化失败';
|
|
64
|
+
} finally {
|
|
65
|
+
optimizingPrompt.value = false;
|
|
66
|
+
}
|
|
67
|
+
if (optimized) await save(branch.value);
|
|
68
|
+
}
|
|
69
|
+
|
|
38
70
|
async function save(enabled: boolean, observation?: AiObservationConfig) {
|
|
39
71
|
if (busy.value || props.disabled) return;
|
|
40
72
|
error.value = '';
|
|
@@ -112,9 +144,10 @@ async function save(enabled: boolean, observation?: AiObservationConfig) {
|
|
|
112
144
|
</el-tooltip>
|
|
113
145
|
</span>
|
|
114
146
|
</template>
|
|
115
|
-
<AiRecognitionPromptInput v-model="prompt" :presets="promptPresets" :placeholder="untilMatch ? '例如:当前画面是否出现广告弹窗?' : ''" :disabled="disabled || busy" @change="error = ''; !branch && !untilMatch && save(false)" />
|
|
116
|
-
<div
|
|
117
|
-
<el-button :disabled="disabled || busy" @click="
|
|
147
|
+
<AiRecognitionPromptInput v-model="prompt" :presets="promptPresets" :placeholder="untilMatch ? '例如:当前画面是否出现广告弹窗?' : ''" :disabled="disabled || busy || optimizingPrompt" @change="error = ''; !branch && !untilMatch && save(false)" />
|
|
148
|
+
<div class="recognition-prompt-actions">
|
|
149
|
+
<el-button :loading="optimizingPrompt" :disabled="disabled || busy || !prompt.trim()" @click="optimizePrompt">优化提示词</el-button>
|
|
150
|
+
<el-button v-if="(branch || untilMatch) && prompt !== step.value" :disabled="disabled || busy || optimizingPrompt" @click="save(branch)">验证并保存问题</el-button>
|
|
118
151
|
</div>
|
|
119
152
|
</el-form-item>
|
|
120
153
|
<el-form-item>
|
|
@@ -124,6 +157,24 @@ async function save(enabled: boolean, observation?: AiObservationConfig) {
|
|
|
124
157
|
正在验证…
|
|
125
158
|
</span>
|
|
126
159
|
</el-form-item>
|
|
160
|
+
<el-form-item v-if="branch">
|
|
161
|
+
<el-checkbox :model-value="fallbackEnabled" :disabled="disabled || busy" @update:model-value="toggleFallback($event === true)">启用兜底</el-checkbox>
|
|
162
|
+
<el-tooltip placement="top" :show-after="200">
|
|
163
|
+
<template #content><div class="recognition-help-content">仅在模型返回空内容、无效格式、无法判断或没有返回 true/false 时生效。正常返回布尔结果时仍按识别结果进入分支;请求失败和超时不使用此兜底。</div></template>
|
|
164
|
+
<el-button class="recognition-help" text :icon="QuestionFilled" aria-label="AI 识别兜底说明" @click.prevent />
|
|
165
|
+
</el-tooltip>
|
|
166
|
+
</el-form-item>
|
|
167
|
+
<el-form-item v-if="branch && fallbackEnabled" label="兜底分支">
|
|
168
|
+
<el-select
|
|
169
|
+
:model-value="step.aiInvalidResultBranch || ''"
|
|
170
|
+
:disabled="disabled || busy"
|
|
171
|
+
placeholder="请选择左右分支"
|
|
172
|
+
@update:model-value="emit('update', { aiInvalidResultBranch: $event })"
|
|
173
|
+
>
|
|
174
|
+
<el-option :label="`进入左侧分支(${flowBranchLabel(step, 'yes')})`" value="yes" />
|
|
175
|
+
<el-option :label="`进入右侧分支(${flowBranchLabel(step, 'no')})`" value="no" />
|
|
176
|
+
</el-select>
|
|
177
|
+
</el-form-item>
|
|
127
178
|
<el-alert v-if="error" :title="error" type="warning" :closable="false" show-icon />
|
|
128
179
|
</template>
|
|
129
180
|
|
|
@@ -3,14 +3,30 @@ import { computed } from 'vue';
|
|
|
3
3
|
import type { AppiumRecordedStep } from '../types';
|
|
4
4
|
import { enclosingLoops } from '../bounded-loop';
|
|
5
5
|
|
|
6
|
-
const props = defineProps<{
|
|
6
|
+
const props = defineProps<{
|
|
7
|
+
step: AppiumRecordedStep;
|
|
8
|
+
steps: AppiumRecordedStep[];
|
|
9
|
+
disabled?: boolean;
|
|
10
|
+
mode?: 'break' | 'continue';
|
|
11
|
+
}>();
|
|
7
12
|
const emit = defineEmits<{ update: [patch: Partial<AppiumRecordedStep>] }>();
|
|
8
13
|
const loops = computed(() => enclosingLoops(props.steps, props.step));
|
|
14
|
+
const isContinue = computed(() => props.mode === 'continue');
|
|
15
|
+
const fieldLabel = computed(() => isContinue.value ? '继续目标循环' : '退出目标循环');
|
|
16
|
+
const targetId = computed(() => (
|
|
17
|
+
isContinue.value ? props.step.continueLoopTargetId : props.step.breakLoopTargetId
|
|
18
|
+
) || loops.value[0]?.id);
|
|
19
|
+
|
|
20
|
+
function updateTarget(value: string) {
|
|
21
|
+
emit('update', isContinue.value
|
|
22
|
+
? { continueLoopTargetId: value }
|
|
23
|
+
: { breakLoopTargetId: value });
|
|
24
|
+
}
|
|
9
25
|
</script>
|
|
10
26
|
|
|
11
27
|
<template>
|
|
12
|
-
<el-form-item label="
|
|
13
|
-
<el-select class="break-loop-target" popper-class="break-loop-target-popper" :model-value="
|
|
28
|
+
<el-form-item :label="fieldLabel">
|
|
29
|
+
<el-select class="break-loop-target" popper-class="break-loop-target-popper" :model-value="targetId" :disabled="disabled" :aria-label="fieldLabel" @update:model-value="updateTarget">
|
|
14
30
|
<el-option class="break-loop-target-option" v-for="(loop, index) in loops" :key="loop.id" :value="loop.id" :label="`${loop.label}(${index === 0 ? '当前循环' : `外层 ${index}`} · 节点 ${steps.findIndex(item => item.id === loop.id) + 1})`" />
|
|
15
31
|
</el-select>
|
|
16
32
|
</el-form-item>
|
|
@@ -69,9 +69,9 @@ watch(
|
|
|
69
69
|
</template>
|
|
70
70
|
|
|
71
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
|
|
74
|
-
.appium-tree :deep(.el-tree) { min-width:
|
|
75
|
-
.appium-tree-node { flex: 1
|
|
76
|
-
.appium-tree-node__label {
|
|
72
|
+
.appium-tree-container { display: flex; flex-direction: column; gap: 8px; min-width: 0; min-height: 0; }
|
|
73
|
+
.appium-tree { flex: 1; height: auto; overflow: auto; }
|
|
74
|
+
.appium-tree :deep(.el-tree) { width: max-content; min-width: 100%; }
|
|
75
|
+
.appium-tree-node { flex: 1 0 auto; }
|
|
76
|
+
.appium-tree-node__label { white-space: nowrap; }
|
|
77
77
|
</style>
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed, inject, onBeforeUnmount, watch } from 'vue';
|
|
3
|
+
import { Aim } from '@element-plus/icons-vue';
|
|
4
|
+
import { coordinatePreviewKey } from '../coordinate-preview';
|
|
5
|
+
|
|
6
|
+
const props = defineProps<{ disabled?: boolean }>();
|
|
7
|
+
const emit = defineEmits<{ select: [point: { x: number; y: number }] }>();
|
|
8
|
+
const picker = inject(coordinatePreviewKey, undefined);
|
|
9
|
+
const owner = Symbol('coordinate-editor');
|
|
10
|
+
const picking = computed(() => picker?.owner.value === owner);
|
|
11
|
+
function cancel() { picker?.cancel(owner); }
|
|
12
|
+
watch(() => props.disabled, (disabled) => { if (disabled) cancel(); });
|
|
13
|
+
onBeforeUnmount(cancel);
|
|
14
|
+
</script>
|
|
15
|
+
|
|
16
|
+
<template>
|
|
17
|
+
<el-form-item>
|
|
18
|
+
<el-button :icon="Aim" :disabled="disabled || !picker?.enabled.value" @click="picker?.start(owner, point => emit('select', point))">
|
|
19
|
+
{{ picking ? '请在左侧设备预览点击' : '获取点击坐标' }}
|
|
20
|
+
</el-button>
|
|
21
|
+
<el-button v-if="picking" text @click="cancel">取消取点</el-button>
|
|
22
|
+
</el-form-item>
|
|
23
|
+
</template>
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import { nextTick, shallowRef } from 'vue';
|
|
3
3
|
import type { MenuInstance, PopoverInstance } from 'element-plus';
|
|
4
|
-
import { PASTE_COMMAND, type FlowActionGroup, type InsertAction } from '../flow-graph';
|
|
4
|
+
import { PASTE_COMMAND, type FlowActionGroup, type FlowActionSubgroup, type InsertAction } from '../flow-graph';
|
|
5
5
|
import { actionDescriptions } from '../action-descriptions';
|
|
6
6
|
|
|
7
7
|
const props = defineProps<{
|
|
@@ -21,6 +21,10 @@ const menuRef = shallowRef<HTMLElement>();
|
|
|
21
21
|
const menuControlRef = shallowRef<MenuInstance>();
|
|
22
22
|
const menuMounted = shallowRef(false);
|
|
23
23
|
|
|
24
|
+
function isSubgroup(item: FlowActionGroup['actions'][number]): item is FlowActionSubgroup {
|
|
25
|
+
return 'actions' in item;
|
|
26
|
+
}
|
|
27
|
+
|
|
24
28
|
async function openMenu() {
|
|
25
29
|
// 每次打开重新创建面板,避免上次选中的操作被当成当前值而无法重复添加。
|
|
26
30
|
menuMounted.value = true;
|
|
@@ -55,7 +59,7 @@ async function handleMenuKeydown(event: KeyboardEvent) {
|
|
|
55
59
|
} else if (group) {
|
|
56
60
|
menuControlRef.value?.open(group);
|
|
57
61
|
await nextTick();
|
|
58
|
-
target.querySelector<HTMLElement>('
|
|
62
|
+
target.querySelector<HTMLElement>('[role="menuitem"]:not(.is-disabled)')?.focus();
|
|
59
63
|
} else if (event.key !== 'ArrowRight') {
|
|
60
64
|
target.click();
|
|
61
65
|
}
|
|
@@ -119,25 +123,51 @@ function selectAction(value: string) {
|
|
|
119
123
|
@click="openGroupOnClick($event, group.title)"
|
|
120
124
|
>
|
|
121
125
|
<template #title>{{ group.title }}</template>
|
|
122
|
-
<
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
126
|
+
<template v-for="item in group.actions" :key="isSubgroup(item) ? item.label : item.type">
|
|
127
|
+
<el-sub-menu
|
|
128
|
+
v-if="isSubgroup(item)"
|
|
129
|
+
:index="`${group.title}/${item.label}`"
|
|
130
|
+
:data-group="`${group.title}/${item.label}`"
|
|
131
|
+
:aria-label="item.label"
|
|
132
|
+
tabindex="-1"
|
|
133
|
+
:teleported="true"
|
|
134
|
+
popper-class="appium-action-submenu appium-action-submenu--third"
|
|
135
|
+
@click.stop="openGroupOnClick($event, `${group.title}/${item.label}`)"
|
|
136
|
+
>
|
|
137
|
+
<template #title>{{ item.label }}</template>
|
|
138
|
+
<el-tooltip
|
|
139
|
+
v-for="action in item.actions"
|
|
140
|
+
:key="action.type"
|
|
141
|
+
:content="actionDescriptions[action.type]"
|
|
142
|
+
placement="right"
|
|
143
|
+
effect="dark"
|
|
144
|
+
:show-after="350"
|
|
145
|
+
:hide-after="0"
|
|
146
|
+
:enterable="false"
|
|
147
|
+
:popper-options="{ modifiers: [{ name: 'preventOverflow', options: { altAxis: true, padding: 8 } }] }"
|
|
148
|
+
popper-class="appium-action-description"
|
|
149
|
+
>
|
|
150
|
+
<el-menu-item :index="action.type" :disabled="isActionDisabled(action.type)">
|
|
151
|
+
{{ action.label }}
|
|
152
|
+
</el-menu-item>
|
|
153
|
+
</el-tooltip>
|
|
154
|
+
</el-sub-menu>
|
|
155
|
+
<el-tooltip
|
|
156
|
+
v-else
|
|
157
|
+
:content="actionDescriptions[item.type]"
|
|
158
|
+
placement="right"
|
|
159
|
+
effect="dark"
|
|
160
|
+
:show-after="350"
|
|
161
|
+
:hide-after="0"
|
|
162
|
+
:enterable="false"
|
|
163
|
+
:popper-options="{ modifiers: [{ name: 'preventOverflow', options: { altAxis: true, padding: 8 } }] }"
|
|
164
|
+
popper-class="appium-action-description"
|
|
137
165
|
>
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
166
|
+
<el-menu-item :index="item.type" :disabled="isActionDisabled(item.type)">
|
|
167
|
+
{{ item.label }}
|
|
168
|
+
</el-menu-item>
|
|
169
|
+
</el-tooltip>
|
|
170
|
+
</template>
|
|
141
171
|
</el-sub-menu>
|
|
142
172
|
<el-menu-item v-if="clipboardCount" :index="PASTE_COMMAND" class="appium-action-paste">
|
|
143
173
|
粘贴 {{ clipboardCount }} 个节点
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import { computed } from 'vue';
|
|
3
|
+
import CoordinatePointPicker from './CoordinatePointPicker.vue';
|
|
3
4
|
import type { AppiumRecordedStep, AppiumSelector } from '../types';
|
|
4
5
|
import { normalizeVisualChangeConfig } from '../visual-change';
|
|
5
6
|
import { longPressMode } from '../long-press';
|
|
@@ -99,6 +100,12 @@ function patchTapCoordinate(axis: 'centerX' | 'centerY', value: number | undefin
|
|
|
99
100
|
? { label: `点击坐标 ${fallback.centerX},${fallback.centerY}` } : {}) });
|
|
100
101
|
}
|
|
101
102
|
|
|
103
|
+
function applyTapPoint(point: { x: number; y: number }) {
|
|
104
|
+
const fallback = { ...props.step.fallback, strategy: 'bounds' as const, centerX: point.x, centerY: point.y };
|
|
105
|
+
patchStep({ fallback, ...(/^点击坐标 \d+,\d+$/.test(props.step.label)
|
|
106
|
+
? { label: `点击坐标 ${point.x},${point.y}` } : {}) });
|
|
107
|
+
}
|
|
108
|
+
|
|
102
109
|
function patchVisualChange(patch: Partial<VisualChangeConfig>) {
|
|
103
110
|
patchStep({
|
|
104
111
|
visualChange: normalizeVisualChangeConfig({
|
|
@@ -158,7 +165,7 @@ function patchSelector(patch: Partial<AppiumSelector>) {
|
|
|
158
165
|
<el-form-item v-if="step.type === 'aiRecognition'">
|
|
159
166
|
<el-checkbox :model-value="step.aiTimeoutEnabled === true" :disabled="disabled" @update:model-value="patchStep({ aiTimeoutEnabled: $event === true })">启用超时</el-checkbox>
|
|
160
167
|
</el-form-item>
|
|
161
|
-
<el-form-item v-if="!['longPress', 'stopApp', 'log', 'openGallery', 'endFlow', 'loop', 'breakLoop'].includes(step.type) && (step.type !== 'aiRecognition' || step.aiTimeoutEnabled === true)" :label="step.type === 'aiRecognition' && step.aiObservation ? '模型请求超时 ms' : '超时时间 ms'">
|
|
168
|
+
<el-form-item v-if="!['longPress', 'stopApp', 'log', 'openGallery', 'endFlow', 'loop', 'breakLoop', 'continueLoop'].includes(step.type) && (step.type !== 'aiRecognition' || step.aiTimeoutEnabled === true)" :label="step.type === 'aiRecognition' && step.aiObservation ? '模型请求超时 ms' : '超时时间 ms'">
|
|
162
169
|
<el-input-number
|
|
163
170
|
:model-value="step.timeoutMs ?? (step.type === 'delay' ? 1000 : DEFAULT_NODE_TIMEOUT_MS)"
|
|
164
171
|
:disabled="disabled"
|
|
@@ -196,11 +203,12 @@ function patchSelector(patch: Partial<AppiumSelector>) {
|
|
|
196
203
|
<el-input-number :model-value="step.fallback?.centerY ?? 0" :disabled="disabled" :min="0" :max="99999" :precision="0" controls-position="right" aria-label="坐标 Y" @update:model-value="patchTapCoordinate('centerY', $event)" />
|
|
197
204
|
</el-form-item>
|
|
198
205
|
</div>
|
|
206
|
+
<CoordinatePointPicker v-if="step.type === 'coordinateTap'" :disabled="disabled" @select="applyTapPoint" />
|
|
199
207
|
<StageLogSettings v-if="step.type === 'log'" :step="step" :disabled="disabled" @update="patchStep" />
|
|
200
208
|
<VariableExtractionSettings v-if="step.type === 'extractVariable'" :step="step" :disabled="disabled" @update="patchStep" />
|
|
201
209
|
<ScriptParameterSettings v-if="step.type === 'runScript'" :step="step" :disabled="disabled" @update="patchStep" />
|
|
202
210
|
<LoopSettings v-if="step.type === 'loop'" :step="step" :disabled="disabled" @update="patchStep" />
|
|
203
|
-
<BreakLoopSettings v-if="step.type === 'breakLoop'" :step="step" :steps="steps" :disabled="disabled" @update="patchStep" />
|
|
211
|
+
<BreakLoopSettings v-if="step.type === 'breakLoop' || step.type === 'continueLoop'" :step="step" :steps="steps" :disabled="disabled" :mode="step.type === 'continueLoop' ? 'continue' : 'break'" @update="patchStep" />
|
|
204
212
|
<TextClickSettings v-if="step.type === 'textClick'" :step="step" :disabled="disabled" @update="patchStep" />
|
|
205
213
|
<AiRecognitionSettings v-if="step.type === 'aiRecognition'" :step="step" :steps="steps" :disabled="disabled" @update="patchStep" />
|
|
206
214
|
<el-form-item
|
|
@@ -6,53 +6,10 @@ import type { AppiumRecordedStep } from '../types';
|
|
|
6
6
|
import { buildConditionLayouts, FLOW_BRANCH_GAP, FLOW_NODE_WIDTH } from '../flow-layout';
|
|
7
7
|
import FlowStepEditor from './FlowStepEditor.vue';
|
|
8
8
|
import FlowActionMenu from './FlowActionMenu.vue';
|
|
9
|
+
import type { FlowActionGroup, InsertAction } from '../flow-graph';
|
|
9
10
|
|
|
10
11
|
type BranchName = 'yes' | 'no';
|
|
11
12
|
type FlowKind = 'action' | 'condition' | 'assertion';
|
|
12
|
-
type InsertAction =
|
|
13
|
-
| 'delay'
|
|
14
|
-
| 'tap'
|
|
15
|
-
| 'input'
|
|
16
|
-
| 'clearInput'
|
|
17
|
-
| 'coordinateTap'
|
|
18
|
-
| 'longPress'
|
|
19
|
-
| 'keyBack'
|
|
20
|
-
| 'keyHome'
|
|
21
|
-
| 'keyRecent'
|
|
22
|
-
| 'keyPower'
|
|
23
|
-
| 'swipe'
|
|
24
|
-
| 'pinch'
|
|
25
|
-
| 'launchApp'
|
|
26
|
-
| 'stopApp'
|
|
27
|
-
| 'openGallery'
|
|
28
|
-
| 'endFlow'
|
|
29
|
-
| 'loop'
|
|
30
|
-
| 'breakLoop'
|
|
31
|
-
| 'clearAppData'
|
|
32
|
-
| 'popupCondition'
|
|
33
|
-
| 'checkboxState'
|
|
34
|
-
| 'checkedState'
|
|
35
|
-
| 'radioButtonState'
|
|
36
|
-
| 'aiRecognition'
|
|
37
|
-
| 'imageCheck'
|
|
38
|
-
| 'textClick'
|
|
39
|
-
| 'tapIfExists'
|
|
40
|
-
| 'inputIfExists'
|
|
41
|
-
| 'clearIfExists'
|
|
42
|
-
| 'backIfExists'
|
|
43
|
-
| 'waitFor'
|
|
44
|
-
| 'assertExists'
|
|
45
|
-
| 'assertText'
|
|
46
|
-
| 'waitDisappear'
|
|
47
|
-
| 'waitActivity'
|
|
48
|
-
| 'runScript'
|
|
49
|
-
| 'extractVariable'
|
|
50
|
-
| 'noop'
|
|
51
|
-
| 'log'
|
|
52
|
-
| 'visualChangeStart'
|
|
53
|
-
| 'visualChangeEnd'
|
|
54
|
-
| 'visualChange';
|
|
55
|
-
|
|
56
13
|
const PASTE_COMMAND = '__paste_flow_nodes__';
|
|
57
14
|
|
|
58
15
|
const props = defineProps<{
|
|
@@ -86,7 +43,7 @@ const layout = computed(() => buildConditionLayouts(props.steps).get(props.condi
|
|
|
86
43
|
yesAxis: 25,
|
|
87
44
|
noAxis: 75,
|
|
88
45
|
});
|
|
89
|
-
const actionGroups:
|
|
46
|
+
const actionGroups: FlowActionGroup[] = [
|
|
90
47
|
{
|
|
91
48
|
title: '组件操作',
|
|
92
49
|
actions: [
|
|
@@ -126,8 +83,13 @@ const actionGroups: Array<{ title: string; actions: Array<{ type: InsertAction;
|
|
|
126
83
|
{ type: 'waitFor', label: '等待出现' },
|
|
127
84
|
{ type: 'waitDisappear', label: '等待元素消失' },
|
|
128
85
|
{ type: 'waitActivity', label: '等待 Activity' },
|
|
129
|
-
{
|
|
130
|
-
|
|
86
|
+
{
|
|
87
|
+
label: '检测画面变化',
|
|
88
|
+
actions: [
|
|
89
|
+
{ type: 'visualChangeStart', label: '开始检测' },
|
|
90
|
+
{ type: 'visualChangeEnd', label: '结束检测' },
|
|
91
|
+
],
|
|
92
|
+
},
|
|
131
93
|
],
|
|
132
94
|
},
|
|
133
95
|
{
|
|
@@ -142,8 +104,14 @@ const actionGroups: Array<{ title: string; actions: Array<{ type: InsertAction;
|
|
|
142
104
|
actions: [
|
|
143
105
|
{ type: 'noop', label: '空节点' },
|
|
144
106
|
{ type: 'endFlow', label: '终止流程' },
|
|
145
|
-
{
|
|
146
|
-
|
|
107
|
+
{
|
|
108
|
+
label: '循环',
|
|
109
|
+
actions: [
|
|
110
|
+
{ type: 'loop', label: '有界循环' },
|
|
111
|
+
{ type: 'continueLoop', label: '继续下一次循环' },
|
|
112
|
+
{ type: 'breakLoop', label: '退出循环' },
|
|
113
|
+
],
|
|
114
|
+
},
|
|
147
115
|
{ type: 'log', label: '输出日志' },
|
|
148
116
|
{ type: 'runScript', label: '连接脚本' },
|
|
149
117
|
],
|
|
@@ -166,6 +134,7 @@ function typeLabel(step: AppiumRecordedStep) {
|
|
|
166
134
|
endFlow: '终止流程',
|
|
167
135
|
loop: '有界循环',
|
|
168
136
|
breakLoop: '退出循环',
|
|
137
|
+
continueLoop: '继续下一次循环',
|
|
169
138
|
log: '输出日志',
|
|
170
139
|
checkboxState: 'Checkbox 状态',
|
|
171
140
|
checkedState: '判断勾选',
|
|
@@ -96,8 +96,13 @@ const insertActionGroups: FlowActionGroup[] = [
|
|
|
96
96
|
{ type: 'waitFor', label: '等待出现' },
|
|
97
97
|
{ type: 'waitDisappear', label: '等待元素消失' },
|
|
98
98
|
{ type: 'waitActivity', label: '等待 Activity' },
|
|
99
|
-
{
|
|
100
|
-
|
|
99
|
+
{
|
|
100
|
+
label: '检测画面变化',
|
|
101
|
+
actions: [
|
|
102
|
+
{ type: 'visualChangeStart', label: '开始检测' },
|
|
103
|
+
{ type: 'visualChangeEnd', label: '结束检测' },
|
|
104
|
+
],
|
|
105
|
+
},
|
|
101
106
|
],
|
|
102
107
|
},
|
|
103
108
|
{
|
|
@@ -112,8 +117,14 @@ const insertActionGroups: FlowActionGroup[] = [
|
|
|
112
117
|
actions: [
|
|
113
118
|
{ type: 'noop', label: '空节点' },
|
|
114
119
|
{ type: 'endFlow', label: '终止流程' },
|
|
115
|
-
{
|
|
116
|
-
|
|
120
|
+
{
|
|
121
|
+
label: '循环',
|
|
122
|
+
actions: [
|
|
123
|
+
{ type: 'loop', label: '有界循环' },
|
|
124
|
+
{ type: 'continueLoop', label: '继续下一次循环' },
|
|
125
|
+
{ type: 'breakLoop', label: '退出循环' },
|
|
126
|
+
],
|
|
127
|
+
},
|
|
117
128
|
{ type: 'log', label: '输出日志' },
|
|
118
129
|
{ type: 'runScript', label: '连接脚本' },
|
|
119
130
|
],
|
|
@@ -122,7 +133,7 @@ const insertActionGroups: FlowActionGroup[] = [
|
|
|
122
133
|
|
|
123
134
|
const mainActionGroups = insertActionGroups.map((group) => ({
|
|
124
135
|
...group,
|
|
125
|
-
actions: group.actions.filter((action) => action.type !== 'clearAppData'),
|
|
136
|
+
actions: group.actions.filter((action) => !('type' in action) || action.type !== 'clearAppData'),
|
|
126
137
|
}));
|
|
127
138
|
const startActionGroups = insertActionGroups;
|
|
128
139
|
const hasClearAppDataStep = computed(() => props.steps.some((step) => step.type === 'clearAppData'));
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { computed, provide, shallowRef, watch, type InjectionKey, type Ref } from 'vue';
|
|
2
|
+
|
|
3
|
+
type Point = { x: number; y: number };
|
|
4
|
+
interface CoordinatePicker {
|
|
5
|
+
enabled: Readonly<Ref<boolean>>;
|
|
6
|
+
owner: Readonly<Ref<symbol | undefined>>;
|
|
7
|
+
start: (owner: symbol, select: (point: Point) => void) => void;
|
|
8
|
+
cancel: (owner?: symbol) => void;
|
|
9
|
+
}
|
|
10
|
+
export const coordinatePreviewKey: InjectionKey<CoordinatePicker> = Symbol('coordinate-preview');
|
|
11
|
+
|
|
12
|
+
export function useCoordinatePicker(enabled: Readonly<Ref<boolean>>, showPreview: () => void) {
|
|
13
|
+
const session = shallowRef<{ owner: symbol; select: (point: Point) => void }>();
|
|
14
|
+
function cancel(owner?: symbol) {
|
|
15
|
+
if (!owner || session.value?.owner === owner) session.value = undefined;
|
|
16
|
+
}
|
|
17
|
+
provide(coordinatePreviewKey, {
|
|
18
|
+
enabled,
|
|
19
|
+
owner: computed(() => session.value?.owner),
|
|
20
|
+
start(owner, select) {
|
|
21
|
+
if (!enabled.value) return;
|
|
22
|
+
session.value = { owner, select };
|
|
23
|
+
showPreview();
|
|
24
|
+
},
|
|
25
|
+
cancel,
|
|
26
|
+
});
|
|
27
|
+
watch(enabled, (value) => { if (!value) cancel(); });
|
|
28
|
+
return {
|
|
29
|
+
picking: computed(() => Boolean(session.value)),
|
|
30
|
+
cancel,
|
|
31
|
+
select(point: Point) {
|
|
32
|
+
const current = session.value;
|
|
33
|
+
cancel();
|
|
34
|
+
if (enabled.value) current?.select(point);
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
}
|
|
@@ -184,6 +184,7 @@ export function pasteFlowClipboard(
|
|
|
184
184
|
delete step.mergeUndo;
|
|
185
185
|
step.id = idMap.get(source.id) || createId();
|
|
186
186
|
if (step.breakLoopTargetId) step.breakLoopTargetId = idMap.get(step.breakLoopTargetId) || step.breakLoopTargetId;
|
|
187
|
+
if (step.continueLoopTargetId) step.continueLoopTargetId = idMap.get(step.continueLoopTargetId) || step.continueLoopTargetId;
|
|
187
188
|
const flow = { ...(step.flow || {}) };
|
|
188
189
|
for (const key of targetKeys) {
|
|
189
190
|
const mappedId = flow[key] ? idMap.get(flow[key] || '') : '';
|
|
@@ -29,6 +29,7 @@ export type InsertAction =
|
|
|
29
29
|
| 'endFlow'
|
|
30
30
|
| 'loop'
|
|
31
31
|
| 'breakLoop'
|
|
32
|
+
| 'continueLoop'
|
|
32
33
|
| 'clearAppData'
|
|
33
34
|
| 'popupCondition'
|
|
34
35
|
| 'checkboxState'
|
|
@@ -70,9 +71,11 @@ const FLOW_BRANCH_LABEL_GAP = 58;
|
|
|
70
71
|
const FLOW_BRANCH_MIN_SPREAD = 260;
|
|
71
72
|
const FLOW_BRANCH_SUBTREE_GAP = 80;
|
|
72
73
|
|
|
74
|
+
export type FlowAction = { type: InsertAction; label: string };
|
|
75
|
+
export type FlowActionSubgroup = { label: string; actions: FlowAction[] };
|
|
73
76
|
export type FlowActionGroup = {
|
|
74
77
|
title: string;
|
|
75
|
-
actions: Array<
|
|
78
|
+
actions: Array<FlowAction | FlowActionSubgroup>;
|
|
76
79
|
};
|
|
77
80
|
|
|
78
81
|
export type FlowGraphNodeData =
|
|
@@ -283,7 +286,7 @@ function estimateStepNodeHeight(label: { title: string; meta: string; note?: str
|
|
|
283
286
|
}
|
|
284
287
|
|
|
285
288
|
function withLaunchAppAction(groups: FlowActionGroup[]) {
|
|
286
|
-
if (groups.some((group) => group.actions.some((action) => action.type === 'launchApp'))) return groups;
|
|
289
|
+
if (groups.some((group) => group.actions.some((action) => 'type' in action && action.type === 'launchApp'))) return groups;
|
|
287
290
|
const launchAction = { type: 'launchApp' as const, label: '启动 App' };
|
|
288
291
|
let added = false;
|
|
289
292
|
const nextGroups = groups.map((group) => {
|
|
@@ -15,7 +15,7 @@ export function defaultFlowKind(step: AppiumRecordedStep): FlowKind {
|
|
|
15
15
|
if (step.type === 'aiRecognition') return isAiBranchEnabled(step) ? 'condition' : 'action';
|
|
16
16
|
if (step.type === 'extractVariable') return 'action';
|
|
17
17
|
if (step.type === 'loop') return 'condition';
|
|
18
|
-
if (step.type === 'breakLoop') return 'action';
|
|
18
|
+
if (step.type === 'breakLoop' || step.type === 'continueLoop') return 'action';
|
|
19
19
|
if (step.type === 'log' || step.type === 'openGallery' || step.type === 'endFlow') return 'action';
|
|
20
20
|
if (isBooleanCondition(step)) return 'condition';
|
|
21
21
|
if (step.flow?.nodeKind) return step.flow.nodeKind;
|
|
@@ -65,6 +65,7 @@ export function flowTypeLabel(step: AppiumRecordedStep) {
|
|
|
65
65
|
endFlow: '终止流程',
|
|
66
66
|
loop: '有界循环',
|
|
67
67
|
breakLoop: '退出循环',
|
|
68
|
+
continueLoop: '继续下一次循环',
|
|
68
69
|
clearAppData: '清理 APP 缓存',
|
|
69
70
|
longPress: '长按',
|
|
70
71
|
pinch: '双指缩放',
|
|
@@ -82,8 +83,9 @@ export function flowStepMeta(step: AppiumRecordedStep) {
|
|
|
82
83
|
if (step.type === 'extractVariable') return `${step.extractVariable?.attribute || 'text'} → ${step.extractVariable?.name || '未设置变量名'}`;
|
|
83
84
|
if (step.type === 'loop') return `最多 ${step.loop?.maxIterations ?? '?'} 次 · ${step.loop?.exitWhen === 'exists' ? '元素出现时退出' : step.loop?.exitWhen === 'notExists' ? '元素消失时退出' : '固定次数'}${step.loop?.exitWhen !== 'never' ? ` ${step.selector?.value || ''}` : ''}`;
|
|
84
85
|
if (step.type === 'breakLoop') return step.breakLoopTargetId ? '退出指定循环,继续循环结束后的流程' : '退出当前循环,继续循环结束后的流程';
|
|
86
|
+
if (step.type === 'continueLoop') return step.continueLoopTargetId ? '结束本轮,进入指定循环的下一轮' : '结束本轮,进入当前循环的下一轮';
|
|
85
87
|
if (step.type === 'log') return `${step.logPrefix ?? DEFAULT_LOG_PREFIX}:${step.value || ''}`;
|
|
86
|
-
if (step.type === 'aiRecognition') return `AI ${step.aiObservation?.mode === 'untilMatch' ? '命中即结束' : step.aiObservation ? '持续观察' : '识别'} · ${step.value || '未填写识别内容'}`;
|
|
88
|
+
if (step.type === 'aiRecognition') return `AI ${step.aiObservation?.mode === 'untilMatch' ? '命中即结束' : step.aiObservation ? '持续观察' : '识别'} · ${step.value || '未填写识别内容'}${step.aiInvalidResultBranch ? ` · 无有效结果→${step.aiInvalidResultBranch === 'yes' ? 'true' : 'false'}` : ''}`;
|
|
87
89
|
if (step.type === 'longPress') {
|
|
88
90
|
const target = longPressMode(step) === 'element'
|
|
89
91
|
? `元素 ${step.selector?.strategy || ''} ${step.selector?.value || ''}`
|
|
@@ -63,7 +63,7 @@ export function mergeBranches(steps: AppiumRecordedStep[], conditionId: string,
|
|
|
63
63
|
}
|
|
64
64
|
}
|
|
65
65
|
function connectEnd(step: typeof next[number]) {
|
|
66
|
-
if (step.type === 'endFlow' || step.type === 'breakLoop') return;
|
|
66
|
+
if (step.type === 'endFlow' || step.type === 'breakLoop' || step.type === 'continueLoop') return;
|
|
67
67
|
step.flow.successTargetId = commonId;
|
|
68
68
|
if (defaultFlowKind(step) === 'condition') {
|
|
69
69
|
for (const branch of (step.type === 'loop' ? ['no'] : ['yes', 'no']) as Branch[]) {
|
|
@@ -28,7 +28,7 @@ export function unmergeBranches(steps: AppiumRecordedStep[], conditionId: string
|
|
|
28
28
|
const shared = new Set([...common, ...added.map(step => step.id)]);
|
|
29
29
|
// 普通新增操作可分配到任一侧;控制节点和跨节点配对不做猜测性复制。
|
|
30
30
|
if (added.some(step => defaultFlowKind(step) === 'condition'
|
|
31
|
-
|| ['visualChange', 'breakLoop', 'endFlow'].includes(step.type) || step.mergeUndo)) {
|
|
31
|
+
|| ['visualChange', 'breakLoop', 'continueLoop', 'endFlow'].includes(step.type) || step.mergeUndo)) {
|
|
32
32
|
throw new Error('公共流程新增了判断、循环、终止或配对节点,请先移出这些节点再取消合并');
|
|
33
33
|
}
|
|
34
34
|
if (added.some(step => edges.some(key => step.flow?.[key]
|
|
@@ -98,7 +98,7 @@ export function unmergeBranches(steps: AppiumRecordedStep[], conditionId: string
|
|
|
98
98
|
const siblings = branchSteps(next, conditionId, other);
|
|
99
99
|
const appended = siblings.slice(-added.length);
|
|
100
100
|
const previous = siblings[siblings.length - appended.length - 1];
|
|
101
|
-
if (previous && (defaultFlowKind(previous) === 'condition' || ['endFlow', 'breakLoop'].includes(previous.type))) {
|
|
101
|
+
if (previous && (defaultFlowKind(previous) === 'condition' || ['endFlow', 'breakLoop', 'continueLoop'].includes(previous.type))) {
|
|
102
102
|
throw new Error('另一侧末尾是判断、循环或终止操作,新增节点不能安全追加,请仅保留在原来源分支');
|
|
103
103
|
}
|
|
104
104
|
if (previous?.flow?.successTargetId) {
|