android-midscene-automation 0.1.30 → 0.1.31
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 +7 -26
- package/README.md +7 -26
- package/index.html +1 -1
- package/package.json +7 -7
- package/server/appium-recorder/appium-runner.ts +49 -29
- package/server/appium-recorder/bounded-loop.ts +118 -0
- package/server/appium-recorder/repository.ts +9 -0
- package/src/appium-recorder/AppiumPage.vue +82 -38
- 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 +8 -1
- package/src/appium-recorder/components/NodeDetail.vue +30 -6
- package/src/appium-recorder/components/RecordedSteps.vue +22 -0
- package/src/appium-recorder/components/RecorderWorkspace.vue +132 -0
- package/src/appium-recorder/flow-copy.ts +1 -0
- package/src/appium-recorder/flow-graph.ts +3 -1
- 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/tree.ts +2 -5
- package/src/appium-recorder/types.ts +7 -0
- package/src/components/device/DevicePreviewPanel.vue +62 -10
- package/src/style.css +24 -7
- package/src/ui-refresh.css +40 -0
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
sqlString,
|
|
8
8
|
} from '../storage/sqlite';
|
|
9
9
|
import { normalizeLegacyNestedConditionBranches } from '../../src/appium-recorder/flow-normalize';
|
|
10
|
+
import { validateLoopSteps } from '../../src/appium-recorder/bounded-loop';
|
|
10
11
|
|
|
11
12
|
export type AppiumRecordedStepRecord = {
|
|
12
13
|
id: string;
|
|
@@ -34,6 +35,8 @@ export type AppiumRecordedStepRecord = {
|
|
|
34
35
|
| 'launchApp'
|
|
35
36
|
| 'openGallery'
|
|
36
37
|
| 'endFlow'
|
|
38
|
+
| 'loop'
|
|
39
|
+
| 'breakLoop'
|
|
37
40
|
| 'clearAppData'
|
|
38
41
|
| 'waitDisappear'
|
|
39
42
|
| 'assertText'
|
|
@@ -86,6 +89,11 @@ export type AppiumRecordedStepRecord = {
|
|
|
86
89
|
keyCode?: number;
|
|
87
90
|
timeoutMs?: number;
|
|
88
91
|
longPressMode?: 'element' | 'coordinates';
|
|
92
|
+
breakLoopTargetId?: string;
|
|
93
|
+
loop?: {
|
|
94
|
+
maxIterations: number;
|
|
95
|
+
exitWhen: 'never' | 'exists' | 'notExists';
|
|
96
|
+
};
|
|
89
97
|
flow?: {
|
|
90
98
|
nodeKind?: 'action' | 'condition' | 'assertion';
|
|
91
99
|
yesTargetId?: string;
|
|
@@ -338,6 +346,7 @@ export function saveAppiumRecordedScript(input: {
|
|
|
338
346
|
const name = input.name.trim();
|
|
339
347
|
const appPackage = input.appPackage.trim();
|
|
340
348
|
const steps = normalizeLegacyNestedConditionBranches(input.steps || []);
|
|
349
|
+
validateLoopSteps(steps);
|
|
341
350
|
const appActivity = normalizeScriptActivity(appPackage, input.appActivity, steps);
|
|
342
351
|
|
|
343
352
|
if (!name) throw new Error('脚本名称不能为空');
|
|
@@ -5,6 +5,9 @@ import { ElForm, ElFormItem, ElInputNumber, ElMessage, ElMessageBox, ElOption, E
|
|
|
5
5
|
import { Check, CircleClose, CopyDocument, Delete, Document, Download, Edit, Plus, Refresh, Upload, VideoPlay, View } from '@element-plus/icons-vue';
|
|
6
6
|
import type { AndroidDevice, AppPreset, DeviceAction } from '../types';
|
|
7
7
|
import DevicePreviewPanel from '../components/device/DevicePreviewPanel.vue';
|
|
8
|
+
import LoopSettings from './components/LoopSettings.vue';
|
|
9
|
+
import BreakLoopSettings from './components/BreakLoopSettings.vue';
|
|
10
|
+
import { defaultLoopConfig, enclosingLoops, breakLoopTarget, validateLoop, validateLoopSteps } from './bounded-loop';
|
|
8
11
|
import {
|
|
9
12
|
clearAppiumDeviceAppData,
|
|
10
13
|
deleteAppiumScript,
|
|
@@ -18,9 +21,9 @@ import {
|
|
|
18
21
|
stopAppiumReplay,
|
|
19
22
|
tapAppiumDevice,
|
|
20
23
|
} from './api';
|
|
21
|
-
import
|
|
24
|
+
import ComponentInspector from './components/ComponentInspector.vue';
|
|
25
|
+
import RecorderWorkspace from './components/RecorderWorkspace.vue';
|
|
22
26
|
import FlowCanvas from './components/FlowCanvas.vue';
|
|
23
|
-
import NodeDetail from './components/NodeDetail.vue';
|
|
24
27
|
import LongPressSettings from './components/LongPressSettings.vue';
|
|
25
28
|
import StageLogSettings from './components/StageLogSettings.vue';
|
|
26
29
|
import TextClickSettings from './components/TextClickSettings.vue';
|
|
@@ -76,6 +79,7 @@ type BranchTarget = {
|
|
|
76
79
|
type RecorderAction = InsertAction;
|
|
77
80
|
|
|
78
81
|
const WORKBENCH_TAB_STORAGE_KEY = 'android-midscene-automation:appium-workbench-tab';
|
|
82
|
+
const workspaceRef = shallowRef<InstanceType<typeof RecorderWorkspace>>();
|
|
79
83
|
const AUTO_TREE_REFRESH_INTERVAL_MS = 1800;
|
|
80
84
|
|
|
81
85
|
const insertableRecorderActions: RecorderAction[] = [
|
|
@@ -106,6 +110,8 @@ const insertableRecorderActions: RecorderAction[] = [
|
|
|
106
110
|
'launchApp',
|
|
107
111
|
'openGallery',
|
|
108
112
|
'endFlow',
|
|
113
|
+
'loop',
|
|
114
|
+
'breakLoop',
|
|
109
115
|
'clearAppData',
|
|
110
116
|
'waitDisappear',
|
|
111
117
|
'assertText',
|
|
@@ -357,6 +363,7 @@ function pasteFlowNodes(index: number, branch?: BranchName) {
|
|
|
357
363
|
afterIndex: index,
|
|
358
364
|
branch,
|
|
359
365
|
});
|
|
366
|
+
validateLoopSteps(nextSteps);
|
|
360
367
|
steps.value = nextSteps;
|
|
361
368
|
ElMessage.success(`已粘贴 ${flowClipboardCount.value} 个节点${branch ? `到${branch === 'yes' ? '是' : '否'}分支` : ''}`);
|
|
362
369
|
flowClipboard.value = null;
|
|
@@ -916,6 +923,7 @@ function selectNodeFromPoint(point: { x: number; y: number }) {
|
|
|
916
923
|
const node = findSmallestNodeAtPoint(tree.value, point.x, point.y);
|
|
917
924
|
if (node) {
|
|
918
925
|
selectedNode.value = node;
|
|
926
|
+
workspaceRef.value?.showTree();
|
|
919
927
|
return;
|
|
920
928
|
}
|
|
921
929
|
ElMessage.warning('未命中组件树节点');
|
|
@@ -928,6 +936,7 @@ async function swipePreview(gesture: {
|
|
|
928
936
|
endY: number;
|
|
929
937
|
duration: number;
|
|
930
938
|
}) {
|
|
939
|
+
if (recordingBusy.value || replaying.value) return;
|
|
931
940
|
if (!selectedDeviceId.value) return;
|
|
932
941
|
try {
|
|
933
942
|
await props.swipeDevice(
|
|
@@ -1208,6 +1217,7 @@ function pickVisualChangeRegion() {
|
|
|
1208
1217
|
mode: 'region',
|
|
1209
1218
|
});
|
|
1210
1219
|
visualChangePicking.value = true;
|
|
1220
|
+
workspaceRef.value?.showPreview();
|
|
1211
1221
|
ElMessage.info('请在设备预览中拖拽框选检测区域');
|
|
1212
1222
|
}
|
|
1213
1223
|
|
|
@@ -1286,6 +1296,46 @@ async function addAction(
|
|
|
1286
1296
|
if (action === 'endFlow') {
|
|
1287
1297
|
return insertStep({ id: createStepId(), type: 'endFlow', label: '终止流程', flow: { nodeKind: 'action' } }, index, branchTarget);
|
|
1288
1298
|
}
|
|
1299
|
+
if (action === 'loop') {
|
|
1300
|
+
const step = reactive<AppiumRecordedStep>({
|
|
1301
|
+
id: createStepId(), type: 'loop', label: '有界循环', loop: defaultLoopConfig(),
|
|
1302
|
+
selector: selectedNode.value?.selector ? { ...selectedNode.value.selector } : { strategy: 'id', value: '' },
|
|
1303
|
+
flow: { nodeKind: 'condition' },
|
|
1304
|
+
});
|
|
1305
|
+
const result = await ElMessageBox({
|
|
1306
|
+
title: '添加有界循环',
|
|
1307
|
+
message: h(ElForm, { labelPosition: 'top' }, () => h(LoopSettings, { step, onUpdate: (patch) => Object.assign(step, patch) })),
|
|
1308
|
+
showCancelButton: true, confirmButtonText: '添加', cancelButtonText: '取消',
|
|
1309
|
+
beforeClose: (action, _instance, done) => {
|
|
1310
|
+
if (action === 'confirm') {
|
|
1311
|
+
try { validateLoop(step); } catch (error) { ElMessage.warning((error as Error).message); return; }
|
|
1312
|
+
}
|
|
1313
|
+
done();
|
|
1314
|
+
},
|
|
1315
|
+
}).catch(() => null);
|
|
1316
|
+
if (result) return insertStep({ ...step, loop: { ...step.loop! }, selector: { ...step.selector! } }, index, branchTarget);
|
|
1317
|
+
return;
|
|
1318
|
+
}
|
|
1319
|
+
if (action === 'breakLoop') {
|
|
1320
|
+
const step: AppiumRecordedStep = { id: createStepId(), type: 'breakLoop', label: '退出循环', flow: { nodeKind: 'action' } };
|
|
1321
|
+
const scoped = reactive(branchTarget ? stepWithBranchTarget(step, branchTarget) : step);
|
|
1322
|
+
const loops = enclosingLoops(steps.value, scoped);
|
|
1323
|
+
if (!loops.length) { ElMessage.warning('退出循环只能添加在循环体内'); return; }
|
|
1324
|
+
scoped.breakLoopTargetId = loops[0]!.id;
|
|
1325
|
+
const result = await ElMessageBox({
|
|
1326
|
+
title: '添加退出循环',
|
|
1327
|
+
message: h(ElForm, { labelPosition: 'top' }, () => h(BreakLoopSettings, { step: scoped, steps: steps.value, onUpdate: (patch) => Object.assign(scoped, patch) })),
|
|
1328
|
+
showCancelButton: true, confirmButtonText: '添加', cancelButtonText: '取消',
|
|
1329
|
+
beforeClose: (action, _instance, done) => {
|
|
1330
|
+
if (action === 'confirm') {
|
|
1331
|
+
try { breakLoopTarget(steps.value, scoped); } catch (error) { ElMessage.warning((error as Error).message); return; }
|
|
1332
|
+
}
|
|
1333
|
+
done();
|
|
1334
|
+
},
|
|
1335
|
+
}).catch(() => null);
|
|
1336
|
+
if (result) return insertStep({ ...step, breakLoopTargetId: scoped.breakLoopTargetId }, index, branchTarget);
|
|
1337
|
+
return;
|
|
1338
|
+
}
|
|
1289
1339
|
if (action === 'aiRecognition') {
|
|
1290
1340
|
const input = await ElMessageBox.prompt('识别内容', '添加 AI 识别', {
|
|
1291
1341
|
inputType: 'textarea',
|
|
@@ -2027,8 +2077,11 @@ watch(
|
|
|
2027
2077
|
</el-button>
|
|
2028
2078
|
</div>
|
|
2029
2079
|
|
|
2030
|
-
<
|
|
2080
|
+
<RecorderWorkspace ref="workspaceRef">
|
|
2081
|
+
<template #preview>
|
|
2031
2082
|
<DevicePreviewPanel
|
|
2083
|
+
compact
|
|
2084
|
+
:interaction-disabled="recordingBusy || replaying"
|
|
2032
2085
|
:available="playgroundAvailable"
|
|
2033
2086
|
:devices="androidDevices"
|
|
2034
2087
|
:selected-device-id="selectedDeviceId"
|
|
@@ -2050,27 +2103,12 @@ watch(
|
|
|
2050
2103
|
@region-select="applyVisualChangeRegion"
|
|
2051
2104
|
@swipe="swipePreview"
|
|
2052
2105
|
/>
|
|
2053
|
-
|
|
2054
|
-
<
|
|
2055
|
-
<
|
|
2056
|
-
|
|
2057
|
-
<span>App 组件树</span>
|
|
2058
|
-
<el-button :icon="Refresh" :loading="loadingTree" :disabled="replaying" @click="refreshTree">
|
|
2059
|
-
刷新组件树
|
|
2060
|
-
</el-button>
|
|
2061
|
-
</div>
|
|
2062
|
-
</template>
|
|
2063
|
-
<div class="appium-tree-panel">
|
|
2064
|
-
<ComponentTree :tree="tree" :selected-id="selectedNode?.id || ''" @select="selectedNode = $event" />
|
|
2065
|
-
<div class="appium-current-activity">
|
|
2066
|
-
<span>当前 Activity</span>
|
|
2067
|
-
<code>{{ currentActivity || '-' }}</code>
|
|
2068
|
-
</div>
|
|
2069
|
-
</div>
|
|
2070
|
-
</el-card>
|
|
2106
|
+
</template>
|
|
2107
|
+
<template #tree>
|
|
2108
|
+
<ComponentInspector :tree="tree" :node="selectedNode" :activity="currentActivity" :loading="loadingTree" :disabled="replaying" :recording="recordingTap" @refresh="refreshTree" @select="selectedNode = $event" />
|
|
2109
|
+
</template>
|
|
2071
2110
|
|
|
2072
2111
|
<el-card shadow="never" class="appium-recorder-card appium-recorder-card--workbench">
|
|
2073
|
-
<template #header>录制与脚本</template>
|
|
2074
2112
|
<el-tabs
|
|
2075
2113
|
:model-value="activeWorkbenchTab"
|
|
2076
2114
|
class="appium-workbench-tabs"
|
|
@@ -2112,15 +2150,9 @@ watch(
|
|
|
2112
2150
|
<p>请在录制流程中添加或执行“启动 APP”节点;Activity 匹配后会自动解除编辑锁定。</p>
|
|
2113
2151
|
</div>
|
|
2114
2152
|
</details>
|
|
2115
|
-
<NodeDetail
|
|
2116
|
-
:node="selectedNode"
|
|
2117
|
-
:current-activity="currentActivity"
|
|
2118
|
-
:loading="recordingTap"
|
|
2119
|
-
/>
|
|
2120
2153
|
</section>
|
|
2121
2154
|
|
|
2122
2155
|
<section class="appium-workbench__section appium-workbench__flow">
|
|
2123
|
-
<h3>录制步骤</h3>
|
|
2124
2156
|
<RecordedSteps
|
|
2125
2157
|
:key="newScriptRevision"
|
|
2126
2158
|
:ai-recognition-model-configured="aiRecognitionModelConfigured"
|
|
@@ -2151,9 +2183,9 @@ watch(
|
|
|
2151
2183
|
<el-tab-pane label="脚本列表" name="scripts">
|
|
2152
2184
|
<div class="appium-script-list">
|
|
2153
2185
|
<div class="appium-script-list__toolbar">
|
|
2154
|
-
<el-
|
|
2155
|
-
导入脚本
|
|
2156
|
-
</el-
|
|
2186
|
+
<el-tooltip content="导入脚本" placement="top" :show-after="200"><span class="appium-script-icon">
|
|
2187
|
+
<el-button size="small" :icon="Upload" aria-label="导入脚本" :loading="importingScript" @click="chooseScriptImportFile" />
|
|
2188
|
+
</span></el-tooltip>
|
|
2157
2189
|
<input
|
|
2158
2190
|
ref="scriptImportInput"
|
|
2159
2191
|
class="appium-script-list__file-input"
|
|
@@ -2176,37 +2208,47 @@ watch(
|
|
|
2176
2208
|
<small>{{ script.steps.length }} 步 · {{ formatScriptTime(script.updatedAt) }}</small>
|
|
2177
2209
|
</div>
|
|
2178
2210
|
<div class="appium-script-list__actions">
|
|
2179
|
-
<el-
|
|
2211
|
+
<el-tooltip content="加载" placement="top" :show-after="200"><span class="appium-script-icon">
|
|
2212
|
+
<el-button size="small" :icon="Document" aria-label="加载" @click.stop="loadScript(script)" />
|
|
2213
|
+
</span></el-tooltip>
|
|
2214
|
+
<el-tooltip content="修改脚本名称" placement="top" :show-after="200"><span class="appium-script-icon">
|
|
2180
2215
|
<el-button
|
|
2181
2216
|
size="small"
|
|
2182
2217
|
:icon="Edit"
|
|
2183
2218
|
:loading="renamingScriptId === script.id"
|
|
2184
|
-
|
|
2219
|
+
aria-label="修改脚本名称"
|
|
2185
2220
|
@click.stop="renameScript(script)"
|
|
2186
2221
|
/>
|
|
2222
|
+
</span></el-tooltip>
|
|
2223
|
+
<el-tooltip content="复制脚本" placement="top" :show-after="200"><span class="appium-script-icon">
|
|
2187
2224
|
<el-button
|
|
2188
2225
|
size="small"
|
|
2189
2226
|
:icon="CopyDocument"
|
|
2190
2227
|
:loading="duplicatingScriptId === script.id"
|
|
2191
|
-
|
|
2228
|
+
aria-label="复制脚本"
|
|
2192
2229
|
@click.stop="duplicateScript(script)"
|
|
2193
2230
|
/>
|
|
2231
|
+
</span></el-tooltip>
|
|
2232
|
+
<el-tooltip content="下载脚本" placement="top" :show-after="200"><span class="appium-script-icon">
|
|
2194
2233
|
<el-button
|
|
2195
2234
|
size="small"
|
|
2196
2235
|
:icon="Download"
|
|
2197
2236
|
:loading="downloadingScriptId === script.id"
|
|
2198
2237
|
:disabled="Boolean(downloadingScriptId) && downloadingScriptId !== script.id"
|
|
2199
|
-
|
|
2238
|
+
aria-label="下载脚本"
|
|
2200
2239
|
@click.stop="downloadScript(script)"
|
|
2201
2240
|
/>
|
|
2241
|
+
</span></el-tooltip>
|
|
2242
|
+
<el-tooltip content="删除脚本" placement="top" :show-after="200"><span class="appium-script-icon">
|
|
2202
2243
|
<el-button
|
|
2203
2244
|
size="small"
|
|
2204
2245
|
type="danger"
|
|
2205
2246
|
:icon="Delete"
|
|
2206
2247
|
:loading="deletingScriptId === script.id"
|
|
2207
|
-
|
|
2248
|
+
aria-label="删除脚本"
|
|
2208
2249
|
@click.stop="removeScript(script)"
|
|
2209
2250
|
/>
|
|
2251
|
+
</span></el-tooltip>
|
|
2210
2252
|
</div>
|
|
2211
2253
|
</article>
|
|
2212
2254
|
</template>
|
|
@@ -2214,7 +2256,7 @@ watch(
|
|
|
2214
2256
|
</el-tab-pane>
|
|
2215
2257
|
</el-tabs>
|
|
2216
2258
|
</el-card>
|
|
2217
|
-
</
|
|
2259
|
+
</RecorderWorkspace>
|
|
2218
2260
|
|
|
2219
2261
|
<NewScriptDialog
|
|
2220
2262
|
v-if="newScriptDialogVisible"
|
|
@@ -2402,13 +2444,15 @@ watch(
|
|
|
2402
2444
|
</template>
|
|
2403
2445
|
|
|
2404
2446
|
<style scoped>
|
|
2447
|
+
.appium-script-icon { display: inline-flex; }
|
|
2448
|
+
.appium-script-icon .el-button { width: 30px; height: 30px; min-height: 30px; padding: 0; margin: 0; }
|
|
2405
2449
|
.appium-activity-summary { margin: 8px 0; color: #946000; font-size: 12px; }
|
|
2406
2450
|
.appium-activity-summary summary { cursor: pointer; line-height: 20px; }
|
|
2407
2451
|
.appium-activity-summary[open] .appium-activity-lock-alert__details { max-height: 110px; overflow: auto; margin-top: 6px; }
|
|
2408
2452
|
:deep(.appium-recorder-card--workbench > .el-card__body) { overflow: hidden; }
|
|
2409
2453
|
:deep(.appium-workbench-tabs > .el-tabs__content) { overflow: hidden; }
|
|
2410
2454
|
:deep(.appium-workbench-tabs .el-tab-pane) { height: 100%; overflow: auto; }
|
|
2411
|
-
.appium-workbench { height: 100%; box-sizing: border-box; grid-template-rows: auto minmax(
|
|
2455
|
+
.appium-workbench { height: 100%; box-sizing: border-box; grid-template-rows: auto minmax(0, 1fr); gap: 10px; overflow: hidden; padding: 12px; }
|
|
2412
2456
|
.appium-workbench__flow { display: flex; flex-direction: column; min-height: 0; }
|
|
2413
2457
|
.appium-workbench__flow h3 { margin-bottom: 6px; }
|
|
2414
2458
|
.appium-workbench__flow :deep(.appium-recorded-steps-panel) { display: flex; flex-direction: column; flex: 1; min-height: 0; }
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import type { AppiumRecordedStep } from './types';
|
|
2
|
+
|
|
3
|
+
export const MAX_LOOP_ITERATIONS = 10000;
|
|
4
|
+
export const defaultLoopConfig = () => ({ maxIterations: 3, exitWhen: 'never' as const });
|
|
5
|
+
|
|
6
|
+
export function validateLoop(step: AppiumRecordedStep) {
|
|
7
|
+
const config = step.loop;
|
|
8
|
+
if (!config || !Number.isInteger(config.maxIterations) || config.maxIterations < 1 || config.maxIterations > MAX_LOOP_ITERATIONS) {
|
|
9
|
+
throw new Error(`循环最大次数必须是 1-${MAX_LOOP_ITERATIONS} 的整数`);
|
|
10
|
+
}
|
|
11
|
+
if (!['never', 'exists', 'notExists'].includes(config.exitWhen)) throw new Error('循环退出条件无效');
|
|
12
|
+
if (config.exitWhen !== 'never' && (!step.selector?.value?.trim() || !['id', 'accessibilityId', 'xpath', 'androidUiAutomator'].includes(step.selector.strategy))) {
|
|
13
|
+
throw new Error('请设置循环退出条件的元素定位内容');
|
|
14
|
+
}
|
|
15
|
+
return config;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Branch ownership, not array adjacency, determines the innermost active loop.
|
|
19
|
+
export function enclosingLoop(steps: readonly AppiumRecordedStep[], step: AppiumRecordedStep): AppiumRecordedStep | undefined {
|
|
20
|
+
return enclosingLoops(steps, step)[0];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function enclosingLoops(steps: readonly AppiumRecordedStep[], step: AppiumRecordedStep): AppiumRecordedStep[] {
|
|
24
|
+
const loops: AppiumRecordedStep[] = [];
|
|
25
|
+
const byId = new Map(steps.map((item) => [item.id, item]));
|
|
26
|
+
const seen = new Set<string>();
|
|
27
|
+
let current = step;
|
|
28
|
+
while (current.flow?.parentConditionId) {
|
|
29
|
+
const parent = byId.get(current.flow.parentConditionId);
|
|
30
|
+
if (!parent || seen.has(parent.id)) break;
|
|
31
|
+
seen.add(parent.id);
|
|
32
|
+
if (parent.type === 'loop' && current.flow.parentBranch === 'yes') loops.push(parent);
|
|
33
|
+
current = parent;
|
|
34
|
+
}
|
|
35
|
+
return loops;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function breakLoopTarget(steps: readonly AppiumRecordedStep[], step: AppiumRecordedStep) {
|
|
39
|
+
const loops = enclosingLoops(steps, step);
|
|
40
|
+
const target = step.breakLoopTargetId ? loops.find((loop) => loop.id === step.breakLoopTargetId) : loops[0];
|
|
41
|
+
if (!target) throw new Error('退出循环目标必须是当前节点所属的循环体,目标可能已删除或不在当前分支');
|
|
42
|
+
return target;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function validateLoopSteps(steps: readonly AppiumRecordedStep[]) {
|
|
46
|
+
if (!steps.some((step) => step.type === 'loop' || step.type === 'breakLoop')) return;
|
|
47
|
+
const byId = new Map(steps.map((step) => [step.id, step]));
|
|
48
|
+
if (byId.size !== steps.length) throw new Error('流程节点 ID 重复');
|
|
49
|
+
for (const step of steps) {
|
|
50
|
+
let current: AppiumRecordedStep | undefined = step;
|
|
51
|
+
const seen = new Set<string>();
|
|
52
|
+
while (current) {
|
|
53
|
+
if (seen.has(current.id)) throw new Error('流程分支归属存在循环');
|
|
54
|
+
seen.add(current.id);
|
|
55
|
+
const id: string | undefined = current.flow?.parentConditionId;
|
|
56
|
+
if (id && !byId.has(id)) throw new Error('流程分支所属节点不存在');
|
|
57
|
+
current = id ? byId.get(id) : undefined;
|
|
58
|
+
}
|
|
59
|
+
if (step.type === 'loop') validateLoop(step);
|
|
60
|
+
if (step.type === 'breakLoop') breakLoopTarget(steps, step);
|
|
61
|
+
if (step.type === 'loop' && step.flow?.yesTargetId) {
|
|
62
|
+
const target = byId.get(step.flow.yesTargetId);
|
|
63
|
+
if (!target || target.flow?.parentConditionId !== step.id || target.flow.parentBranch !== 'yes') throw new Error('循环入口必须指向所属循环体');
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed } from 'vue';
|
|
3
|
+
import type { AppiumRecordedStep } from '../types';
|
|
4
|
+
import { enclosingLoops } from '../bounded-loop';
|
|
5
|
+
|
|
6
|
+
const props = defineProps<{ step: AppiumRecordedStep; steps: AppiumRecordedStep[]; disabled?: boolean }>();
|
|
7
|
+
const emit = defineEmits<{ update: [patch: Partial<AppiumRecordedStep>] }>();
|
|
8
|
+
const loops = computed(() => enclosingLoops(props.steps, props.step));
|
|
9
|
+
</script>
|
|
10
|
+
|
|
11
|
+
<template>
|
|
12
|
+
<el-form-item label="退出目标循环">
|
|
13
|
+
<el-select class="break-loop-target" popper-class="break-loop-target-popper" :model-value="step.breakLoopTargetId || loops[0]?.id" :disabled="disabled" aria-label="退出目标循环" @update:model-value="emit('update', { breakLoopTargetId: $event })">
|
|
14
|
+
<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
|
+
</el-select>
|
|
16
|
+
</el-form-item>
|
|
17
|
+
</template>
|
|
18
|
+
|
|
19
|
+
<style scoped>
|
|
20
|
+
.break-loop-target { width: 100%; min-width: 0; }
|
|
21
|
+
.break-loop-target :deep(.el-select__wrapper) { height: auto; min-height: 36px; }
|
|
22
|
+
.break-loop-target :deep(.el-select__placeholder) {
|
|
23
|
+
position: relative; top: auto; transform: none; z-index: 0; pointer-events: none;
|
|
24
|
+
white-space: normal; overflow-wrap: anywhere; line-height: 22px;
|
|
25
|
+
}
|
|
26
|
+
.break-loop-target :deep(.el-select__input-wrapper) { position: absolute; width: 100%; }
|
|
27
|
+
.break-loop-target-option { height: auto; min-height: 34px; padding-top: 6px; padding-bottom: 6px; white-space: normal; overflow-wrap: anywhere; line-height: 22px; }
|
|
28
|
+
:global(.break-loop-target-popper) { max-width: calc(100vw - 32px); }
|
|
29
|
+
</style>
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { Refresh } from '@element-plus/icons-vue';
|
|
3
|
+
import type { AppiumNode } from '../types';
|
|
4
|
+
import ComponentTree from './ComponentTree.vue';
|
|
5
|
+
import NodeDetail from './NodeDetail.vue';
|
|
6
|
+
defineProps<{ tree: AppiumNode | null; node: AppiumNode | null; activity: string; loading: boolean; disabled: boolean; recording: boolean }>();
|
|
7
|
+
defineEmits<{ refresh: []; select: [node: AppiumNode] }>();
|
|
8
|
+
</script>
|
|
9
|
+
|
|
10
|
+
<template>
|
|
11
|
+
<el-card shadow="never" class="appium-recorder-card component-inspector">
|
|
12
|
+
<template #header><div class="panel-header"><span>App 组件树</span><el-tooltip content="刷新组件树" placement="top" :show-after="200"><el-button class="recorder-panel-tool" :icon="Refresh" size="small" aria-label="刷新组件树" :loading="loading" :disabled="disabled" @click="$emit('refresh')" /></el-tooltip></div></template>
|
|
13
|
+
<ComponentTree :tree="tree" :selected-id="node?.id || ''" @select="$emit('select', $event)" />
|
|
14
|
+
<NodeDetail :node="node" :current-activity="activity" :loading="recording" />
|
|
15
|
+
<div class="component-inspector__activity"><span>Activity</span><code :title="activity">{{ activity || '-' }}</code></div>
|
|
16
|
+
</el-card>
|
|
17
|
+
</template>
|
|
18
|
+
|
|
19
|
+
<style scoped>
|
|
20
|
+
.component-inspector :deep(> .el-card__body) { display: flex; flex-direction: column; overflow: hidden; gap: 8px; }
|
|
21
|
+
.component-inspector :deep(.appium-tree-container) { flex: 1; min-height: 0; }
|
|
22
|
+
.component-inspector :deep(.appium-node-summary) { flex: none; }
|
|
23
|
+
.panel-header { font-size: 14px; gap: 4px; }
|
|
24
|
+
.component-inspector__activity { flex: none; display: flex; min-width: 0; gap: 6px; padding-top: 8px; border-top: 1px solid #e4e7ed; font-size: 12px; color: #606266; }
|
|
25
|
+
.component-inspector__activity code { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
26
|
+
</style>
|
|
@@ -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"
|