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.
- package/CHANGELOG.md +16 -26
- package/README.md +16 -26
- package/bin/sync-readme-docs.mjs +49 -0
- package/index.html +1 -1
- package/package.json +8 -8
- package/remote-agent/index.ts +1 -1
- package/server/appium-recorder/appium-runner.ts +97 -43
- package/server/appium-recorder/bounded-loop.ts +118 -0
- package/server/appium-recorder/history-capture.ts +34 -0
- package/server/appium-recorder/report.ts +1 -1
- package/server/appium-recorder/repository.ts +9 -0
- package/server/appium-recorder/routes.ts +29 -5
- package/server/appium-recorder/run-history.ts +34 -0
- package/src/appium-recorder/AppiumPage.vue +114 -54
- package/src/appium-recorder/RunHistoryDialog.vue +153 -0
- package/src/appium-recorder/bounded-loop.ts +66 -0
- package/src/appium-recorder/components/BreakLoopSettings.vue +29 -0
- package/src/appium-recorder/components/ComponentInspector.vue +26 -0
- package/src/appium-recorder/components/ComponentTree.vue +24 -4
- package/src/appium-recorder/components/FlowCanvas.vue +19 -1
- package/src/appium-recorder/components/FlowNodeCard.vue +6 -3
- package/src/appium-recorder/components/FlowNodeSearch.vue +62 -0
- package/src/appium-recorder/components/FlowStepEditor.vue +9 -4
- package/src/appium-recorder/components/LoopSettings.vue +37 -0
- package/src/appium-recorder/components/NestedConditionBranches.vue +11 -3
- package/src/appium-recorder/components/NodeDetail.vue +30 -6
- package/src/appium-recorder/components/RecordedSteps.vue +24 -4
- package/src/appium-recorder/components/RecorderWorkspace.vue +132 -0
- package/src/appium-recorder/components/VisualChangeEndPicker.vue +21 -0
- package/src/appium-recorder/flow-copy.ts +1 -0
- package/src/appium-recorder/flow-graph.ts +4 -9
- package/src/appium-recorder/flow-labels.ts +7 -0
- package/src/appium-recorder/flow-merge.ts +3 -2
- package/src/appium-recorder/flow-normalize.ts +1 -0
- package/src/appium-recorder/flow-search.ts +29 -0
- package/src/appium-recorder/node-locators.ts +25 -0
- package/src/appium-recorder/run-history.ts +50 -0
- package/src/appium-recorder/tree.ts +2 -5
- package/src/appium-recorder/types.ts +7 -0
- package/src/appium-recorder/visual-change-pairs.ts +16 -10
- package/src/components/device/DevicePreviewPanel.vue +62 -10
- package/src/style.css +24 -7
- 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
|
-
:
|
|
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
|
|
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, "'");
|
|
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
|
|
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
|
|
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
|
-
//
|
|
129
|
-
//
|
|
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
|
-
&&
|
|
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
|
-
))
|
|
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
|
-
|
|
310
|
+
aria-label="刷新画面"
|
|
282
311
|
@click="emit('refreshPreview')"
|
|
283
312
|
/>
|
|
284
|
-
|
|
285
|
-
|
|
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>
|