android-midscene-automation 0.1.29 → 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.
Files changed (34) hide show
  1. package/CHANGELOG.md +10 -25
  2. package/README.md +10 -25
  3. package/index.html +1 -1
  4. package/package.json +7 -7
  5. package/server/appium-recorder/appium-runner.ts +49 -29
  6. package/server/appium-recorder/bounded-loop.ts +118 -0
  7. package/server/appium-recorder/repository.ts +9 -0
  8. package/src/appium-recorder/AppiumPage.vue +101 -45
  9. package/src/appium-recorder/bounded-loop.ts +66 -0
  10. package/src/appium-recorder/components/BreakLoopSettings.vue +29 -0
  11. package/src/appium-recorder/components/ComponentInspector.vue +26 -0
  12. package/src/appium-recorder/components/ComponentTree.vue +24 -4
  13. package/src/appium-recorder/components/FlowCanvas.vue +21 -1
  14. package/src/appium-recorder/components/FlowNodeCard.vue +12 -4
  15. package/src/appium-recorder/components/FlowNodeSearch.vue +62 -0
  16. package/src/appium-recorder/components/FlowStepEditor.vue +9 -4
  17. package/src/appium-recorder/components/LoopSettings.vue +37 -0
  18. package/src/appium-recorder/components/NestedConditionBranches.vue +8 -1
  19. package/src/appium-recorder/components/NodeDetail.vue +50 -8
  20. package/src/appium-recorder/components/RecordedSteps.vue +108 -40
  21. package/src/appium-recorder/components/RecorderWorkspace.vue +132 -0
  22. package/src/appium-recorder/flow-copy.ts +1 -0
  23. package/src/appium-recorder/flow-graph.ts +6 -1
  24. package/src/appium-recorder/flow-labels.ts +7 -0
  25. package/src/appium-recorder/flow-merge.ts +3 -2
  26. package/src/appium-recorder/flow-normalize.ts +1 -0
  27. package/src/appium-recorder/flow-remove.ts +6 -0
  28. package/src/appium-recorder/flow-search.ts +29 -0
  29. package/src/appium-recorder/node-locators.ts +25 -0
  30. package/src/appium-recorder/tree.ts +2 -5
  31. package/src/appium-recorder/types.ts +7 -0
  32. package/src/components/device/DevicePreviewPanel.vue +62 -10
  33. package/src/style.css +24 -7
  34. 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 ComponentTree from './components/ComponentTree.vue';
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
- <div class="appium-recorder-layout">
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
- <el-card shadow="never" class="appium-recorder-card">
2055
- <template #header>
2056
- <div class="panel-header">
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"
@@ -2095,14 +2133,11 @@ watch(
2095
2133
  />
2096
2134
  </el-select>
2097
2135
  </div>
2098
- <el-alert
2136
+ <details
2099
2137
  v-if="scriptActivityMismatch"
2100
- class="appium-activity-lock-alert"
2101
- title="当前脚本已锁定"
2102
- type="warning"
2103
- show-icon
2104
- :closable="false"
2138
+ class="appium-activity-summary"
2105
2139
  >
2140
+ <summary>当前页面与脚本不一致,部分录制操作不可用</summary>
2106
2141
  <div class="appium-activity-lock-alert__details">
2107
2142
  <div>
2108
2143
  <strong>脚本绑定</strong>
@@ -2114,16 +2149,10 @@ watch(
2114
2149
  </div>
2115
2150
  <p>请在录制流程中添加或执行“启动 APP”节点;Activity 匹配后会自动解除编辑锁定。</p>
2116
2151
  </div>
2117
- </el-alert>
2118
- <NodeDetail
2119
- :node="selectedNode"
2120
- :current-activity="currentActivity"
2121
- :loading="recordingTap"
2122
- />
2152
+ </details>
2123
2153
  </section>
2124
2154
 
2125
- <section class="appium-workbench__section">
2126
- <h3>录制步骤</h3>
2155
+ <section class="appium-workbench__section appium-workbench__flow">
2127
2156
  <RecordedSteps
2128
2157
  :key="newScriptRevision"
2129
2158
  :ai-recognition-model-configured="aiRecognitionModelConfigured"
@@ -2154,9 +2183,9 @@ watch(
2154
2183
  <el-tab-pane label="脚本列表" name="scripts">
2155
2184
  <div class="appium-script-list">
2156
2185
  <div class="appium-script-list__toolbar">
2157
- <el-button :icon="Upload" :loading="importingScript" @click="chooseScriptImportFile">
2158
- 导入脚本
2159
- </el-button>
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>
2160
2189
  <input
2161
2190
  ref="scriptImportInput"
2162
2191
  class="appium-script-list__file-input"
@@ -2179,37 +2208,47 @@ watch(
2179
2208
  <small>{{ script.steps.length }} 步 · {{ formatScriptTime(script.updatedAt) }}</small>
2180
2209
  </div>
2181
2210
  <div class="appium-script-list__actions">
2182
- <el-button size="small" @click.stop="loadScript(script)">加载</el-button>
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">
2183
2215
  <el-button
2184
2216
  size="small"
2185
2217
  :icon="Edit"
2186
2218
  :loading="renamingScriptId === script.id"
2187
- title="修改脚本名称"
2219
+ aria-label="修改脚本名称"
2188
2220
  @click.stop="renameScript(script)"
2189
2221
  />
2222
+ </span></el-tooltip>
2223
+ <el-tooltip content="复制脚本" placement="top" :show-after="200"><span class="appium-script-icon">
2190
2224
  <el-button
2191
2225
  size="small"
2192
2226
  :icon="CopyDocument"
2193
2227
  :loading="duplicatingScriptId === script.id"
2194
- title="复制脚本"
2228
+ aria-label="复制脚本"
2195
2229
  @click.stop="duplicateScript(script)"
2196
2230
  />
2231
+ </span></el-tooltip>
2232
+ <el-tooltip content="下载脚本" placement="top" :show-after="200"><span class="appium-script-icon">
2197
2233
  <el-button
2198
2234
  size="small"
2199
2235
  :icon="Download"
2200
2236
  :loading="downloadingScriptId === script.id"
2201
2237
  :disabled="Boolean(downloadingScriptId) && downloadingScriptId !== script.id"
2202
- title="下载脚本"
2238
+ aria-label="下载脚本"
2203
2239
  @click.stop="downloadScript(script)"
2204
2240
  />
2241
+ </span></el-tooltip>
2242
+ <el-tooltip content="删除脚本" placement="top" :show-after="200"><span class="appium-script-icon">
2205
2243
  <el-button
2206
2244
  size="small"
2207
2245
  type="danger"
2208
2246
  :icon="Delete"
2209
2247
  :loading="deletingScriptId === script.id"
2210
- title="删除脚本"
2248
+ aria-label="删除脚本"
2211
2249
  @click.stop="removeScript(script)"
2212
2250
  />
2251
+ </span></el-tooltip>
2213
2252
  </div>
2214
2253
  </article>
2215
2254
  </template>
@@ -2217,7 +2256,7 @@ watch(
2217
2256
  </el-tab-pane>
2218
2257
  </el-tabs>
2219
2258
  </el-card>
2220
- </div>
2259
+ </RecorderWorkspace>
2221
2260
 
2222
2261
  <NewScriptDialog
2223
2262
  v-if="newScriptDialogVisible"
@@ -2403,3 +2442,20 @@ watch(
2403
2442
  </el-tooltip>
2404
2443
  </section>
2405
2444
  </template>
2445
+
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; }
2449
+ .appium-activity-summary { margin: 8px 0; color: #946000; font-size: 12px; }
2450
+ .appium-activity-summary summary { cursor: pointer; line-height: 20px; }
2451
+ .appium-activity-summary[open] .appium-activity-lock-alert__details { max-height: 110px; overflow: auto; margin-top: 6px; }
2452
+ :deep(.appium-recorder-card--workbench > .el-card__body) { overflow: hidden; }
2453
+ :deep(.appium-workbench-tabs > .el-tabs__content) { overflow: hidden; }
2454
+ :deep(.appium-workbench-tabs .el-tab-pane) { height: 100%; overflow: auto; }
2455
+ .appium-workbench { height: 100%; box-sizing: border-box; grid-template-rows: auto minmax(0, 1fr); gap: 10px; overflow: hidden; padding: 12px; }
2456
+ .appium-workbench__flow { display: flex; flex-direction: column; min-height: 0; }
2457
+ .appium-workbench__flow h3 { margin-bottom: 6px; }
2458
+ .appium-workbench__flow :deep(.appium-recorded-steps-panel) { display: flex; flex-direction: column; flex: 1; min-height: 0; }
2459
+ .appium-workbench__flow :deep(.appium-flow-toolbar) { flex: none; margin-bottom: 8px; }
2460
+ .appium-workbench__flow :deep(.appium-flow-canvas--vue:not(.appium-flow-canvas--dialog)) { flex: 1; height: auto; min-height: 120px; }
2461
+ </style>
@@ -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 treeRef = ref<{ setCurrentKey: (key: string) => void } | null>(null);
16
+ const query = ref('');
17
+ // Keep the tree data identity stable while typing so filtering is not reset.
18
+ const treeData = computed(() => props.tree ? [props.tree] : []);
19
+ const treeRef = ref<{ setCurrentKey: (key: string) => void; filter: (value: string) => void } | null>(null);
20
+ const filterNode = (value: string, node: AppiumNode) => !value || [node.label, node.resourceId, node.text, node.contentDesc, node.className].some((text) => text?.toLowerCase().includes(value.toLowerCase()));
21
+ watch(query, (value) => treeRef.value?.filter(value));
22
+ watch(() => props.tree, async () => { await nextTick(); treeRef.value?.filter(query.value); });
16
23
 
17
24
  watch(
18
25
  () => props.selectedId,
19
26
  async () => {
27
+ query.value = '';
20
28
  treeRef.value?.setCurrentKey(props.selectedId);
21
29
  await nextTick();
22
30
  containerRef.value
@@ -27,22 +35,25 @@ watch(
27
35
  </script>
28
36
 
29
37
  <template>
38
+ <div class="appium-tree-container">
39
+ <el-input v-model="query" size="small" :prefix-icon="Search" clearable placeholder="搜索文字、ID、组件类型" aria-label="搜索组件" />
30
40
  <div ref="containerRef" class="appium-tree">
31
41
  <el-empty v-if="!tree" description="暂无组件树" />
32
42
  <el-tree
33
43
  v-else
34
44
  ref="treeRef"
35
- :data="[tree]"
45
+ :data="treeData"
36
46
  node-key="id"
37
47
  :props="{ label: 'label', children: 'children' }"
38
48
  :current-node-key="selectedId"
39
49
  highlight-current
40
50
  default-expand-all
51
+ :filter-node-method="filterNode"
41
52
  @node-click="$emit('select', $event)"
42
53
  >
43
54
  <template #default="{ data }">
44
55
  <span class="appium-tree-node">
45
- <span class="appium-tree-node__label">{{ data.label }}</span>
56
+ <span class="appium-tree-node__label" :title="`${data.label}\n${data.resourceId || data.className}`">{{ data.label }}</span>
46
57
  <span
47
58
  v-if="data.selector?.matchCount"
48
59
  class="appium-tree-node__badge"
@@ -54,4 +65,13 @@ watch(
54
65
  </template>
55
66
  </el-tree>
56
67
  </div>
68
+ </div>
57
69
  </template>
70
+
71
+ <style scoped>
72
+ .appium-tree-container { display: flex; flex-direction: column; gap: 8px; min-height: 0; }
73
+ .appium-tree { flex: 1; height: auto; overflow-x: hidden; }
74
+ .appium-tree :deep(.el-tree) { min-width: 0; }
75
+ .appium-tree-node { flex: 1; min-width: 0; overflow: hidden; }
76
+ .appium-tree-node__label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
77
+ </style>
@@ -21,6 +21,7 @@ const props = defineProps<{
21
21
  steps: AppiumRecordedStep[];
22
22
  expandedStepIndex: number | null;
23
23
  copyMode: boolean;
24
+ deleteMode?: boolean;
24
25
  selectedCopyIndexes: number[];
25
26
  disabled?: boolean;
26
27
  removeDisabled?: boolean;
@@ -29,6 +30,9 @@ const props = defineProps<{
29
30
  launchingStepId?: string;
30
31
  clipboardCount?: number;
31
32
  resetViewToken?: number;
33
+ searchTargetId?: string;
34
+ searchFocusToken?: number;
35
+ searchActive?: boolean;
32
36
  startActionGroups: FlowActionGroup[];
33
37
  mainActionGroups: FlowActionGroup[];
34
38
  canOpenInsertMenu: boolean;
@@ -54,13 +58,26 @@ const emit = defineEmits<{
54
58
  }>();
55
59
 
56
60
  const measuredNodeHeights = shallowRef<Record<string, number>>({});
57
- const { fitView, updateNodeInternals } = useVueFlow(props.id);
61
+ const { fitView, updateNodeInternals, findNode, setCenter, viewport } = useVueFlow(props.id);
58
62
  const paneReady = shallowRef(false);
59
63
  let internalsFrame = 0;
60
64
  let resetViewFrame = 0;
61
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
+ });
62
78
 
63
79
  const graph = computed(() => buildFlowGraph(props.steps, {
80
+ deleteMode: props.deleteMode,
64
81
  mergeDisabled: props.mergeDisabled,
65
82
  aiRecognitionModelConfigured: props.aiRecognitionModelConfigured,
66
83
  expandedStepIndex: props.expandedStepIndex,
@@ -152,6 +169,7 @@ watch(() => props.resetViewToken, () => {
152
169
  });
153
170
 
154
171
  onBeforeUnmount(() => {
172
+ if (searchFrame) cancelAnimationFrame(searchFrame);
155
173
  if (internalsFrame) window.cancelAnimationFrame(internalsFrame);
156
174
  if (resetViewFrame) window.cancelAnimationFrame(resetViewFrame);
157
175
  pendingResetView = false;
@@ -200,9 +218,11 @@ function handleInsert(payload: {
200
218
  >
201
219
  <template #node-flow-node="{ id: nodeId, data }">
202
220
  <FlowNodeCard
221
+ :steps="steps"
203
222
  :node-id="nodeId"
204
223
  :data="data"
205
224
  :readonly="props.readonly"
225
+ :search-highlighted="nodeId === `step:${searchTargetId}`"
206
226
  @node-click="emit('nodeClick', $event)"
207
227
  @copy="emit('copy', [$event])"
208
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
@@ -159,6 +161,7 @@ watch(() => props.data, () => {
159
161
  {
160
162
  'appium-flow-step-shell--expanded': data.expanded,
161
163
  'appium-flow-step-shell--selected': data.selected,
164
+ 'appium-flow-step-shell--delete-selected': data.selected && data.deleteMode,
162
165
  'appium-flow-step-shell--copying': data.copyMode,
163
166
  'appium-flow-step-shell--readonly': props.readonly,
164
167
  },
@@ -176,13 +179,13 @@ watch(() => props.data, () => {
176
179
  <WarningFilled />
177
180
  </el-icon>
178
181
  </el-tooltip>
179
- <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>
180
183
  <small :title="data.meta">{{ data.meta }}</small>
181
184
  <em v-if="data.note" :title="data.note">{{ data.note }}</em>
182
185
  </span>
183
186
  </button>
184
- <span v-if="!props.readonly" class="appium-flow-step-card__actions nodrag nopan" @click.stop>
185
- <el-tooltip v-if="data.flowKind === 'condition' && !data.copyMode" content="合并分支:将两侧后续节点汇入公共流程" placement="top" :show-after="200">
187
+ <span v-if="!props.readonly && !data.deleteMode" class="appium-flow-step-card__actions nodrag nopan" @click.stop>
188
+ <el-tooltip v-if="data.flowKind === 'condition' && data.step.type !== 'loop' && !data.copyMode" content="合并分支:将两侧后续节点汇入公共流程" placement="top" :show-after="200">
186
189
  <span class="appium-node-action-tooltip"><el-button text size="small" :icon="Connection"
187
190
  title="合并分支" aria-label="合并分支" :disabled="data.mergeDisabled || Boolean(data.step.flow?.successTargetId)"
188
191
  @click="emit('merge', data.index)" />
@@ -247,6 +250,7 @@ watch(() => props.data, () => {
247
250
  v-if="!props.readonly && data.expanded"
248
251
  class="appium-flow-step-editor nodrag nopan"
249
252
  :step="data.step"
253
+ :steps="steps"
250
254
  :index="data.index"
251
255
  :disabled="data.disabled"
252
256
  @update="updateStep"
@@ -263,6 +267,10 @@ watch(() => props.data, () => {
263
267
  </template>
264
268
 
265
269
  <style scoped>
270
+ .appium-flow-step-shell--delete-selected .appium-flow-step-card { background: #b83232; border-color: #a52a2a; }
271
+ .appium-flow-step-shell--delete-selected .appium-flow-step-card__content strong,
272
+ .appium-flow-step-shell--delete-selected .appium-flow-step-card__content em { color: #fff; }
273
+ .appium-flow-step-shell--delete-selected .appium-flow-step-card__content small { color: #fee2e2; }
266
274
  .appium-node-action-tooltip { display: inline-flex; }
267
275
  .appium-ai-model-warning { float: left; margin: 2px 6px 0 0; font-size: 16px; }
268
276
  </style>