android-midscene-automation 0.1.26 → 0.1.28

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 (56) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/FAQ.md +14 -0
  3. package/README.md +39 -3
  4. package/bin/android-midscene-automation.js +71 -1
  5. package/package.json +7 -2
  6. package/remote-agent/index.ts +2 -11
  7. package/server/appium-recorder/ai-recognition.ts +67 -0
  8. package/server/appium-recorder/appium-runner.ts +335 -52
  9. package/server/appium-recorder/report.ts +297 -16
  10. package/server/appium-recorder/repository.ts +31 -4
  11. package/server/appium-recorder/routes.ts +31 -32
  12. package/server/appium-recorder/screenshot.ts +21 -0
  13. package/server/appium-recorder/script-export.ts +67 -0
  14. package/server/appium-recorder/tree-dump.ts +22 -0
  15. package/server/appium-recorder/visual-change.ts +184 -0
  16. package/server/config.ts +10 -0
  17. package/server/http-api.ts +4 -5
  18. package/src/App.vue +14 -7
  19. package/src/api.ts +1 -1
  20. package/src/appium-recorder/AppiumPage.vue +658 -162
  21. package/src/appium-recorder/ai-recognition.ts +16 -0
  22. package/src/appium-recorder/api.ts +20 -3
  23. package/src/appium-recorder/components/AiRecognitionTestDialog.vue +71 -0
  24. package/src/appium-recorder/components/FlowActionMenu.vue +206 -0
  25. package/src/appium-recorder/components/FlowCanvas.vue +16 -1
  26. package/src/appium-recorder/components/FlowNodeCard.vue +23 -37
  27. package/src/appium-recorder/components/FlowStepEditor.vue +152 -25
  28. package/src/appium-recorder/components/LongPressSettings.vue +58 -0
  29. package/src/appium-recorder/components/NestedConditionBranches.vue +55 -35
  30. package/src/appium-recorder/components/NewScriptDialog.vue +27 -0
  31. package/src/appium-recorder/components/NodeDetail.vue +2 -0
  32. package/src/appium-recorder/components/RecordedSteps.vue +24 -4
  33. package/src/appium-recorder/components/StageLogSettings.vue +17 -0
  34. package/src/appium-recorder/components/TextClickSettings.vue +19 -0
  35. package/src/appium-recorder/components/VisualChangeDialog.vue +151 -0
  36. package/src/appium-recorder/flow-copy.ts +47 -1
  37. package/src/appium-recorder/flow-graph.ts +147 -64
  38. package/src/appium-recorder/flow-labels.ts +35 -6
  39. package/src/appium-recorder/flow-normalize.ts +76 -0
  40. package/src/appium-recorder/flow-remove.ts +69 -0
  41. package/src/appium-recorder/long-press.ts +27 -0
  42. package/src/appium-recorder/native-control-state.ts +26 -0
  43. package/src/appium-recorder/stage-log.ts +21 -0
  44. package/src/appium-recorder/text-click.ts +9 -0
  45. package/src/appium-recorder/tree.ts +3 -0
  46. package/src/appium-recorder/types.ts +34 -2
  47. package/src/appium-recorder/visual-change-pairs.ts +147 -0
  48. package/src/appium-recorder/visual-change.ts +75 -0
  49. package/src/components/config/AppiumConfigPanel.vue +48 -0
  50. package/src/components/config/BaseConfigPanel.vue +90 -0
  51. package/src/components/config/MidsceneConfigPanel.vue +204 -0
  52. package/src/components/device/DevicePreviewPanel.vue +148 -4
  53. package/src/pages/ConfigPage.vue +107 -265
  54. package/src/style.css +43 -4
  55. package/src/types.ts +2 -0
  56. package/src/ui-refresh.css +5 -0
