android-midscene-automation 0.1.30 → 0.1.32

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 (43) hide show
  1. package/CHANGELOG.md +16 -26
  2. package/README.md +16 -26
  3. package/bin/sync-readme-docs.mjs +49 -0
  4. package/index.html +1 -1
  5. package/package.json +8 -8
  6. package/remote-agent/index.ts +1 -1
  7. package/server/appium-recorder/appium-runner.ts +97 -43
  8. package/server/appium-recorder/bounded-loop.ts +118 -0
  9. package/server/appium-recorder/history-capture.ts +34 -0
  10. package/server/appium-recorder/report.ts +1 -1
  11. package/server/appium-recorder/repository.ts +9 -0
  12. package/server/appium-recorder/routes.ts +29 -5
  13. package/server/appium-recorder/run-history.ts +34 -0
  14. package/src/appium-recorder/AppiumPage.vue +114 -54
  15. package/src/appium-recorder/RunHistoryDialog.vue +153 -0
  16. package/src/appium-recorder/bounded-loop.ts +66 -0
  17. package/src/appium-recorder/components/BreakLoopSettings.vue +29 -0
  18. package/src/appium-recorder/components/ComponentInspector.vue +26 -0
  19. package/src/appium-recorder/components/ComponentTree.vue +24 -4
  20. package/src/appium-recorder/components/FlowCanvas.vue +19 -1
  21. package/src/appium-recorder/components/FlowNodeCard.vue +6 -3
  22. package/src/appium-recorder/components/FlowNodeSearch.vue +62 -0
  23. package/src/appium-recorder/components/FlowStepEditor.vue +9 -4
  24. package/src/appium-recorder/components/LoopSettings.vue +37 -0
  25. package/src/appium-recorder/components/NestedConditionBranches.vue +11 -3
  26. package/src/appium-recorder/components/NodeDetail.vue +30 -6
  27. package/src/appium-recorder/components/RecordedSteps.vue +24 -4
  28. package/src/appium-recorder/components/RecorderWorkspace.vue +132 -0
  29. package/src/appium-recorder/components/VisualChangeEndPicker.vue +21 -0
  30. package/src/appium-recorder/flow-copy.ts +1 -0
  31. package/src/appium-recorder/flow-graph.ts +4 -9
  32. package/src/appium-recorder/flow-labels.ts +7 -0
  33. package/src/appium-recorder/flow-merge.ts +3 -2
  34. package/src/appium-recorder/flow-normalize.ts +1 -0
  35. package/src/appium-recorder/flow-search.ts +29 -0
  36. package/src/appium-recorder/node-locators.ts +25 -0
  37. package/src/appium-recorder/run-history.ts +50 -0
  38. package/src/appium-recorder/tree.ts +2 -5
  39. package/src/appium-recorder/types.ts +7 -0
  40. package/src/appium-recorder/visual-change-pairs.ts +16 -10
  41. package/src/components/device/DevicePreviewPanel.vue +62 -10
  42. package/src/style.css +24 -7
  43. package/src/ui-refresh.css +40 -0
