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.
Files changed (33) hide show
  1. package/CHANGELOG.md +7 -26
  2. package/README.md +7 -26
  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 +82 -38
  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 +19 -1
  14. package/src/appium-recorder/components/FlowNodeCard.vue +6 -3
  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 +30 -6
  20. package/src/appium-recorder/components/RecordedSteps.vue +22 -0
  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 +3 -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-search.ts +29 -0
  28. package/src/appium-recorder/node-locators.ts +25 -0
  29. package/src/appium-recorder/tree.ts +2 -5
  30. package/src/appium-recorder/types.ts +7 -0
  31. package/src/components/device/DevicePreviewPanel.vue +62 -10
  32. package/src/style.css +24 -7
  33. 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"
@@ -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-button :icon="Upload" :loading="importingScript" @click="chooseScriptImportFile">
2155
- 导入脚本
2156
- </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>
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-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">
2180
2215
  <el-button
2181
2216
  size="small"
2182
2217
  :icon="Edit"
2183
2218
  :loading="renamingScriptId === script.id"
2184
- title="修改脚本名称"
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
- title="复制脚本"
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
- title="下载脚本"
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
- title="删除脚本"
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
- </div>
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(180px, 1fr); gap: 10px; overflow: auto; }
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 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>
@@ -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"