@@ -0,0 +1,17 @@
1
+ <script setup lang="ts">
2
+ import { DEFAULT_LOG_PREFIX } from '../stage-log';
3
+ import type { AppiumRecordedStep } from '../types';
4
+ defineProps<{ step: AppiumRecordedStep; disabled?: boolean }>();
5
+ defineEmits<{ update: [patch: Partial<AppiumRecordedStep>] }>();
6
+ </script>
7
+
8
+ <template>
9
+ <el-form-item label="日志关键字">
10
+ <el-input :model-value="step.logPrefix ?? DEFAULT_LOG_PREFIX" :disabled="disabled" maxlength="100"
11
+ @update:model-value="$emit('update', { logPrefix: String($event) })" />
12
+ </el-form-item>
13
+ <el-form-item label="日志内容">
14
+ <el-input :model-value="step.value || ''" :disabled="disabled" type="textarea" :rows="3" maxlength="4000"
15
+ @update:model-value="$emit('update', { value: String($event) })" />
16
+ </el-form-item>
17
+ </template>
@@ -0,0 +1,19 @@
1
+ <script setup lang="ts">
2
+ import type { AppiumRecordedStep } from '../types';
3
+ const props = defineProps<{ step: AppiumRecordedStep; disabled?: boolean }>();
4
+ const emit = defineEmits<{ update: [patch: Partial<AppiumRecordedStep>] }>();
5
+ </script>
6
+
7
+ <template>
8
+ <el-form-item label="点击文字" required>
9
+ <el-input :model-value="step.value || ''" :disabled="disabled" maxlength="1000" aria-label="点击文字"
10
+ @update:model-value="emit('update', { value: String($event) })" />
11
+ </el-form-item>
12
+ <el-form-item label="文本匹配方式">
13
+ <el-select :model-value="step.flow?.textMatch || 'contains'" :disabled="disabled"
14
+ @update:model-value="emit('update', { flow: { ...props.step.flow, textMatch: $event } })">
15
+ <el-option label="精准匹配(完全一致)" value="exact" />
16
+ <el-option label="模糊匹配(包含)" value="contains" />
17
+ </el-select>
18
+ </el-form-item>
19
+ </template>
@@ -0,0 +1,151 @@
1
+ <script setup lang="ts">
2
+ import { computed } from 'vue';
3
+ import { Aim } from '@element-plus/icons-vue';
4
+ import type { AppiumVisualChangeConfig } from '../types';
5
+ import { normalizeVisualChangeConfig } from '../visual-change';
6
+
7
+ const props = defineProps<{
8
+ modelValue: boolean;
9
+ config: AppiumVisualChangeConfig;
10
+ hasSelectedElement: boolean;
11
+ pickingRegion?: boolean;
12
+ }>();
13
+
14
+ const emit = defineEmits<{
15
+ 'update:modelValue': [value: boolean];
16
+ 'update:config': [value: AppiumVisualChangeConfig];
17
+ pickRegion: [];
18
+ confirm: [];
19
+ }>();
20
+
21
+ const normalizedConfig = computed(() => normalizeVisualChangeConfig(props.config));
22
+
23
+ function patchConfig(patch: Partial<AppiumVisualChangeConfig>) {
24
+ emit('update:config', normalizeVisualChangeConfig({
25
+ ...normalizedConfig.value,
26
+ ...patch,
27
+ }));
28
+ }
29
+
30
+ function patchRegion(key: keyof AppiumVisualChangeConfig['region'], value: unknown) {
31
+ patchConfig({
32
+ region: {
33
+ ...normalizedConfig.value.region,
34
+ [key]: Number(value),
35
+ },
36
+ });
37
+ }
38
+ </script>
39
+
40
+ <template>
41
+ <el-dialog
42
+ :model-value="modelValue"
43
+ title="检测画面变化"
44
+ width="480px"
45
+ top="9vh"
46
+ class="appium-visual-change-dialog"
47
+ append-to-body
48
+ draggable
49
+ :modal="false"
50
+ modal-penetrable
51
+ :close-on-click-modal="false"
52
+ :close-on-press-escape="false"
53
+ @update:model-value="emit('update:modelValue', $event)"
54
+ >
55
+ <el-form label-position="top" size="small" class="appium-visual-change-form">
56
+ <el-form-item label="检测目标">
57
+ <el-radio-group
58
+ :model-value="normalizedConfig.mode"
59
+ @update:model-value="patchConfig({ mode: $event as AppiumVisualChangeConfig['mode'] })"
60
+ >
61
+ <el-radio-button label="selectedElement" :disabled="!hasSelectedElement">
62
+ 当前选中元素
63
+ </el-radio-button>
64
+ <el-radio-button label="region">手动框选区域</el-radio-button>
65
+ </el-radio-group>
66
+ </el-form-item>
67
+
68
+ <el-alert
69
+ v-if="normalizedConfig.mode === 'selectedElement' && !hasSelectedElement"
70
+ type="warning"
71
+ :closable="false"
72
+ show-icon
73
+ title="当前没有可用的选中元素,请先选择组件或改为手动框选区域"
74
+ />
75
+
76
+ <el-form-item v-if="normalizedConfig.mode === 'region'" label="框选区域">
77
+ <el-button :icon="Aim" :loading="pickingRegion" @click="emit('pickRegion')">
78
+ {{ pickingRegion ? '请在设备预览拖拽' : '框选/拖动区域' }}
79
+ </el-button>
80
+ </el-form-item>
81
+
82
+ <div class="appium-visual-change-form__grid">
83
+ <el-form-item label="X">
84
+ <el-input-number
85
+ :model-value="normalizedConfig.region.x"
86
+ :min="0"
87
+ :max="99999"
88
+ :precision="0"
89
+ controls-position="right"
90
+ @update:model-value="patchRegion('x', $event)"
91
+ />
92
+ </el-form-item>
93
+ <el-form-item label="Y">
94
+ <el-input-number
95
+ :model-value="normalizedConfig.region.y"
96
+ :min="0"
97
+ :max="99999"
98
+ :precision="0"
99
+ controls-position="right"
100
+ @update:model-value="patchRegion('y', $event)"
101
+ />
102
+ </el-form-item>
103
+ <el-form-item label="宽度">
104
+ <el-input-number
105
+ :model-value="normalizedConfig.region.width"
106
+ :min="1"
107
+ :max="99999"
108
+ :precision="0"
109
+ controls-position="right"
110
+ @update:model-value="patchRegion('width', $event)"
111
+ />
112
+ </el-form-item>
113
+ <el-form-item label="高度">
114
+ <el-input-number
115
+ :model-value="normalizedConfig.region.height"
116
+ :min="1"
117
+ :max="99999"
118
+ :precision="0"
119
+ controls-position="right"
120
+ @update:model-value="patchRegion('height', $event)"
121
+ />
122
+ </el-form-item>
123
+ <el-form-item label="变化阈值 %">
124
+ <el-input-number
125
+ :model-value="normalizedConfig.changeRatioThreshold"
126
+ :min="0.01"
127
+ :max="100"
128
+ :step="0.1"
129
+ controls-position="right"
130
+ @update:model-value="patchConfig({ changeRatioThreshold: Number($event) })"
131
+ />
132
+ </el-form-item>
133
+ <el-form-item label="像素容差">
134
+ <el-input-number
135
+ :model-value="normalizedConfig.pixelmatchThreshold"
136
+ :min="0"
137
+ :max="1"
138
+ :step="0.01"
139
+ controls-position="right"
140
+ @update:model-value="patchConfig({ pixelmatchThreshold: Number($event) })"
141
+ />
142
+ </el-form-item>
143
+ </div>
144
+ </el-form>
145
+
146
+ <template #footer>
147
+ <el-button @click="emit('update:modelValue', false)">取消</el-button>
148
+ <el-button type="primary" @click="emit('confirm')">添加开始节点</el-button>
149
+ </template>
150
+ </el-dialog>
151
+ </template>
@@ -58,6 +58,33 @@ function wireContinuation(step: AppiumRecordedStep, targetId: string) {
58
58
  if (!step.flow.noTargetId) setFlowTarget(step, 'noTargetId', targetId);
59
59
  }