@@ -0,0 +1,132 @@
1
+ <script setup lang="ts">
2
+ import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue';
3
+ import { RefreshLeft, Fold, Operation } from '@element-plus/icons-vue';
4
+
5
+ const STORAGE_KEY = 'android-midscene-automation:recorder-layout';
6
+ const defaults = { previewWidth: null as number | null, treeWidth: null as number | null, previewHidden: false, treeHidden: false, mode: 'recording' };
7
+ const settings = reactive({ ...defaults });
8
+ try {
9
+ const saved = JSON.parse(localStorage.getItem(STORAGE_KEY) || 'null');
10
+ if (saved) {
11
+ for (const key of ['previewWidth', 'treeWidth'] as const) {
12
+ if (Number.isFinite(saved[key])) settings[key] = Math.max(key === 'previewWidth' ? 260 : 220, Math.min(600, saved[key]));
13
+ }
14
+ // The previous fixed defaults should follow the restored proportional layout.
15
+ if (saved.previewWidth === 320 && saved.treeWidth === 260) {
16
+ settings.previewWidth = null;
17
+ settings.treeWidth = null;
18
+ }
19
+ settings.previewHidden = saved.previewHidden === true;
20
+ settings.treeHidden = saved.treeHidden === true;
21
+ settings.mode = saved.mode === 'flow' ? 'flow' : 'recording';
22
+ }
23
+ } catch { /* Layout persistence is optional when browser storage is unavailable. */ }
24
+ watch(settings, () => {
25
+ try { localStorage.setItem(STORAGE_KEY, JSON.stringify(settings)); } catch { /* Keep the layout usable without storage. */ }
26
+ });
27
+
28
+ const container = ref<HTMLElement>();
29
+ const width = ref(1400);
30
+ const drawer = ref<'preview' | 'tree' | null>(null);
31
+ const narrow = computed(() => width.value < 700);
32
+ const treeCollapsed = computed(() => settings.treeHidden || settings.mode === 'flow' || width.value < 1100);
33
+ const previewCollapsed = computed(() => settings.previewHidden || narrow.value);
34
+ watch(() => width.value < 1100 ? (narrow.value ? 'small' : 'medium') : 'wide', () => { drawer.value = null; });
35
+ // Match the original 2.8fr / 3fr / 4.2fr columns, including the preview's 320px minimum.
36
+ const defaultPreviewWidth = computed(() => Math.max(320, (width.value - 16) * .28));
37
+ const defaultTreeWidth = computed(() => (width.value - 16 - defaultPreviewWidth.value) * 3 / 7.2);
38
+ const previewWidth = computed(() => Math.max(260, Math.min(settings.previewWidth ?? defaultPreviewWidth.value, width.value - 440 - (treeCollapsed.value ? 36 : 220) - 16)));
39
+ const treeWidth = computed(() => Math.max(220, Math.min(settings.treeWidth ?? defaultTreeWidth.value, width.value - 440 - (previewCollapsed.value ? 36 : previewWidth.value) - 16)));
40
+ const columns = computed(() => `${previewCollapsed.value ? 36 : previewWidth.value}px 8px ${treeCollapsed.value ? 36 : treeWidth.value}px 8px minmax(0, 1fr)`);
41
+
42
+ function showTree() {
43
+ if (width.value < 1100) drawer.value = 'tree';
44
+ else { settings.mode = 'recording'; settings.treeHidden = false; }
45
+ }
46
+ function showPreview() {
47
+ if (narrow.value) drawer.value = 'preview';
48
+ else settings.previewHidden = false;
49
+ }
50
+ function setMode(mode: string | number | boolean | undefined) {
51
+ settings.mode = mode === 'flow' ? 'flow' : 'recording';
52
+ drawer.value = null;
53
+ if (settings.mode === 'recording') { settings.treeHidden = false; settings.previewHidden = false; }
54
+ }
55
+ function resetLayout() { Object.assign(settings, defaults); drawer.value = null; }
56
+
57
+ let drag: { key: 'previewWidth' | 'treeWidth'; startX: number; startWidth: number; pointerId: number } | null = null;
58
+ function beginResize(event: PointerEvent, key: 'previewWidth' | 'treeWidth') {
59
+ if (event.button !== 0) return;
60
+ drag = { key, startX: event.clientX, startWidth: key === 'previewWidth' ? previewWidth.value : treeWidth.value, pointerId: event.pointerId };
61
+ (event.currentTarget as HTMLElement).setPointerCapture(event.pointerId);
62
+ }
63
+ function changeWidth(key: 'previewWidth' | 'treeWidth', value: number) {
64
+ const other = key === 'previewWidth' ? (treeCollapsed.value ? 36 : treeWidth.value) : (previewCollapsed.value ? 36 : previewWidth.value);
65
+ settings[key] = Math.max(key === 'previewWidth' ? 260 : 220, Math.min(600, width.value - other - 456, value));
66
+ }
67
+ function resize(event: PointerEvent) {
68
+ if (drag?.pointerId === event.pointerId) changeWidth(drag.key, drag.startWidth + event.clientX - drag.startX);
69
+ }
70
+ function resizeKey(event: KeyboardEvent, key: 'previewWidth' | 'treeWidth') {
71
+ if (!['ArrowLeft', 'ArrowRight'].includes(event.key)) return;
72
+ event.preventDefault();
73
+ changeWidth(key, (key === 'previewWidth' ? previewWidth.value : treeWidth.value) + (event.key === 'ArrowRight' ? 20 : -20));
74
+ }
75
+ let observer: ResizeObserver | undefined;
76
+ onMounted(() => {
77
+ observer = new ResizeObserver(([entry]) => { if (entry) width.value = entry.contentRect.width; });
78
+ if (container.value) observer.observe(container.value);
79
+ });
80
+ onBeforeUnmount(() => observer?.disconnect());
81
+ defineExpose({ showTree, showPreview });
82
+ </script>
83
+
84
+ <template>
85
+ <div ref="container" class="recorder-workspace">
86
+ <div class="recorder-workspace__toolbar">
87
+ <el-radio-group :model-value="settings.mode" size="small" aria-label="工作区布局" @update:model-value="setMode">
88
+ <el-radio-button value="recording">录制布局</el-radio-button>
89
+ <el-radio-button value="flow">流程布局</el-radio-button>
90
+ </el-radio-group>
91
+ <div class="recorder-workspace__tools">
92
+ <el-tooltip content="恢复默认布局" placement="top"><el-button :icon="RefreshLeft" size="small" aria-label="恢复默认布局" @click="resetLayout" /></el-tooltip>
93
+ </div>
94
+ </div>
95
+ <div class="recorder-workspace__panels" :style="{ gridTemplateColumns: columns }">
96
+ <div v-show="!previewCollapsed || drawer === 'preview'" class="recorder-workspace__panel" :class="{ 'recorder-workspace__panel--drawer': drawer === 'preview' }">
97
+ <slot name="preview" />
98
+ <el-tooltip content="收起设备预览" placement="top" :show-after="200"><el-button class="recorder-workspace__collapse recorder-panel-tool" :icon="Fold" size="small" aria-label="收起设备预览" @click="settings.previewHidden = true; drawer = null" /></el-tooltip>
99
+ </div>
100
+ <button v-if="previewCollapsed" class="recorder-workspace__rail" aria-label="展开设备预览" title="展开设备预览" @click="showPreview"><el-icon><Operation /></el-icon><span>设备预览</span></button>
101
+ <div class="recorder-workspace__separator" :class="{ 'is-disabled': previewCollapsed }" role="separator" aria-label="调整设备预览宽度" aria-orientation="vertical" :aria-valuenow="previewWidth" :tabindex="previewCollapsed ? -1 : 0" @pointerdown="!previewCollapsed && beginResize($event, 'previewWidth')" @pointermove="resize" @pointerup="drag = null" @pointercancel="drag = null" @keydown="resizeKey($event, 'previewWidth')" />
102
+ <div v-show="!treeCollapsed || drawer === 'tree'" class="recorder-workspace__panel" :class="{ 'recorder-workspace__panel--drawer': drawer === 'tree' }">
103
+ <slot name="tree" />
104
+ <el-tooltip content="收起组件树" placement="top" :show-after="200"><el-button class="recorder-workspace__collapse recorder-panel-tool" :icon="Fold" size="small" aria-label="收起组件树" @click="settings.treeHidden = true; drawer = null" /></el-tooltip>
105
+ </div>
106
+ <button v-if="treeCollapsed" class="recorder-workspace__rail" aria-label="展开组件树" title="展开组件树" @click="showTree"><el-icon><Operation /></el-icon><span>组件树</span></button>
107
+ <div class="recorder-workspace__separator" :class="{ 'is-disabled': treeCollapsed }" role="separator" aria-label="调整组件树宽度" aria-orientation="vertical" :aria-valuenow="treeWidth" :tabindex="treeCollapsed ? -1 : 0" @pointerdown="!treeCollapsed && beginResize($event, 'treeWidth')" @pointermove="resize" @pointerup="drag = null" @pointercancel="drag = null" @keydown="resizeKey($event, 'treeWidth')" />
108
+ <div class="recorder-workspace__main"><slot /></div>
109
+ <button v-if="drawer" class="recorder-workspace__backdrop" aria-label="关闭侧面板" @click="drawer = null" />
110
+ </div>
111
+ </div>
112
+ </template>
113
+
114
+ <style scoped>
115
+ .recorder-workspace { display: flex; flex-direction: column; flex: 1; min-width: 0; min-height: 0; gap: 8px; }
116
+ .recorder-workspace__toolbar { display: flex; align-items: center; flex: none; gap: 8px; }
117
+ .recorder-workspace__tools { display: flex; }
118
+ .recorder-workspace__tools .el-button { width: 24px; height: 24px; min-height: 24px; padding: 0; font-size: 12px; border-radius: 4px; }
119
+ .recorder-workspace__panels { display: grid; position: relative; flex: 1; min-height: 0; }
120
+ .recorder-workspace__panel, .recorder-workspace__main { position: relative; min-width: 0; min-height: 0; display: flex; flex-direction: column; }
121
+ .recorder-workspace__panel :deep(> .el-card), .recorder-workspace__main :deep(> .el-card) { flex: 1; min-width: 0; min-height: 0; }
122
+ .recorder-workspace__panel :deep(.el-card__header) { padding: 12px 46px 12px 12px; min-height: 56px; }
123
+ .recorder-workspace__panel :deep(.el-card__body) { padding: 10px; }
124
+ .recorder-workspace__collapse { position: absolute; right: 9px; top: 13px; }
125
+ .recorder-workspace__rail { display: flex; flex-direction: column; align-items: center; gap: 12px; border: 1px solid #dce3e8; border-radius: 4px; background: #fff; color: #475569; padding: 14px 4px; cursor: pointer; }
126
+ .recorder-workspace__rail span { writing-mode: vertical-rl; font-size: 13px; }
127
+ .recorder-workspace__separator { cursor: col-resize; touch-action: none; user-select: none; }
128
+ .recorder-workspace__separator:hover, .recorder-workspace__separator:focus-visible { background: #b3d8ff; outline: none; }
129
+ .recorder-workspace__separator.is-disabled { pointer-events: none; }
130
+ .recorder-workspace__panel--drawer { position: absolute; inset: 0 auto 0 0; width: min(340px, 100%); z-index: 22; background: #fff; box-shadow: 3px 0 16px #0002; }
131
+ .recorder-workspace__backdrop { position: absolute; inset: 0; border: 0; background: #0003; z-index: 21; }
132
+ </style>
@@ -0,0 +1,21 @@
1
+ <script setup lang="ts">
2
+ defineProps<{ candidates: { id: string; label: string; number: number }[] }>();
3
+ const emit = defineEmits<{ select: [id: string] }>();
4
+ </script>
5
+
6
+ <template>
7
+ <div class="visual-end-picker" aria-label="可结束的画面变化检测">
8
+ <button v-for="item in candidates" :key="item.id" type="button" @click="emit('select', item.id)">
9
+ <strong>{{ item.label }}</strong>
10
+ <span>开始节点 #{{ item.number }}</span>
11
+ </button>
12
+ </div>
13
+ </template>
14
+
15
+ <style scoped>
16
+ .visual-end-picker { display: grid; gap: 4px; max-height: 320px; overflow: auto; }
17
+ .visual-end-picker button { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 6px 16px; width: 100%; padding: 10px 12px; border: 0; border-radius: 4px; background: transparent; color: var(--el-text-color-primary); font: inherit; text-align: left; cursor: pointer; }
18
+ .visual-end-picker button:hover, .visual-end-picker button:focus-visible { background: var(--el-fill-color-light); outline: 2px solid var(--el-color-primary); outline-offset: -2px; }
19
+ .visual-end-picker strong { font-size: 14px; overflow-wrap: anywhere; }
20
+ .visual-end-picker span { font-size: 12px; color: var(--el-text-color-secondary); }
21
+ </style>
@@ -179,6 +179,7 @@ export function pasteFlowClipboard(
179
179
  const copies = clipboard.steps.map((source) => {
180
180
  const step = clone(source);
181
181
  step.id = idMap.get(source.id) || createId();
182
+ if (step.breakLoopTargetId) step.breakLoopTargetId = idMap.get(step.breakLoopTargetId) || step.breakLoopTargetId;
182
183
  const flow = { ...(step.flow || {}) };
183
184
  for (const key of targetKeys) {
184
185
  const mappedId = flow[key] ? idMap.get(flow[key] || '') : '';
@@ -26,6 +26,8 @@ export type InsertAction =
26
26
  | 'launchApp'
27
27
  | 'openGallery'
28
28
  | 'endFlow'
29
+ | 'loop'
30
+ | 'breakLoop'
29
31
  | 'clearAppData'
30
32
  | 'popupCondition'
31
33
  | 'checkboxState'
@@ -359,11 +361,6 @@ export function buildFlowGraph(
359
361
  ) => {
360
362
  const id = insertNodeId(afterIndex, branch, condition?.step.id);
361
363
  const isStartInsert = afterIndex < 0 && !branch;
362
- const previousStep = afterIndex >= 0 ? steps[afterIndex] : undefined;
363
- const canLaunchAfterClearAppData = !isStartInsert
364
- && !branch
365
- && previousStep?.type === 'clearAppData'
366
- && !steps.some((step) => step.type === 'launchApp');
367
364
  addNode({
368
365
  id,
369
366
  type: 'flow-node',
@@ -379,9 +376,7 @@ export function buildFlowGraph(
379
376
  kind: 'insert',
380
377
  actionGroups: isStartInsert
381
378
  ? options.startActionGroups
382
- : canLaunchAfterClearAppData
383
- ? withLaunchAppAction(options.mainActionGroups)
384
- : options.mainActionGroups,
379
+ : withLaunchAppAction(options.mainActionGroups),
385
380
  afterIndex,
386
381
  branch,
387
382
  branchLabel: branch && condition ? flowBranchLabel(condition.step, branch) : undefined,
@@ -447,7 +442,7 @@ export function buildFlowGraph(
447
442
 
448
443
  const addBranchNode = (condition: StepItem, branch: FlowBranch) => {
449
444
  const id = branchNodeId(condition.step, branch);
450
- const width = condition.step.type === 'textClick' ? 116 : isBooleanCondition(condition.step) ? 56 : FLOW_BRANCH_NODE_WIDTH;
445
+ const width = condition.step.type === 'textClick' ? 116 : condition.step.type === 'loop' ? 80 : isBooleanCondition(condition.step) ? 56 : FLOW_BRANCH_NODE_WIDTH;
451
446
  addNode({
452
447
  id,
453
448
  type: 'flow-node',
@@ -10,6 +10,8 @@ export function isBooleanCondition(step: Pick<AppiumRecordedStep, 'type'>) {
10
10
  }
11
11
 
12
12
  export function defaultFlowKind(step: AppiumRecordedStep): FlowKind {
13
+ if (step.type === 'loop') return 'condition';
14
+ if (step.type === 'breakLoop') return 'action';
13
15
  if (step.type === 'log' || step.type === 'openGallery' || step.type === 'endFlow') return 'action';
14
16
  if (isBooleanCondition(step)) return 'condition';
15
17
  if (step.flow?.nodeKind) return step.flow.nodeKind;
@@ -18,6 +20,7 @@ export function defaultFlowKind(step: AppiumRecordedStep): FlowKind {
18
20
  }
19
21
 
20
22
  export function flowBranchLabel(step: Pick<AppiumRecordedStep, 'type'>, branch: FlowBranch) {
23
+ if (step.type === 'loop') return branch === 'yes' ? '循环体' : '循环结束';
21
24
  if (step.type === 'textClick') return branch === 'yes' ? '匹配到文字' : '未匹配到文字';
22
25
  if (isBooleanCondition(step)) return branch === 'yes' ? 'true' : 'false';
23
26
  return branch === 'yes' ? '是' : '否';
@@ -54,6 +57,8 @@ export function flowTypeLabel(step: AppiumRecordedStep) {
54
57
  launchApp: '启动 APP',
55
58
  openGallery: '启动相册',
56
59
  endFlow: '终止流程',
60
+ loop: '有界循环',
61
+ breakLoop: '退出循环',
57
62
  clearAppData: '清理 APP 缓存',
58
63
  longPress: '长按',
59
64
  pinch: '双指缩放',
@@ -66,6 +71,8 @@ export function flowTypeLabel(step: AppiumRecordedStep) {
66
71
  }
67
72
 
68
73
  export function flowStepMeta(step: AppiumRecordedStep) {
74
+ if (step.type === 'loop') return `最多 ${step.loop?.maxIterations ?? '?'} 次 · ${step.loop?.exitWhen === 'exists' ? '元素出现时退出' : step.loop?.exitWhen === 'notExists' ? '元素消失时退出' : '固定次数'}${step.loop?.exitWhen !== 'never' ? ` ${step.selector?.value || ''}` : ''}`;
75
+ if (step.type === 'breakLoop') return step.breakLoopTargetId ? '退出指定循环,继续循环结束后的流程' : '退出当前循环,继续循环结束后的流程';
69
76
  if (step.type === 'log') return `${step.logPrefix ?? DEFAULT_LOG_PREFIX}:${step.value || ''}`;
70
77
  if (step.type === 'aiRecognition') return step.value || '未填写识别内容';
71
78
  if (step.type === 'longPress') {
@@ -25,6 +25,7 @@ export function mergeBranches(steps: AppiumRecordedStep[], conditionId: string,
25
25
  const condition = steps.find((step) => step.id === conditionId);
26
26
  const common = steps.find((step) => step.id === commonId);
27
27
  if (!condition || defaultFlowKind(condition) !== 'condition') throw new Error('请选择判断节点');
28
+ if (condition.type === 'loop') throw new Error('循环体与循环结束路径不能合并');
28
29
  if (condition.flow?.successTargetId) throw new Error('该判断已有后续公共流程,不能重复合流');
29
30
  if (!common || common.flow?.parentConditionId !== conditionId || !common.flow.parentBranch) throw new Error('公共起点必须属于当前判断的直接分支');
30
31
  const side = common.flow.parentBranch;
@@ -62,10 +63,10 @@ export function mergeBranches(steps: AppiumRecordedStep[], conditionId: string,
62
63
  }
63
64
  }
64
65
  function connectEnd(step: typeof next[number]) {
65
- if (step.type === 'endFlow') return;
66
+ if (step.type === 'endFlow' || step.type === 'breakLoop') return;
66
67
  step.flow.successTargetId = commonId;
67
68
  if (defaultFlowKind(step) === 'condition') {
68
- for (const branch of ['yes', 'no'] as const) {
69
+ for (const branch of (step.type === 'loop' ? ['no'] : ['yes', 'no']) as Branch[]) {
69
70
  const children = branchSteps(next, step.id, branch);
70
71
  if (children.length) connectEnd(byId.get(children[children.length - 1]!.id)!);
71
72
  else step.flow[branch === 'yes' ? 'yesTargetId' : 'noTargetId'] = commonId;
@@ -11,6 +11,7 @@ function isEmptyNestedCondition(step: AppiumRecordedStep) {
11
11
  return isCondition(step)
12
12
  // 新类型不存在旧版分支数据,不应把后续同级节点迁入 true 分支。
13
13
  && !isBooleanCondition(step)
14
+ && step.type !== 'loop'
14
15
  && Boolean(step.flow?.parentConditionId)
15
16
  && Boolean(step.flow?.parentBranch)
16
17
  && !step.flow?.yesTargetId
@@ -0,0 +1,29 @@
1
+ import type { AppiumRecordedStep } from './types';
2
+ import { flowBranchLabel, labelFlowStep } from './flow-labels';
3
+
4
+ export type FlowSearchResult = { id: string; index: number; title: string; meta: string; branch: string };
5
+
6
+ export function searchFlowNodes(steps: AppiumRecordedStep[], query: string): FlowSearchResult[] {
7
+ const terms = query.trim().toLocaleLowerCase().split(/\s+/).filter(Boolean);
8
+ if (!terms.length) return [];
9
+ const byId = new Map(steps.map((step) => [step.id, step]));
10
+ return steps.flatMap((step, index) => {
11
+ const label = labelFlowStep(step);
12
+ const content = [label.title, label.meta, label.note, step.type, step.selector?.value, step.contextSelector?.value,
13
+ step.value, step.logPrefix].filter(Boolean).join(' ').toLocaleLowerCase();
14
+ if (!terms.every((term) => content.includes(term))) return [];
15
+ const path: string[] = [];
16
+ const visited = new Set([step.id]);
17
+ let current = step;
18
+ // Follow ownership rather than outgoing edges so merged flows never inherit a sibling branch.
19
+ while (current.flow?.parentConditionId) {
20
+ const parent = byId.get(current.flow.parentConditionId);
21
+ if (!parent || visited.has(parent.id)) break;
22
+ visited.add(parent.id);
23
+ const branch = current.flow.parentBranch;
24
+ path.unshift(`${parent.label} · ${branch ? flowBranchLabel(parent, branch) : '分支'}`);
25
+ current = parent;
26
+ }
27
+ return [{ id: step.id, index, title: label.title, meta: label.meta, branch: ['主流程', ...path].join(' / ') }];
28
+ });
29
+ }
@@ -0,0 +1,25 @@
1
+ import type { AppiumNode } from './types';
2
+
3
+ export function xpathStringLiteral(value: string) {
4
+ if (!value.includes("'")) return `'${value}'`;
5
+ if (!value.includes('"')) return `"${value}"`;
6
+ return `concat(${value.split("'").map((part) => `'${part}'`).join(', "\'", ')})`;
7
+ }
8
+
9
+ function uiAutomatorLocator(node: AppiumNode) {
10
+ // Prefer stable identifiers; adding current text to an ID selector would make it state-dependent.
11
+ if (node.resourceId) return `new UiSelector().resourceId(${JSON.stringify(node.resourceId)})`;
12
+ if (node.contentDesc) return `new UiSelector().description(${JSON.stringify(node.contentDesc)})`;
13
+ const classClause = node.className && node.className !== 'node' ? `.className(${JSON.stringify(node.className)})` : '';
14
+ if (node.text) return `new UiSelector().text(${JSON.stringify(node.text)})${classClause}`;
15
+ return classClause ? `new UiSelector()${classClause}` : '';
16
+ }
17
+
18
+ export function nodeLocators(node: AppiumNode) {
19
+ return [
20
+ { name: 'Resource ID', attribute: 'resource-id', value: node.resourceId },
21
+ { name: 'Accessibility ID', attribute: 'content-desc', value: node.contentDesc },
22
+ { name: 'XPath', attribute: '', value: node.xpath },
23
+ { name: 'UiAutomator', attribute: '', value: uiAutomatorLocator(node), warning: !node.resourceId && !node.contentDesc && !node.text && node.className && node.className !== 'node' ? '仅按组件类型匹配,可能匹配多个元素' : '' },
24
+ ];
25
+ }
@@ -0,0 +1,50 @@
1
+ export type HistoryNode = {
2
+ key: string;
3
+ label: string;
4
+ failed: boolean;
5
+ durationMs: number | null;
6
+ };
7
+
8
+ export type RunSummary = {
9
+ id: string;
10
+ scriptId: string;
11
+ scriptName: string;
12
+ appPackage: string;
13
+ appVersion: string;
14
+ deviceId: string;
15
+ startedAt: string;
16
+ durationMs: number;
17
+ status: 'passed' | 'failed' | 'stopped';
18
+ nodes: HistoryNode[];
19
+ };
20
+
21
+ export type RunDetail = RunSummary & {
22
+ output: string;
23
+ frames: { key: string; label: string; phase: string; capturedAt: string; imageUrl: string }[];
24
+ };
25
+
26
+ // 分母只计算实际到达该节点的运行;未执行的分支不能当作通过。
27
+ export function historyStatistics(runs: RunSummary[]) {
28
+ const completed = runs.filter(run => run.status !== 'stopped');
29
+ const nodes = new Map<string, { key: string; label: string; runs: number; failures: number; firstFailure: string; version: string }>();
30
+ for (const run of [...completed].sort((a, b) => a.startedAt.localeCompare(b.startedAt))) {
31
+ const unique = new Map<string, HistoryNode>();
32
+ for (const node of run.nodes) {
33
+ const prior = unique.get(node.key);
34
+ unique.set(node.key, { ...node, failed: node.failed || Boolean(prior?.failed) });
35
+ }
36
+ for (const node of unique.values()) {
37
+ const item = nodes.get(node.key) || { key: node.key, label: node.label, runs: 0, failures: 0, firstFailure: '', version: '' };
38
+ item.runs++;
39
+ if (node.failed) {
40
+ item.failures++;
41
+ if (!item.firstFailure) { item.firstFailure = run.startedAt; item.version = run.appVersion; }
42
+ }
43
+ nodes.set(node.key, item);
44
+ }
45
+ }
46
+ return {
47
+ passRate: completed.length ? completed.filter(run => run.status === 'passed').length / completed.length : null,
48
+ failures: [...nodes.values()].filter(node => node.failures).sort((a, b) => b.failures - a.failures),
49
+ };
50
+ }
@@ -1,5 +1,6 @@
1
1
  import type { AppiumBounds, AppiumNode, AppiumSelector } from './types';
2
2
  import { parseNativeBoolean } from './native-control-state';
3
+ import { xpathStringLiteral } from './node-locators';
3
4
 
4
5
  type SelectorCounts = {
5
6
  contentDesc: Map<string, number>;
@@ -75,10 +76,6 @@ function elementChildren(element: Element) {
75
76
  return Array.from(element.children).filter((child) => child.tagName === 'node');
76
77
  }
77
78
 
78
- function escapeXpathValue(value: string) {
79
- return value.replace(/'/g, "&apos;");
80
- }
81
-
82
79
  function transformNode(
83
80
  element: Element,
84
81
  parentPath: string,
@@ -95,7 +92,7 @@ function transformNode(
95
92
  .slice(0, index + 1)
96
93
  .filter((sibling) => (attr(sibling, 'class') || sibling.tagName) === className).length;
97
94
  const xpath = resourceId && resourceIdCounts.get(resourceId) === 1
98
- ? `//*[@resource-id='${escapeXpathValue(resourceId)}']`
95
+ ? `//*[@resource-id=${xpathStringLiteral(resourceId)}]`
99
96
  : `${parentPath}/${className}[${sameClassIndex}]`;
100
97
  const selector = buildSelector(element, counts, bounds);
101
98
  const selfContextSelector: AppiumSelector = selector.unique && selector.strategy !== 'bounds'
@@ -90,6 +90,8 @@ export type AppiumRecordedStep = {
90
90
  | 'launchApp'
91
91
  | 'openGallery'
92
92
  | 'endFlow'
93
+ | 'loop'
94
+ | 'breakLoop'
93
95
  | 'clearAppData'
94
96
  | 'waitDisappear'
95
97
  | 'assertText'
@@ -110,6 +112,11 @@ export type AppiumRecordedStep = {
110
112
  keyCode?: number;
111
113
  timeoutMs?: number;
112
114
  longPressMode?: 'element' | 'coordinates';
115
+ breakLoopTargetId?: string;
116
+ loop?: {
117
+ maxIterations: number;
118
+ exitWhen: 'never' | 'exists' | 'notExists';
119
+ };
113
120
  flow?: {
114
121
  nodeKind?: 'action' | 'condition' | 'assertion';
115
122
  yesTargetId?: string;
@@ -93,10 +93,6 @@ export function branchPathForInsert(
93
93
  : [];
94
94
  }
95
95
 
96
- function sameBranchPath(left: string[], right: string[]) {
97
- return left.length === right.length && left.every((value, index) => value === right[index]);
98
- }
99
-
100
96
  function branchPathContains(parent: string[], child: string[]) {
101
97
  return parent.length <= child.length && parent.every((value, index) => value === child[index]);
102
98
  }
@@ -118,19 +114,21 @@ function isEndForStart(endStep: AppiumRecordedStep, startStep: AppiumRecordedSte
118
114
  );
119
115
  }
120
116
 
121
- export function findOpenVisualChangeStart(
117
+ export function findOpenVisualChangeStarts(
122
118
  steps: AppiumRecordedStep[],
123
119
  index?: number,
124
120
  branchTarget?: VisualChangeBranchTarget,
125
121
  ) {
126
122
  const insertPath = branchPathForInsert(steps, index, branchTarget);
127
123
  const insertIndex = typeof index === 'number' ? index : steps.length - 1;
128
- // A start marker may be closed once per concrete branch path. This allows a
129
- // root start before a condition to have independent yes/no end markers, while
130
- // preventing a yes-branch start from pairing with a no-branch end.
124
+ // 祖先或子分支中的结束节点可能与新节点在同一路径执行,不能重复关闭。
125
+ // 互斥的兄弟分支不包含彼此,仍允许分别结束同一个开始节点。
131
126
  const hasEndInInsertBranch = (startStep: AppiumRecordedStep) => steps.some((step) => (
132
127
  isEndForStart(step, startStep)
133
- && sameBranchPath(branchPathForStep(steps, step), insertPath)
128
+ && (
129
+ branchPathContains(branchPathForStep(steps, step), insertPath)
130
+ || branchPathContains(insertPath, branchPathForStep(steps, step))
131
+ )
134
132
  ));
135
133
 
136
134
  return steps
@@ -143,5 +141,13 @@ export function findOpenVisualChangeStart(
143
141
  ))
144
142
  .sort((left, right) => (
145
143
  right.path.length - left.path.length || right.stepIndex - left.stepIndex
146
- ))[0]?.step;
144
+ )).map(({ step }) => step);
145
+ }
146
+
147
+ export function findOpenVisualChangeStart(
148
+ steps: AppiumRecordedStep[],
149
+ index?: number,
150
+ branchTarget?: VisualChangeBranchTarget,
151
+ ) {
152
+ return findOpenVisualChangeStarts(steps, index, branchTarget)[0];
147
153
  }
@@ -1,6 +1,6 @@
1
1
  <script setup lang="ts">
2
- import { computed, shallowRef, watch } from 'vue';
3
- import { Cpu, Refresh } from '@element-plus/icons-vue';
2
+ import { computed, onBeforeUnmount, onMounted, ref, shallowRef, watch } from 'vue';
3
+ import { Close, Cpu, FullScreen, Refresh } from '@element-plus/icons-vue';
4
4
  import type { AndroidDevice, DeviceAction } from '../../types';
5
5
 
6
6
  type DeviceOverlayBounds = {
@@ -33,6 +33,8 @@ const props = defineProps<{
33
33
  regionDrawMode?: boolean;
34
34
  deviceWidth?: number;
35
35
  deviceHeight?: number;
36
+ compact?: boolean;
37
+ interactionDisabled?: boolean;
36
38
  }>();
37
39
 
38
40
  const emit = defineEmits<{
@@ -53,6 +55,20 @@ const emit = defineEmits<{
53
55
  }>();
54
56
 
55
57
  const imageSize = shallowRef({ width: 0, height: 0 });
58
+ const previewRef = ref<HTMLElement>();
59
+ const previewSize = ref({ width: 0, height: 0 });
60
+ const enlarged = ref(false);
61
+ let observer: ResizeObserver | undefined;
62
+ function closeOnEscape(event: KeyboardEvent) { if (event.key === 'Escape') enlarged.value = false; }
63
+ onMounted(() => {
64
+ if (!props.compact) return;
65
+ observer = new ResizeObserver(([entry]) => {
66
+ if (entry) previewSize.value = { width: entry.contentRect.width, height: entry.contentRect.height };
67
+ });
68
+ if (previewRef.value) observer.observe(previewRef.value);
69
+ window.addEventListener('keydown', closeOnEscape);
70
+ });
71
+ onBeforeUnmount(() => { observer?.disconnect(); window.removeEventListener('keydown', closeOnEscape); });
56
72
  let pointerSession: {
57
73
  mode: 'gesture' | 'region-draw' | 'region-move';
58
74
  pointerId: number;
@@ -64,9 +80,14 @@ let pointerSession: {
64
80
  const draftRegion = shallowRef<DeviceOverlayBounds | null>(null);
65
81
  const hasPreview = computed(() => Boolean(props.frameUrl || props.imageUrl));
66
82
  const imageBoxStyle = computed(() => {
67
- if (props.frameUrl) return {};
68
83
  const width = props.deviceWidth || imageSize.value.width;
69
84
  const height = props.deviceHeight || imageSize.value.height;
85
+ if (props.compact) {
86
+ const ratio = width && height ? width / height : 9 / 20;
87
+ const fittedWidth = Math.min(previewSize.value.width, previewSize.value.height * ratio);
88
+ return { width: `${fittedWidth}px`, height: `${fittedWidth / ratio}px` };
89
+ }
90
+ if (props.frameUrl) return {};
70
91
  return { aspectRatio: width && height ? `${width} / ${height}` : '9 / 20' };
71
92
  });
72
93
  const overlayViewBox = computed(() => {
@@ -184,6 +205,7 @@ function emitRegion(bounds: DeviceOverlayBounds) {
184
205
  }
185
206
 
186
207
  function handlePointerDown(event: PointerEvent) {
208
+ if (props.interactionDisabled && !props.regionSelection) return;
187
209
  if (event.pointerType === 'mouse' && event.button !== 0) return;
188
210
  const target = event.currentTarget as HTMLElement;
189
211
  const point = getDevicePoint(target, event.clientX, event.clientY);
@@ -268,22 +290,28 @@ function handlePointerCancel(event: PointerEvent) {
268
290
  </script>
269
291
 
270
292
  <template>
271
- <el-card shadow="never" class="automation-card device-preview-card">
293
+ <el-card shadow="never" class="automation-card device-preview-card" :class="{ 'device-preview-card--compact': compact, 'device-preview-card--enlarged': enlarged }">
272
294
  <template #header>
273
295
  <div class="panel-header">
274
296
  <span>设备预览</span>
275
297
  <div class="device-status">
298
+ <el-tag v-if="compact" :type="available ? 'success' : 'info'">
299
+ {{ available ? '已连接' : '未连接' }}
300
+ </el-tag>
301
+ <el-tooltip v-if="compact" :content="enlarged ? '退出设备放大' : '放大设备预览'" placement="top" :show-after="200"><el-button class="recorder-panel-tool" size="small" :icon="enlarged ? Close : FullScreen" :aria-label="enlarged ? '退出设备放大' : '放大设备预览'" @click="enlarged = !enlarged" /></el-tooltip>
302
+ <el-tooltip content="刷新画面" placement="top" :show-after="200">
276
303
  <el-button
277
- text
278
- circle
304
+ :text="!compact"
305
+ :circle="!compact"
306
+ :class="{ 'recorder-panel-tool': compact }"
307
+ :size="compact ? 'small' : 'default'"
279
308
  :icon="Refresh"
280
309
  :disabled="!selectedDeviceId"
281
- title="刷新画面"
310
+ aria-label="刷新画面"
282
311
  @click="emit('refreshPreview')"
283
312
  />
284
- <el-tag :type="available ? 'success' : 'info'">
285
- {{ available ? 'ADB 已连接' : '未检测到设备' }}
286
- </el-tag>
313
+ </el-tooltip>
314
+ <el-tag v-if="!compact" :type="available ? 'success' : 'info'">{{ available ? 'ADB 已连接' : '未检测到设备' }}</el-tag>
287
315
  </div>
288
316
  </div>
289
317
  </template>
@@ -310,6 +338,8 @@ function handlePointerCancel(event: PointerEvent) {
310
338
  :key="action.key"
311
339
  type="button"
312
340
  class="device-action-button"
341
+ :disabled="interactionDisabled || regionSelection"
342
+ :aria-label="action.label"
313
343
  @click="emit('triggerKey', action.keyCode)"
314
344
  >
315
345
  <img :src="action.icon" :alt="action.label" class="device-action-button__icon" />
@@ -318,6 +348,7 @@ function handlePointerCancel(event: PointerEvent) {
318
348
  </div>
319
349
 
320
350
  <div
351
+ ref="previewRef"
321
352
  class="device-preview"
322
353
  :class="{
323
354
  'device-preview--image': imageUrl && !frameUrl,
@@ -411,3 +442,24 @@ function handlePointerCancel(event: PointerEvent) {
411
442
  </div>
412
443
  </el-card>
413
444
  </template>
445
+
446
+ <style scoped>
447
+ .device-preview-card--compact { display: flex; flex-direction: column; min-height: 0; }
448
+ .device-preview-card--compact :deep(> .el-card__body) { display: flex; flex-direction: column; flex: 1; min-height: 0; overflow: hidden; gap: 8px; }
449
+ .device-preview-card--compact .panel-header { flex-wrap: wrap; gap: 8px; font-size: 14px; }
450
+ .device-preview-card--compact .device-status { gap: 8px; margin-left: auto; }
451
+ .device-preview-card--compact .device-status .el-tag { font-size: 11px; padding: 0 4px; }
452
+ .device-preview-card--compact .device-toolbar, .device-preview-card--compact .device-actions { flex: none; margin: 0; }
453
+ .device-preview-card--compact .device-toolbar { display: flex; padding: 0; }
454
+ .device-preview-card--compact .device-select { width: 100%; min-width: 0; }
455
+ .device-preview-card--compact .device-actions { display: flex; flex-wrap: wrap; justify-content: center; gap: 4px; padding: 0; }
456
+ .device-preview-card--compact .device-actions::before { display: none; }
457
+ .device-preview-card--compact .device-action-button { margin: 0; width: 30px; height: 30px; }
458
+ .device-preview-card--compact .device-action-button:disabled { opacity: .45; cursor: not-allowed; }
459
+ .device-preview-card--compact .device-preview { flex: 1; height: auto; min-height: 0; }
460
+ .device-preview-card--compact .device-preview__empty { display: flex; flex-direction: column; justify-content: center; align-items: center; gap: 8px; }
461
+ .device-preview-card--compact.device-preview-card--enlarged { position: fixed; inset: 24px; z-index: 2100; margin: 0; box-shadow: 0 0 0 100vmax #0007; }
462
+ .device-preview-card--enlarged :deep(.el-card__header) { padding-right: 12px; }
463
+ .device-preview-card--enlarged .device-toolbar { max-width: 420px; align-self: center; width: 100%; }
464
+ @media (max-width: 600px) { .device-preview-card--compact.device-preview-card--enlarged { inset: 12px; } }
465
+ </style>