60
60
 
61
+ function isVisualChangeStart(step: AppiumRecordedStep) {
62
+ return step.visualChange?.role === 'start';
63
+ }
64
+
65
+ function isVisualChangeEnd(step: AppiumRecordedStep) {
66
+ return step.visualChange?.role === 'end';
67
+ }
68
+
69
+ function visualChangePairKeys(step: AppiumRecordedStep) {
70
+ const config = step.visualChange;
71
+ if (!config) return [];
72
+ return [
73
+ config.pairId ? `pair:${config.pairId}` : '',
74
+ step.id ? `start:${step.id}` : '',
75
+ config.startStepId ? `start:${config.startStepId}` : '',
76
+ config.pairLabel ? `label:${config.pairLabel}` : '',
77
+ ].filter(Boolean);
78
+ }
79
+
80
+ function hasCopiedVisualChangeStartForEnd(copiedSteps: AppiumRecordedStep[], endStep: AppiumRecordedStep) {
81
+ const endKeys = new Set(visualChangePairKeys(endStep));
82
+ return copiedSteps.some((step) => (
83
+ isVisualChangeStart(step)
84
+ && visualChangePairKeys(step).some((key) => endKeys.has(key))
85
+ ));
86
+ }
87
+
61
88
  function branchConditionForTarget(
62
89
  steps: AppiumRecordedStep[],
63
90
  target: PasteTarget,
@@ -65,10 +92,10 @@ function branchConditionForTarget(
65
92
  if (!target.branch) return undefined;
66
93
  const anchor = steps[target.afterIndex];
67
94
  if (!anchor) return undefined;
95
+ if (anchor.flow?.nodeKind === 'condition') return anchor;
68
96
  if (anchor.flow?.parentConditionId && anchor.flow.parentBranch === target.branch) {
69
97
  return steps.find((step) => step.id === anchor.flow?.parentConditionId);
70
98
  }
71
- if (anchor.flow?.nodeKind === 'condition') return anchor;
72
99
  return undefined;
73
100
  }
74
101
 
@@ -123,6 +150,9 @@ export function createFlowClipboard(steps: AppiumRecordedStep[], indexes: number
123
150
  if (copiedSteps.some((step) => step.type === 'launchApp')) {
124
151
  throw new Error('启动 APP 节点不能复制');
125
152
  }
153
+ if (copiedSteps.some((step) => isVisualChangeEnd(step) && !hasCopiedVisualChangeStartForEnd(copiedSteps, step))) {
154
+ throw new Error('检测画面变化结束节点不能单独复制');
155
+ }
126
156
  return { steps: clone(copiedSteps), rootIds };
127
157
  }
128
158
 
@@ -141,6 +171,11 @@ export function pasteFlowClipboard(
141
171
  throw new Error('目标分支不存在');
142
172
  }
143
173
 
174
+ const pairMap = new Map<string, string>();
175
+ clipboard.steps.filter(isVisualChangeStart).forEach((step) => {
176
+ const pairId = createId();
177
+ visualChangePairKeys(step).forEach((key) => pairMap.set(key, pairId));
178
+ });
144
179
  const copies = clipboard.steps.map((source) => {
145
180
  const step = clone(source);
146
181
  step.id = idMap.get(source.id) || createId();
@@ -168,6 +203,17 @@ export function pasteFlowClipboard(
168
203
  }
169
204
  }
170
205
  step.flow = Object.keys(flow).length ? flow : undefined;
206
+ if (step.visualChange) {
207
+ const nextPairId = visualChangePairKeys(source)
208
+ .map((key) => pairMap.get(key))
209
+ .find(Boolean);
210
+ step.visualChange = {
211
+ ...step.visualChange,
212
+ pairId: nextPairId || step.visualChange.pairId,
213
+ startStepId: idMap.get(step.visualChange.startStepId || '') || step.visualChange.startStepId,
214
+ endStepId: idMap.get(step.visualChange.endStepId || '') || step.visualChange.endStepId,
215
+ };
216
+ }
171
217
  return step;
172
218
  });
173
219
 
@@ -1,6 +1,7 @@
1
1
  import { Position, type Edge, type Node } from '@vue-flow/core';
2
2
  import * as dagre from '@dagrejs/dagre';
3
3
  import type { AppiumRecordedStep } from './types';
4
+ import { defaultFlowKind as defaultKind, flowBranchLabel, isBooleanCondition } from './flow-labels';
4
5
 
5
6
  type DagreApi = typeof dagre;
6
7
  const dagreApi = ((dagre as DagreApi & { default?: DagreApi }).layout
@@ -25,6 +26,10 @@ export type InsertAction =
25
26
  | 'launchApp'
26
27
  | 'clearAppData'
27
28
  | 'popupCondition'
29
+ | 'checkboxState'
30
+ | 'radioButtonState'
31
+ | 'aiRecognition'
32
+ | 'textClick'
28
33
  | 'tapIfExists'
29
34
  | 'inputIfExists'
30
35
  | 'clearIfExists'
@@ -34,7 +39,12 @@ export type InsertAction =
34
39
  | 'assertText'
35
40
  | 'waitDisappear'
36
41
  | 'waitActivity'
37
- | 'runScript';
42
+ | 'runScript'
43
+ | 'noop'
44
+ | 'log'
45
+ | 'visualChangeStart'
46
+ | 'visualChangeEnd'
47
+ | 'visualChange';
38
48
 
39
49
  export const PASTE_COMMAND = '__paste_flow_nodes__';
40
50
  export const FLOW_STEP_NODE_WIDTH = 340;
@@ -50,6 +60,7 @@ const FLOW_STANDARD_LINE_GAP = 24;
50
60
  const FLOW_BRANCH_TRUNK_GAP = 96;
51
61
  const FLOW_BRANCH_LABEL_GAP = 58;
52
62
  const FLOW_BRANCH_MIN_SPREAD = 260;
63
+ const FLOW_BRANCH_SUBTREE_GAP = 80;
53
64
 
54
65
  export type FlowActionGroup = {
55
66
  title: string;
@@ -82,12 +93,14 @@ export type FlowGraphNodeData =
82
93
  canCopy: boolean;
83
94
  canEditInput: boolean;
84
95
  canExecute: boolean;
96
+ missingAiModel?: boolean;
85
97
  }
86
98
  | {
87
99
  kind: 'insert';
88
100
  actionGroups: FlowActionGroup[];
89
101
  afterIndex: number;
90
102
  branch?: FlowBranch;
103
+ branchLabel?: string;
91
104
  conditionIndex?: number;
92
105
  clipboardCount?: number;
93
106
  canOpenInsertMenu: boolean;
@@ -96,6 +109,8 @@ export type FlowGraphNodeData =
96
109
  | {
97
110
  kind: 'branch';
98
111
  branch: FlowBranch;
112
+ label: string;
113
+ width: number;
99
114
  conditionIndex: number;
100
115
  }
101
116
  | {
@@ -111,6 +126,7 @@ type StepItem = {
111
126
  };
112
127
 
113
128
  type BuildFlowGraphOptions = {
129
+ aiRecognitionModelConfigured?: boolean;
114
130
  expandedStepIndex: number | null;
115
131
  selectedCopyIndexes: number[];
116
132
  copyMode: boolean;
@@ -137,11 +153,12 @@ type Link = {
137
153
  visual?: boolean;
138
154
  };
139
155
 
140
- function defaultKind(step: AppiumRecordedStep): FlowKind {
141
- if (step.flow?.nodeKind) return step.flow.nodeKind;
142
- if (step.type === 'assertExists' || step.type === 'assertText') return 'assertion';
143
- return 'action';
144
- }
156
+ type BranchLayout = {
157
+ yesWidth: number;
158
+ noWidth: number;
159
+ centerGap: number;
160
+ totalWidth: number;
161
+ };
145
162
 
146
163
  function nodeIdForStep(step: AppiumRecordedStep) {
147
164
  return `step:${step.id}`;
@@ -232,6 +249,21 @@ function estimateStepNodeHeight(label: { title: string; meta: string; note?: str
232
249
  return Math.max(FLOW_STEP_NODE_HEIGHT, 34 + textHeight);
233
250
  }
234
251
 
252
+ function withLaunchAppAction(groups: FlowActionGroup[]) {
253
+ if (groups.some((group) => group.actions.some((action) => action.type === 'launchApp'))) return groups;
254
+ const launchAction = { type: 'launchApp' as const, label: '启动 App' };
255
+ let added = false;
256
+ const nextGroups = groups.map((group) => {
257
+ if (group.title !== '设备操作') return group;
258
+ added = true;
259
+ return {
260
+ ...group,
261
+ actions: [...group.actions, launchAction],
262
+ };
263
+ });
264
+ return added ? nextGroups : [...nextGroups, { title: '设备操作', actions: [launchAction] }];
265
+ }
266
+
235
267
  export function buildFlowGraph(
236
268
  steps: AppiumRecordedStep[],
237
269
  options: BuildFlowGraphOptions,
@@ -242,9 +274,63 @@ export function buildFlowGraph(
242
274
  const layoutLinks: Link[] = [];
243
275
  const seenLinks = new Set<string>();
244
276
  const allMainItems = mainItems(items);
277
+ const branchLayouts = new Map<string, BranchLayout>();
278
+ const measuringBranches = new Set<string>();
279
+
280
+ function conditionBranchLayout(condition: AppiumRecordedStep): BranchLayout {
281
+ const cached = branchLayouts.get(condition.id);
282
+ if (cached) return cached;
283
+ if (measuringBranches.has(condition.id)) {
284
+ return {
285
+ yesWidth: FLOW_STEP_NODE_WIDTH,
286
+ noWidth: FLOW_STEP_NODE_WIDTH,
287
+ centerGap: FLOW_BRANCH_MIN_SPREAD * 2,
288
+ totalWidth: FLOW_BRANCH_MIN_SPREAD * 2 + FLOW_STEP_NODE_WIDTH,
289
+ };
290
+ }
291
+
292
+ measuringBranches.add(condition.id);
293
+ const branchWidth = (branch: FlowBranch) => Math.max(
294
+ FLOW_STEP_NODE_WIDTH,
295
+ ...directBranchItems(items, condition.id, branch).map(({ step }) => (
296
+ defaultKind(step) === 'condition'
297
+ ? conditionBranchLayout(step).totalWidth
298
+ : FLOW_STEP_NODE_WIDTH
299
+ )),
300
+ );
301
+ const yesWidth = branchWidth('yes');
302
+ const noWidth = branchWidth('no');
303
+ const centerGap = Math.max(
304
+ FLOW_BRANCH_MIN_SPREAD * 2,
305
+ yesWidth / 2 + noWidth / 2 + FLOW_BRANCH_SUBTREE_GAP,
306
+ );
307
+ const sideExtent = Math.max(
308
+ centerGap / 2 + yesWidth / 2,
309
+ centerGap / 2 + noWidth / 2,
310
+ );
311
+ const layout = {
312
+ yesWidth,
313
+ noWidth,
314
+ centerGap,
315
+ totalWidth: Math.max(FLOW_STEP_NODE_WIDTH, sideExtent * 2),
316
+ };
317
+ measuringBranches.delete(condition.id);
318
+ branchLayouts.set(condition.id, layout);
319
+ return layout;
320
+ }
321
+
322
+ items
323
+ .filter(({ step }) => defaultKind(step) === 'condition')
324
+ .forEach(({ step }) => conditionBranchLayout(step));
245
325
 
246
326
  const addNode = (node: FlowGraphNode) => {
247
- nodes.push(node);
327
+ nodes.push({
328
+ ...node,
329
+ dimensions: {
330
+ width: Number(node.width) || FLOW_STEP_NODE_WIDTH,
331
+ height: Number(node.height) || FLOW_STEP_NODE_HEIGHT,
332
+ },
333
+ } as FlowGraphNode);
248
334
  };
249
335
  const addVisibleLink = (source: string, target: string, branch?: FlowBranch) => {
250
336
  addUniqueLink(links, seenLinks, {
@@ -266,6 +352,11 @@ export function buildFlowGraph(
266
352
  ) => {
267
353
  const id = insertNodeId(afterIndex, branch, condition?.step.id);
268
354
  const isStartInsert = afterIndex < 0 && !branch;
355
+ const previousStep = afterIndex >= 0 ? steps[afterIndex] : undefined;
356
+ const canLaunchAfterClearAppData = !isStartInsert
357
+ && !branch
358
+ && previousStep?.type === 'clearAppData'
359
+ && !steps.some((step) => step.type === 'launchApp');
269
360
  addNode({
270
361
  id,
271
362
  type: 'flow-node',
@@ -279,9 +370,14 @@ export function buildFlowGraph(
279
370
  connectable: false,
280
371
  data: {
281
372
  kind: 'insert',
282
- actionGroups: isStartInsert ? options.startActionGroups : options.mainActionGroups,
373
+ actionGroups: isStartInsert
374
+ ? options.startActionGroups
375
+ : canLaunchAfterClearAppData
376
+ ? withLaunchAppAction(options.mainActionGroups)
377
+ : options.mainActionGroups,
283
378
  afterIndex,
284
379
  branch,
380
+ branchLabel: branch && condition ? flowBranchLabel(condition.step, branch) : undefined,
285
381
  conditionIndex: condition?.index,
286
382
  clipboardCount: options.clipboardCount,
287
383
  canOpenInsertMenu: options.canOpenInsertMenu,
@@ -295,11 +391,13 @@ export function buildFlowGraph(
295
391
  const id = nodeIdForStep(item.step);
296
392
  const flowKind = defaultKind(item.step);
297
393
  const label = options.labelStep(item.step);
298
- const cardMinHeight = estimateStepNodeHeight(label);
394
+ // 字数估算仅用于首次布局,不强制卡片高度;实际换行由浏览器决定。
395
+ const cardMinHeight = FLOW_STEP_NODE_HEIGHT;
396
+ const estimatedCardHeight = estimateStepNodeHeight(label);
299
397
  const estimatedNodeHeight = options.expandedStepIndex === item.index
300
- ? cardMinHeight + FLOW_EXPANDED_NODE_HEIGHT
301
- : cardMinHeight;
302
- const nodeHeight = Math.max(estimatedNodeHeight, Math.ceil(options.measuredNodeHeights?.[id] || 0));
398
+ ? estimatedCardHeight + FLOW_EXPANDED_NODE_HEIGHT
399
+ : estimatedCardHeight;
400
+ const nodeHeight = Math.max(cardMinHeight, Math.ceil(options.measuredNodeHeights?.[id] || estimatedNodeHeight));
303
401
  addNode({
304
402
  id,
305
403
  type: 'flow-node',
@@ -325,30 +423,34 @@ export function buildFlowGraph(
325
423
  copyMode: options.copyMode,
326
424
  disabled: item.step.type === 'launchApp' || item.step.type === 'clearAppData'
327
425
  ? options.isAppExecutionDisabled(item.step.type)
328
- : options.disabled,
426
+ : item.step.type === 'aiRecognition' ? options.isInsertActionDisabled('aiRecognition') : options.disabled,
329
427
  removeDisabled: options.removeDisabled,
330
428
  launching: options.launchingStepId === item.step.id,
331
- canCopy: item.step.type !== 'launchApp' && item.step.type !== 'clearAppData',
429
+ canCopy: item.step.type !== 'launchApp'
430
+ && item.step.type !== 'clearAppData'
431
+ && item.step.visualChange?.role !== 'end',
332
432
  canEditInput: item.step.type === 'input' || item.step.type === 'inputIfExists',
333
- canExecute: item.step.type === 'launchApp' || item.step.type === 'clearAppData',
433
+ canExecute: item.step.type === 'launchApp' || item.step.type === 'clearAppData' || item.step.type === 'aiRecognition',
434
+ missingAiModel: item.step.type === 'aiRecognition' && !options.aiRecognitionModelConfigured,
334
435
  },
335
436
  });
336
437
  };
337
438
 
338
439
  const addBranchNode = (condition: StepItem, branch: FlowBranch) => {
339
440
  const id = branchNodeId(condition.step, branch);
441
+ const width = condition.step.type === 'textClick' ? 116 : isBooleanCondition(condition.step) ? 56 : FLOW_BRANCH_NODE_WIDTH;
340
442
  addNode({
341
443
  id,
342
444
  type: 'flow-node',
343
445
  position: { x: 0, y: 0 },
344
446
  sourcePosition: Position.Bottom,
345
447
  targetPosition: Position.Top,
346
- width: FLOW_BRANCH_NODE_WIDTH,
448
+ width,
347
449
  height: FLOW_BRANCH_NODE_HEIGHT,
348
450
  selectable: false,
349
451
  draggable: false,
350
452
  connectable: false,
351
- data: { kind: 'branch', branch, conditionIndex: condition.index },
453
+ data: { kind: 'branch', branch, label: flowBranchLabel(condition.step, branch), width, conditionIndex: condition.index },
352
454
  });
353
455
  return id;
354
456
  };
@@ -519,19 +621,6 @@ export function buildFlowGraph(
519
621
  const setCenterX = (node: FlowGraphNode, x: number) => {
520
622
  node.position.x = Math.round(x - nodeWidth(node) / 2);
521
623
  };
522
- const normalizeBranchPositions = () => {
523
- positionedNodes
524
- .filter((node) => node.data?.kind === 'split')
525
- .forEach((splitNode) => {
526
- const branchLinks = links.filter((link) => link.source === splitNode.id && link.branch);
527
- const yesNode = nodeById.get(branchLinks.find((link) => link.branch === 'yes')?.target || '');
528
- const noNode = nodeById.get(branchLinks.find((link) => link.branch === 'no')?.target || '');
529
- const splitX = centerX(splitNode);
530
- const spread = FLOW_BRANCH_MIN_SPREAD;
531
- if (yesNode) setCenterX(yesNode, splitX - spread);
532
- if (noNode) setCenterX(noNode, splitX + spread);
533
- });
534
- };
535
624
  const canAlignStepAfterFlowPoint = (sourceNode: FlowGraphNode, stepNode: FlowGraphNode) => {
536
625
  if (stepNode.data?.kind !== 'step') return false;
537
626
  return sourceNode.data?.kind === 'insert' || sourceNode.data?.kind === 'branch';
@@ -635,43 +724,37 @@ export function buildFlowGraph(
635
724
  if (!moved) break;
636
725
  }
637
726
  };
727
+ // 按依赖顺序传递坐标,避免固定轮数只对齐长分支的前半段。
728
+ // 旧脚本若含回边,保留 Dagre 的纵向顺序,避免拓扑排序抛错。
729
+ const orderedNodeIds = dagreApi.graphlib.alg.isAcyclic(dagreGraph)
730
+ ? dagreApi.graphlib.alg.topsort(dagreGraph)
731
+ : [...positionedNodes].sort((a, b) => a.position.y - b.position.y).map((node) => node.id);
732
+ const nodeOrder = new Map(orderedNodeIds.map((id, index) => [id, index]));
733
+ const orderedLinks = [...links].sort((a, b) => (
734
+ (nodeOrder.get(a.source) || 0) - (nodeOrder.get(b.source) || 0)
735
+ ));
638
736
  const alignVisibleChainGeometry = () => {
639
- for (let pass = 0; pass < 4; pass += 1) {
640
- links.forEach((link) => {
641
- const source = nodeById.get(link.source);
642
- const target = nodeById.get(link.target);
643
- if (!source || !target?.data) return;
644
- if (!['branch', 'split'].includes(target.data.kind)) return;
645
-
646
- const lineGap = target.data.kind === 'split'
647
- ? FLOW_BRANCH_TRUNK_GAP
648
- : FLOW_BRANCH_LABEL_GAP;
649
- target.position.y = Math.round(source.position.y + nodeHeight(source) + lineGap);
650
- if (target.data.kind === 'split') {
651
- setCenterX(target, centerX(source));
652
- }
653
- });
654
- normalizeBranchPositions();
655
-
656
- links.forEach((link) => {
657
- const source = nodeById.get(link.source);
658
- const target = nodeById.get(link.target);
659
- if (!source || target?.data?.kind !== 'insert') return;
660
- target.position.y = Math.round(source.position.y + nodeHeight(source) + FLOW_STANDARD_LINE_GAP);
661
- setCenterX(target, centerX(source));
662
- });
663
- alignableLinks.forEach((link) => {
664
- const source = nodeById.get(link.source);
665
- const target = nodeById.get(link.target);
666
- if (!source || !target || !canAlignStepAfterFlowPoint(source, target)) return;
667
- target.position.y = Math.round(source.position.y + nodeHeight(source) + FLOW_STANDARD_LINE_GAP);
668
- setCenterX(target, centerX(source));
669
- });
670
- }
737
+ orderedLinks.forEach((link) => {
738
+ const source = nodeById.get(link.source);
739
+ const target = nodeById.get(link.target);
740
+ if (!source || !target?.data) return;
741
+ const kind = target.data.kind;
742
+ if (!['branch', 'split', 'insert'].includes(kind) && !canAlignStepAfterFlowPoint(source, target)) return;
743
+
744
+ const lineGap = kind === 'split'
745
+ ? FLOW_BRANCH_TRUNK_GAP
746
+ : kind === 'branch' ? FLOW_BRANCH_LABEL_GAP : FLOW_STANDARD_LINE_GAP;
747
+ target.position.y = Math.round(source.position.y + nodeHeight(source) + lineGap);
748
+ let x = centerX(source);
749
+ if (target.data.kind === 'branch') {
750
+ const conditionId = steps[target.data.conditionIndex].id;
751
+ const spread = (branchLayouts.get(conditionId)?.centerGap || FLOW_BRANCH_MIN_SPREAD * 2) / 2;
752
+ x += target.data.branch === 'yes' ? -spread : spread;
753
+ }
754
+ setCenterX(target, x);
755
+ });
671
756
  };
672
757
 
673
- const alignableLinks = [...links, ...layoutLinks];
674
-
675
758
  alignVisibleChainGeometry();
676
759
 
677
760
  resolveStepCollisions();