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
|
@@ -2,9 +2,14 @@
|
|
|
2
2
|
import { computed, h, onMounted, onUnmounted, provide, reactive, shallowRef, watch } from 'vue';
|
|
3
3
|
import { flowBackgroundKey, normalizeFlowBackground } from './flow-appearance';
|
|
4
4
|
import { ElForm, ElFormItem, ElInputNumber, ElMessage, ElMessageBox, ElOption, ElSelect } from 'element-plus';
|
|
5
|
-
import { Check, CircleClose, CopyDocument, Delete, Document, Download, Edit, Plus, Refresh, Upload, VideoPlay, View } from '@element-plus/icons-vue';
|
|
5
|
+
import { Check, CircleClose, CopyDocument, Delete, Document, Download, Edit, Plus, Refresh, Upload, VideoPlay, View, Clock } from '@element-plus/icons-vue';
|
|
6
|
+
import RunHistoryDialog from './RunHistoryDialog.vue';
|
|
6
7
|
import type { AndroidDevice, AppPreset, DeviceAction } from '../types';
|
|
7
8
|
import DevicePreviewPanel from '../components/device/DevicePreviewPanel.vue';
|
|
9
|
+
import LoopSettings from './components/LoopSettings.vue';
|
|
10
|
+
import BreakLoopSettings from './components/BreakLoopSettings.vue';
|
|
11
|
+
import VisualChangeEndPicker from './components/VisualChangeEndPicker.vue';
|
|
12
|
+
import { defaultLoopConfig, enclosingLoops, breakLoopTarget, validateLoop, validateLoopSteps } from './bounded-loop';
|
|
8
13
|
import {
|
|
9
14
|
clearAppiumDeviceAppData,
|
|
10
15
|
deleteAppiumScript,
|
|
@@ -18,9 +23,9 @@ import {
|
|
|
18
23
|
stopAppiumReplay,
|
|
19
24
|
tapAppiumDevice,
|
|
20
25
|
} from './api';
|
|
21
|
-
import
|
|
26
|
+
import ComponentInspector from './components/ComponentInspector.vue';
|
|
27
|
+
import RecorderWorkspace from './components/RecorderWorkspace.vue';
|
|
22
28
|
import FlowCanvas from './components/FlowCanvas.vue';
|
|
23
|
-
import NodeDetail from './components/NodeDetail.vue';
|
|
24
29
|
import LongPressSettings from './components/LongPressSettings.vue';
|
|
25
30
|
import StageLogSettings from './components/StageLogSettings.vue';
|
|
26
31
|
import TextClickSettings from './components/TextClickSettings.vue';
|
|
@@ -50,7 +55,7 @@ import {
|
|
|
50
55
|
visualRegionToBounds,
|
|
51
56
|
} from './visual-change';
|
|
52
57
|
import {
|
|
53
|
-
|
|
58
|
+
findOpenVisualChangeStarts,
|
|
54
59
|
nextVisualChangePairLabel,
|
|
55
60
|
} from './visual-change-pairs';
|
|
56
61
|
import type {
|
|
@@ -76,6 +81,8 @@ type BranchTarget = {
|
|
|
76
81
|
type RecorderAction = InsertAction;
|
|
77
82
|
|
|
78
83
|
const WORKBENCH_TAB_STORAGE_KEY = 'android-midscene-automation:appium-workbench-tab';
|
|
84
|
+
const workspaceRef = shallowRef<InstanceType<typeof RecorderWorkspace>>();
|
|
85
|
+
const historyScript = shallowRef<{ id: string; name: string } | null>(null);
|
|
79
86
|
const AUTO_TREE_REFRESH_INTERVAL_MS = 1800;
|
|
80
87
|
|
|
81
88
|
const insertableRecorderActions: RecorderAction[] = [
|
|
@@ -106,6 +113,8 @@ const insertableRecorderActions: RecorderAction[] = [
|
|
|
106
113
|
'launchApp',
|
|
107
114
|
'openGallery',
|
|
108
115
|
'endFlow',
|
|
116
|
+
'loop',
|
|
117
|
+
'breakLoop',
|
|
109
118
|
'clearAppData',
|
|
110
119
|
'waitDisappear',
|
|
111
120
|
'assertText',
|
|
@@ -357,6 +366,7 @@ function pasteFlowNodes(index: number, branch?: BranchName) {
|
|
|
357
366
|
afterIndex: index,
|
|
358
367
|
branch,
|
|
359
368
|
});
|
|
369
|
+
validateLoopSteps(nextSteps);
|
|
360
370
|
steps.value = nextSteps;
|
|
361
371
|
ElMessage.success(`已粘贴 ${flowClipboardCount.value} 个节点${branch ? `到${branch === 'yes' ? '是' : '否'}分支` : ''}`);
|
|
362
372
|
flowClipboard.value = null;
|
|
@@ -916,6 +926,7 @@ function selectNodeFromPoint(point: { x: number; y: number }) {
|
|
|
916
926
|
const node = findSmallestNodeAtPoint(tree.value, point.x, point.y);
|
|
917
927
|
if (node) {
|
|
918
928
|
selectedNode.value = node;
|
|
929
|
+
workspaceRef.value?.showTree();
|
|
919
930
|
return;
|
|
920
931
|
}
|
|
921
932
|
ElMessage.warning('未命中组件树节点');
|
|
@@ -928,6 +939,7 @@ async function swipePreview(gesture: {
|
|
|
928
939
|
endY: number;
|
|
929
940
|
duration: number;
|
|
930
941
|
}) {
|
|
942
|
+
if (recordingBusy.value || replaying.value) return;
|
|
931
943
|
if (!selectedDeviceId.value) return;
|
|
932
944
|
try {
|
|
933
945
|
await props.swipeDevice(
|
|
@@ -1208,6 +1220,7 @@ function pickVisualChangeRegion() {
|
|
|
1208
1220
|
mode: 'region',
|
|
1209
1221
|
});
|
|
1210
1222
|
visualChangePicking.value = true;
|
|
1223
|
+
workspaceRef.value?.showPreview();
|
|
1211
1224
|
ElMessage.info('请在设备预览中拖拽框选检测区域');
|
|
1212
1225
|
}
|
|
1213
1226
|
|
|
@@ -1256,11 +1269,6 @@ async function addAction(
|
|
|
1256
1269
|
) {
|
|
1257
1270
|
if (recordingBusy.value) return;
|
|
1258
1271
|
if (!ensureAppPackageSelected()) return;
|
|
1259
|
-
const previousStep = typeof index === 'number' && index >= 0 ? steps.value[index] : undefined;
|
|
1260
|
-
if (action === 'launchApp' && steps.value.some((step) => step.type === 'launchApp')) {
|
|
1261
|
-
ElMessage.warning('启动 APP 节点只能添加一个');
|
|
1262
|
-
return;
|
|
1263
|
-
}
|
|
1264
1272
|
if (action === 'clearAppData' && steps.value.some((step) => step.type === 'clearAppData')) {
|
|
1265
1273
|
ElMessage.warning('清理 App 缓存节点只能添加一个');
|
|
1266
1274
|
return;
|
|
@@ -1269,10 +1277,6 @@ async function addAction(
|
|
|
1269
1277
|
ElMessage.warning('清理 App 缓存只能从开始节点添加');
|
|
1270
1278
|
return;
|
|
1271
1279
|
}
|
|
1272
|
-
if (action === 'launchApp' && index !== -1 && previousStep?.type !== 'clearAppData') {
|
|
1273
|
-
ElMessage.warning('启动 APP 只能从开始节点或清理 App 缓存后添加');
|
|
1274
|
-
return;
|
|
1275
|
-
}
|
|
1276
1280
|
if (action === 'runScript') {
|
|
1277
1281
|
openLinkedScriptDialog(index, branchTarget);
|
|
1278
1282
|
return;
|
|
@@ -1286,6 +1290,46 @@ async function addAction(
|
|
|
1286
1290
|
if (action === 'endFlow') {
|
|
1287
1291
|
return insertStep({ id: createStepId(), type: 'endFlow', label: '终止流程', flow: { nodeKind: 'action' } }, index, branchTarget);
|
|
1288
1292
|
}
|
|
1293
|
+
if (action === 'loop') {
|
|
1294
|
+
const step = reactive<AppiumRecordedStep>({
|
|
1295
|
+
id: createStepId(), type: 'loop', label: '有界循环', loop: defaultLoopConfig(),
|
|
1296
|
+
selector: selectedNode.value?.selector ? { ...selectedNode.value.selector } : { strategy: 'id', value: '' },
|
|
1297
|
+
flow: { nodeKind: 'condition' },
|
|
1298
|
+
});
|
|
1299
|
+
const result = await ElMessageBox({
|
|
1300
|
+
title: '添加有界循环',
|
|
1301
|
+
message: h(ElForm, { labelPosition: 'top' }, () => h(LoopSettings, { step, onUpdate: (patch) => Object.assign(step, patch) })),
|
|
1302
|
+
showCancelButton: true, confirmButtonText: '添加', cancelButtonText: '取消',
|
|
1303
|
+
beforeClose: (action, _instance, done) => {
|
|
1304
|
+
if (action === 'confirm') {
|
|
1305
|
+
try { validateLoop(step); } catch (error) { ElMessage.warning((error as Error).message); return; }
|
|
1306
|
+
}
|
|
1307
|
+
done();
|
|
1308
|
+
},
|
|
1309
|
+
}).catch(() => null);
|
|
1310
|
+
if (result) return insertStep({ ...step, loop: { ...step.loop! }, selector: { ...step.selector! } }, index, branchTarget);
|
|
1311
|
+
return;
|
|
1312
|
+
}
|
|
1313
|
+
if (action === 'breakLoop') {
|
|
1314
|
+
const step: AppiumRecordedStep = { id: createStepId(), type: 'breakLoop', label: '退出循环', flow: { nodeKind: 'action' } };
|
|
1315
|
+
const scoped = reactive(branchTarget ? stepWithBranchTarget(step, branchTarget) : step);
|
|
1316
|
+
const loops = enclosingLoops(steps.value, scoped);
|
|
1317
|
+
if (!loops.length) { ElMessage.warning('退出循环只能添加在循环体内'); return; }
|
|
1318
|
+
scoped.breakLoopTargetId = loops[0]!.id;
|
|
1319
|
+
const result = await ElMessageBox({
|
|
1320
|
+
title: '添加退出循环',
|
|
1321
|
+
message: h(ElForm, { labelPosition: 'top' }, () => h(BreakLoopSettings, { step: scoped, steps: steps.value, onUpdate: (patch) => Object.assign(scoped, patch) })),
|
|
1322
|
+
showCancelButton: true, confirmButtonText: '添加', cancelButtonText: '取消',
|
|
1323
|
+
beforeClose: (action, _instance, done) => {
|
|
1324
|
+
if (action === 'confirm') {
|
|
1325
|
+
try { breakLoopTarget(steps.value, scoped); } catch (error) { ElMessage.warning((error as Error).message); return; }
|
|
1326
|
+
}
|
|
1327
|
+
done();
|
|
1328
|
+
},
|
|
1329
|
+
}).catch(() => null);
|
|
1330
|
+
if (result) return insertStep({ ...step, breakLoopTargetId: scoped.breakLoopTargetId }, index, branchTarget);
|
|
1331
|
+
return;
|
|
1332
|
+
}
|
|
1289
1333
|
if (action === 'aiRecognition') {
|
|
1290
1334
|
const input = await ElMessageBox.prompt('识别内容', '添加 AI 识别', {
|
|
1291
1335
|
inputType: 'textarea',
|
|
@@ -1356,11 +1400,31 @@ async function addAction(
|
|
|
1356
1400
|
return;
|
|
1357
1401
|
}
|
|
1358
1402
|
if (action === 'visualChangeEnd') {
|
|
1359
|
-
const
|
|
1360
|
-
if (!
|
|
1403
|
+
const candidates = findOpenVisualChangeStarts(steps.value, index, branchTarget);
|
|
1404
|
+
if (!candidates.length) {
|
|
1361
1405
|
ElMessage.warning('当前分支没有可对应的检测画面变化开始节点');
|
|
1362
1406
|
return;
|
|
1363
1407
|
}
|
|
1408
|
+
let selectedId = candidates.length === 1 ? candidates[0].id : '';
|
|
1409
|
+
if (candidates.length > 1) {
|
|
1410
|
+
selectedId = await new Promise<string>((resolve) => { void ElMessageBox({
|
|
1411
|
+
title: '选择要结束的画面变化检测',
|
|
1412
|
+
showConfirmButton: false,
|
|
1413
|
+
showCancelButton: false,
|
|
1414
|
+
callback: () => resolve(''),
|
|
1415
|
+
message: h(VisualChangeEndPicker, {
|
|
1416
|
+
candidates: candidates.map(step => ({ id: step.id, label: step.visualChange?.pairLabel || step.label, number: steps.value.findIndex(item => item.id === step.id) + 1 })),
|
|
1417
|
+
onSelect: (id: string) => { resolve(id); ElMessageBox.close(); },
|
|
1418
|
+
}),
|
|
1419
|
+
}); });
|
|
1420
|
+
if (!selectedId) return;
|
|
1421
|
+
}
|
|
1422
|
+
// 选择期间流程可能发生变化;插入前重新检查配对,不能使用过期候选。
|
|
1423
|
+
const startStep = findOpenVisualChangeStarts(steps.value, index, branchTarget).find(step => step.id === selectedId);
|
|
1424
|
+
if (!startStep) {
|
|
1425
|
+
ElMessage.warning('该开始节点已结束或不再属于当前路径,请重新选择');
|
|
1426
|
+
return;
|
|
1427
|
+
}
|
|
1364
1428
|
const inserted = insertStep(createVisualChangeEndStep(startStep), index, branchTarget);
|
|
1365
1429
|
ElMessage.success(`已添加 ${inserted.label}`);
|
|
1366
1430
|
return inserted;
|
|
@@ -1410,14 +1474,12 @@ async function addAction(
|
|
|
1410
1474
|
return;
|
|
1411
1475
|
}
|
|
1412
1476
|
if (action === 'launchApp') {
|
|
1413
|
-
const clearStepIndex = steps.value.findIndex((step) => step.type === 'clearAppData');
|
|
1414
|
-
const insertIndex = previousStep?.type === 'clearAppData' ? index : clearStepIndex >= 0 ? clearStepIndex : index;
|
|
1415
1477
|
return insertStep({
|
|
1416
1478
|
id: `step_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`,
|
|
1417
1479
|
type: 'launchApp',
|
|
1418
1480
|
label: `启动 APP ${form.appPackage}`,
|
|
1419
1481
|
value: form.appPackage,
|
|
1420
|
-
},
|
|
1482
|
+
}, index, branchTarget);
|
|
1421
1483
|
}
|
|
1422
1484
|
if (action === 'clearAppData') {
|
|
1423
1485
|
return insertStep({
|
|
@@ -2027,8 +2089,11 @@ watch(
|
|
|
2027
2089
|
</el-button>
|
|
2028
2090
|
</div>
|
|
2029
2091
|
|
|
2030
|
-
<
|
|
2092
|
+
<RecorderWorkspace ref="workspaceRef">
|
|
2093
|
+
<template #preview>
|
|
2031
2094
|
<DevicePreviewPanel
|
|
2095
|
+
compact
|
|
2096
|
+
:interaction-disabled="recordingBusy || replaying"
|
|
2032
2097
|
:available="playgroundAvailable"
|
|
2033
2098
|
:devices="androidDevices"
|
|
2034
2099
|
:selected-device-id="selectedDeviceId"
|
|
@@ -2050,27 +2115,12 @@ watch(
|
|
|
2050
2115
|
@region-select="applyVisualChangeRegion"
|
|
2051
2116
|
@swipe="swipePreview"
|
|
2052
2117
|
/>
|
|
2053
|
-
|
|
2054
|
-
<
|
|
2055
|
-
<
|
|
2056
|
-
|
|
2057
|
-
<span>App 组件树</span>
|
|
2058
|
-
<el-button :icon="Refresh" :loading="loadingTree" :disabled="replaying" @click="refreshTree">
|
|
2059
|
-
刷新组件树
|
|
2060
|
-
</el-button>
|
|
2061
|
-
</div>
|
|
2062
|
-
</template>
|
|
2063
|
-
<div class="appium-tree-panel">
|
|
2064
|
-
<ComponentTree :tree="tree" :selected-id="selectedNode?.id || ''" @select="selectedNode = $event" />
|
|
2065
|
-
<div class="appium-current-activity">
|
|
2066
|
-
<span>当前 Activity</span>
|
|
2067
|
-
<code>{{ currentActivity || '-' }}</code>
|
|
2068
|
-
</div>
|
|
2069
|
-
</div>
|
|
2070
|
-
</el-card>
|
|
2118
|
+
</template>
|
|
2119
|
+
<template #tree>
|
|
2120
|
+
<ComponentInspector :tree="tree" :node="selectedNode" :activity="currentActivity" :loading="loadingTree" :disabled="replaying" :recording="recordingTap" @refresh="refreshTree" @select="selectedNode = $event" />
|
|
2121
|
+
</template>
|
|
2071
2122
|
|
|
2072
2123
|
<el-card shadow="never" class="appium-recorder-card appium-recorder-card--workbench">
|
|
2073
|
-
<template #header>录制与脚本</template>
|
|
2074
2124
|
<el-tabs
|
|
2075
2125
|
:model-value="activeWorkbenchTab"
|
|
2076
2126
|
class="appium-workbench-tabs"
|
|
@@ -2112,15 +2162,9 @@ watch(
|
|
|
2112
2162
|
<p>请在录制流程中添加或执行“启动 APP”节点;Activity 匹配后会自动解除编辑锁定。</p>
|
|
2113
2163
|
</div>
|
|
2114
2164
|
</details>
|
|
2115
|
-
<NodeDetail
|
|
2116
|
-
:node="selectedNode"
|
|
2117
|
-
:current-activity="currentActivity"
|
|
2118
|
-
:loading="recordingTap"
|
|
2119
|
-
/>
|
|
2120
2165
|
</section>
|
|
2121
2166
|
|
|
2122
2167
|
<section class="appium-workbench__section appium-workbench__flow">
|
|
2123
|
-
<h3>录制步骤</h3>
|
|
2124
2168
|
<RecordedSteps
|
|
2125
2169
|
:key="newScriptRevision"
|
|
2126
2170
|
:ai-recognition-model-configured="aiRecognitionModelConfigured"
|
|
@@ -2151,9 +2195,9 @@ watch(
|
|
|
2151
2195
|
<el-tab-pane label="脚本列表" name="scripts">
|
|
2152
2196
|
<div class="appium-script-list">
|
|
2153
2197
|
<div class="appium-script-list__toolbar">
|
|
2154
|
-
<el-
|
|
2155
|
-
导入脚本
|
|
2156
|
-
</el-
|
|
2198
|
+
<el-tooltip content="导入脚本" placement="top" :show-after="200"><span class="appium-script-icon">
|
|
2199
|
+
<el-button size="small" :icon="Upload" aria-label="导入脚本" :loading="importingScript" @click="chooseScriptImportFile" />
|
|
2200
|
+
</span></el-tooltip>
|
|
2157
2201
|
<input
|
|
2158
2202
|
ref="scriptImportInput"
|
|
2159
2203
|
class="appium-script-list__file-input"
|
|
@@ -2176,37 +2220,50 @@ watch(
|
|
|
2176
2220
|
<small>{{ script.steps.length }} 步 · {{ formatScriptTime(script.updatedAt) }}</small>
|
|
2177
2221
|
</div>
|
|
2178
2222
|
<div class="appium-script-list__actions">
|
|
2179
|
-
<el-
|
|
2223
|
+
<el-tooltip content="历史结果对比" placement="top" :show-after="200"><span class="appium-script-icon">
|
|
2224
|
+
<el-button size="small" :icon="Clock" aria-label="历史结果对比" @click.stop="historyScript = { id: script.id, name: script.name }" />
|
|
2225
|
+
</span></el-tooltip>
|
|
2226
|
+
<el-tooltip content="加载" placement="top" :show-after="200"><span class="appium-script-icon">
|
|
2227
|
+
<el-button size="small" :icon="Document" aria-label="加载" @click.stop="loadScript(script)" />
|
|
2228
|
+
</span></el-tooltip>
|
|
2229
|
+
<el-tooltip content="修改脚本名称" placement="top" :show-after="200"><span class="appium-script-icon">
|
|
2180
2230
|
<el-button
|
|
2181
2231
|
size="small"
|
|
2182
2232
|
:icon="Edit"
|
|
2183
2233
|
:loading="renamingScriptId === script.id"
|
|
2184
|
-
|
|
2234
|
+
aria-label="修改脚本名称"
|
|
2185
2235
|
@click.stop="renameScript(script)"
|
|
2186
2236
|
/>
|
|
2237
|
+
</span></el-tooltip>
|
|
2238
|
+
<el-tooltip content="复制脚本" placement="top" :show-after="200"><span class="appium-script-icon">
|
|
2187
2239
|
<el-button
|
|
2188
2240
|
size="small"
|
|
2189
2241
|
:icon="CopyDocument"
|
|
2190
2242
|
:loading="duplicatingScriptId === script.id"
|
|
2191
|
-
|
|
2243
|
+
aria-label="复制脚本"
|
|
2192
2244
|
@click.stop="duplicateScript(script)"
|
|
2193
2245
|
/>
|
|
2246
|
+
</span></el-tooltip>
|
|
2247
|
+
<el-tooltip content="下载脚本" placement="top" :show-after="200"><span class="appium-script-icon">
|
|
2194
2248
|
<el-button
|
|
2195
2249
|
size="small"
|
|
2196
2250
|
:icon="Download"
|
|
2197
2251
|
:loading="downloadingScriptId === script.id"
|
|
2198
2252
|
:disabled="Boolean(downloadingScriptId) && downloadingScriptId !== script.id"
|
|
2199
|
-
|
|
2253
|
+
aria-label="下载脚本"
|
|
2200
2254
|
@click.stop="downloadScript(script)"
|
|
2201
2255
|
/>
|
|
2256
|
+
</span></el-tooltip>
|
|
2257
|
+
<el-tooltip content="删除脚本" placement="top" :show-after="200"><span class="appium-script-icon">
|
|
2202
2258
|
<el-button
|
|
2203
2259
|
size="small"
|
|
2204
2260
|
type="danger"
|
|
2205
2261
|
:icon="Delete"
|
|
2206
2262
|
:loading="deletingScriptId === script.id"
|
|
2207
|
-
|
|
2263
|
+
aria-label="删除脚本"
|
|
2208
2264
|
@click.stop="removeScript(script)"
|
|
2209
2265
|
/>
|
|
2266
|
+
</span></el-tooltip>
|
|
2210
2267
|
</div>
|
|
2211
2268
|
</article>
|
|
2212
2269
|
</template>
|
|
@@ -2214,8 +2271,9 @@ watch(
|
|
|
2214
2271
|
</el-tab-pane>
|
|
2215
2272
|
</el-tabs>
|
|
2216
2273
|
</el-card>
|
|
2217
|
-
</
|
|
2274
|
+
</RecorderWorkspace>
|
|
2218
2275
|
|
|
2276
|
+
<RunHistoryDialog v-if="historyScript" :script-id="historyScript.id" :script-name="historyScript.name" @close="historyScript = null" />
|
|
2219
2277
|
<NewScriptDialog
|
|
2220
2278
|
v-if="newScriptDialogVisible"
|
|
2221
2279
|
:script-name="form.name"
|
|
@@ -2402,13 +2460,15 @@ watch(
|
|
|
2402
2460
|
</template>
|
|
2403
2461
|
|
|
2404
2462
|
<style scoped>
|
|
2463
|
+
.appium-script-icon { display: inline-flex; }
|
|
2464
|
+
.appium-script-icon .el-button { width: 30px; height: 30px; min-height: 30px; padding: 0; margin: 0; }
|
|
2405
2465
|
.appium-activity-summary { margin: 8px 0; color: #946000; font-size: 12px; }
|
|
2406
2466
|
.appium-activity-summary summary { cursor: pointer; line-height: 20px; }
|
|
2407
2467
|
.appium-activity-summary[open] .appium-activity-lock-alert__details { max-height: 110px; overflow: auto; margin-top: 6px; }
|
|
2408
2468
|
:deep(.appium-recorder-card--workbench > .el-card__body) { overflow: hidden; }
|
|
2409
2469
|
:deep(.appium-workbench-tabs > .el-tabs__content) { overflow: hidden; }
|
|
2410
2470
|
:deep(.appium-workbench-tabs .el-tab-pane) { height: 100%; overflow: auto; }
|
|
2411
|
-
.appium-workbench { height: 100%; box-sizing: border-box; grid-template-rows: auto minmax(
|
|
2471
|
+
.appium-workbench { height: 100%; box-sizing: border-box; grid-template-rows: auto minmax(0, 1fr); gap: 10px; overflow: hidden; padding: 12px; }
|
|
2412
2472
|
.appium-workbench__flow { display: flex; flex-direction: column; min-height: 0; }
|
|
2413
2473
|
.appium-workbench__flow h3 { margin-bottom: 6px; }
|
|
2414
2474
|
.appium-workbench__flow :deep(.appium-recorded-steps-panel) { display: flex; flex-direction: column; flex: 1; min-height: 0; }
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed, onMounted, ref } from 'vue';
|
|
3
|
+
import { Delete, Refresh } from '@element-plus/icons-vue';
|
|
4
|
+
import { ElMessage, ElMessageBox } from 'element-plus';
|
|
5
|
+
import { historyStatistics, type RunSummary, type RunDetail } from './run-history';
|
|
6
|
+
|
|
7
|
+
const props = defineProps<{ scriptId: string; scriptName: string }>();
|
|
8
|
+
const emit = defineEmits<{ close: [] }>();
|
|
9
|
+
const runs = ref<RunSummary[]>([]);
|
|
10
|
+
const selected = ref<RunSummary[]>([]);
|
|
11
|
+
const details = ref<RunDetail[]>([]);
|
|
12
|
+
const loading = ref(false);
|
|
13
|
+
const comparing = ref(false);
|
|
14
|
+
const version = ref('');
|
|
15
|
+
const device = ref('');
|
|
16
|
+
const error = ref('');
|
|
17
|
+
const nodeKey = ref('');
|
|
18
|
+
const logQuery = ref('');
|
|
19
|
+
const base = `/api/appium-recorder/scripts/${encodeURIComponent(props.scriptId)}/history`;
|
|
20
|
+
const labels = { passed: '通过', failed: '失败', stopped: '已终止' };
|
|
21
|
+
const versions = computed(() => [...new Set(runs.value.map(run => run.appVersion || '未知'))]);
|
|
22
|
+
const devices = computed(() => [...new Set(runs.value.map(run => run.deviceId))]);
|
|
23
|
+
const filtered = computed(() => runs.value.filter(run => (!version.value || (run.appVersion || '未知') === version.value) && (!device.value || run.deviceId === device.value)));
|
|
24
|
+
const stats = computed(() => historyStatistics(filtered.value));
|
|
25
|
+
const trend = computed(() => [...filtered.value].sort((a, b) => a.startedAt.localeCompare(b.startedAt)));
|
|
26
|
+
const maxDuration = computed(() => Math.max(1, ...trend.value.map(run => run.durationMs)));
|
|
27
|
+
const points = computed(() => trend.value.map((run, index) => `${20 + index * 760 / Math.max(1, trend.value.length - 1)},${130 - run.durationMs / maxDuration.value * 110}`).join(' '));
|
|
28
|
+
const comparisonNodes = computed(() => [...new Map(details.value.flatMap(run => run.nodes.map(node => [node.key, node] as const))).values()]);
|
|
29
|
+
const time = (value: string) => new Date(value).toLocaleString('zh-CN', { hour12: false });
|
|
30
|
+
const seconds = (value: number) => `${(value / 1000).toFixed(2)}s`;
|
|
31
|
+
|
|
32
|
+
async function request<T>(url: string, method = 'GET'): Promise<T> {
|
|
33
|
+
const response = await fetch(url, { method });
|
|
34
|
+
if (!response.ok) throw new Error('历史结果请求失败');
|
|
35
|
+
return response.json();
|
|
36
|
+
}
|
|
37
|
+
async function load() {
|
|
38
|
+
loading.value = true; error.value = '';
|
|
39
|
+
try { runs.value = await request<RunSummary[]>(base); selected.value = []; }
|
|
40
|
+
catch (cause) { error.value = String(cause); }
|
|
41
|
+
finally { loading.value = false; }
|
|
42
|
+
}
|
|
43
|
+
async function compare() {
|
|
44
|
+
comparing.value = true;
|
|
45
|
+
try {
|
|
46
|
+
details.value = await Promise.all([...selected.value].sort((a, b) => a.startedAt.localeCompare(b.startedAt)).map(run => request<RunDetail>(`${base}/${encodeURIComponent(run.id)}`)));
|
|
47
|
+
nodeKey.value = '';
|
|
48
|
+
} catch (cause) { ElMessage.error(String(cause)); }
|
|
49
|
+
finally { comparing.value = false; }
|
|
50
|
+
}
|
|
51
|
+
async function remove() {
|
|
52
|
+
try { await ElMessageBox.confirm(`删除选中的 ${selected.value.length} 条历史及截图、日志快照?原始报告文件不受影响。`, '删除历史结果', { type: 'warning', confirmButtonText: '确定', cancelButtonText: '取消' }); }
|
|
53
|
+
catch { return; }
|
|
54
|
+
loading.value = true;
|
|
55
|
+
try {
|
|
56
|
+
for (const run of selected.value) await request(`${base}/${encodeURIComponent(run.id)}`, 'DELETE');
|
|
57
|
+
details.value = [];
|
|
58
|
+
await load();
|
|
59
|
+
} catch (cause) { ElMessage.error(String(cause)); await load(); }
|
|
60
|
+
finally { loading.value = false; }
|
|
61
|
+
}
|
|
62
|
+
function nodeDuration(run: RunDetail) {
|
|
63
|
+
const matches = run.nodes.filter(node => node.key === nodeKey.value);
|
|
64
|
+
if (!matches.length) return '未执行';
|
|
65
|
+
if (matches.some(node => node.durationMs === null)) return '耗时不完整';
|
|
66
|
+
return seconds(matches.reduce((sum, node) => sum + (node.durationMs || 0), 0));
|
|
67
|
+
}
|
|
68
|
+
onMounted(load);
|
|
69
|
+
</script>
|
|
70
|
+
|
|
71
|
+
<template>
|
|
72
|
+
<el-dialog :model-value="true" :title="`${scriptName} · 历史结果对比`" width="min(1200px, 96vw)" align-center append-to-body @closed="emit('close')">
|
|
73
|
+
<div v-loading="loading" class="run-history">
|
|
74
|
+
<div class="history-toolbar">
|
|
75
|
+
<el-select v-model="version" clearable placeholder="全部应用版本" aria-label="应用版本"><el-option v-for="item in versions" :key="item" :value="item" /></el-select>
|
|
76
|
+
<el-select v-model="device" clearable placeholder="全部设备" aria-label="设备"><el-option v-for="item in devices" :key="item" :value="item" /></el-select>
|
|
77
|
+
<el-tooltip content="刷新历史"><el-button :icon="Refresh" aria-label="刷新历史" @click="load" /></el-tooltip>
|
|
78
|
+
<el-button :disabled="selected.length !== 2" :loading="comparing" @click="compare">对比两次运行</el-button>
|
|
79
|
+
<el-tooltip content="删除选中历史"><el-button :icon="Delete" type="danger" plain :disabled="!selected.length" aria-label="删除选中历史" @click="remove" /></el-tooltip>
|
|
80
|
+
</div>
|
|
81
|
+
<el-alert v-if="error" :title="error" type="error" :closable="false" />
|
|
82
|
+
<el-empty v-else-if="!runs.length && !loading" description="暂无历史结果,后续运行会自动记录" />
|
|
83
|
+
<template v-else>
|
|
84
|
+
<p>{{ filtered.length }} 次运行 · 通过率 {{ stats.passRate === null ? '—' : `${(stats.passRate * 100).toFixed(1)}%` }}(不含手动终止)</p>
|
|
85
|
+
<h3>耗时趋势</h3>
|
|
86
|
+
<svg class="history-trend" viewBox="0 0 800 160" role="img" aria-label="按时间从早到晚的运行耗时趋势">
|
|
87
|
+
<path d="M20 10V130H790" fill="none" stroke="#a0a8ad" />
|
|
88
|
+
<polyline :points="points" fill="none" stroke="#168a75" stroke-width="2" />
|
|
89
|
+
<circle v-for="(run, index) in trend" :key="run.id" :cx="20 + index * 760 / Math.max(1, trend.length - 1)" :cy="130 - run.durationMs / maxDuration * 110" r="4" :fill="run.status === 'failed' ? '#d14343' : '#168a75'">
|
|
90
|
+
<title>{{ time(run.startedAt) }} · {{ seconds(run.durationMs) }} · {{ run.appVersion || '未知版本' }}</title>
|
|
91
|
+
</circle>
|
|
92
|
+
<text x="24" y="14">{{ seconds(maxDuration) }}</text><text x="20" y="153">{{ trend[0] ? time(trend[0].startedAt) : '' }}</text><text x="780" y="153" text-anchor="end">{{ trend.length > 1 ? time(trend[trend.length - 1].startedAt) : '' }}</text>
|
|
93
|
+
</svg>
|
|
94
|
+
<el-table :data="filtered" row-key="id" max-height="270" @selection-change="selected = $event">
|
|
95
|
+
<el-table-column type="selection" width="42" />
|
|
96
|
+
<el-table-column label="运行时间" min-width="180"><template #default="{ row }">{{ time(row.startedAt) }}</template></el-table-column>
|
|
97
|
+
<el-table-column prop="appVersion" label="应用版本" min-width="140"><template #default="{ row }">{{ row.appVersion || '未知' }}</template></el-table-column>
|
|
98
|
+
<el-table-column prop="deviceId" label="设备" min-width="140" />
|
|
99
|
+
<el-table-column label="结果" width="85"><template #default="{ row }">{{ labels[row.status as keyof typeof labels] }}</template></el-table-column>
|
|
100
|
+
<el-table-column label="耗时" width="100"><template #default="{ row }">{{ seconds(row.durationMs) }}</template></el-table-column>
|
|
101
|
+
</el-table>
|
|
102
|
+
<h3>失败节点分布</h3>
|
|
103
|
+
<el-table :data="stats.failures" max-height="220" empty-text="暂无节点失败记录">
|
|
104
|
+
<el-table-column prop="label" label="节点" min-width="220" />
|
|
105
|
+
<el-table-column label="失败 / 到达次数" width="145"><template #default="{ row }">{{ row.failures }} / {{ row.runs }}</template></el-table-column>
|
|
106
|
+
<el-table-column label="观察结果" width="130"><template #default="{ row }">{{ row.runs < 2 ? '样本不足' : row.failures === row.runs ? '持续失败' : '偶发失败' }}</template></el-table-column>
|
|
107
|
+
<el-table-column label="首次失败 / 版本" min-width="190"><template #default="{ row }">{{ time(row.firstFailure) }} · {{ row.version || '未知' }}</template></el-table-column>
|
|
108
|
+
</el-table>
|
|
109
|
+
</template>
|
|
110
|
+
<section v-if="details.length === 2" class="history-comparison">
|
|
111
|
+
<h3>运行对比 · 总耗时差 {{ seconds(details[1].durationMs - details[0].durationMs) }}</h3>
|
|
112
|
+
<el-alert v-if="details[0].deviceId !== details[1].deviceId || details[0].appPackage !== details[1].appPackage" title="两次运行的设备或应用不同,耗时差不能直接归因于版本变化" type="warning" :closable="false" />
|
|
113
|
+
<el-select v-model="nodeKey" clearable filterable placeholder="全部节点截图" aria-label="对比节点"><el-option v-for="node in comparisonNodes" :key="node.key" :label="node.label" :value="node.key" /></el-select>
|
|
114
|
+
<div class="comparison-columns">
|
|
115
|
+
<section v-for="run in details" :key="run.id">
|
|
116
|
+
<h4>{{ time(run.startedAt) }} · {{ labels[run.status] }}</h4>
|
|
117
|
+
<p>{{ run.appVersion || '未知版本' }} · {{ run.deviceId }} · {{ seconds(run.durationMs) }}</p>
|
|
118
|
+
<p v-if="nodeKey">节点累计耗时(含截图开销):{{ nodeDuration(run) }}</p>
|
|
119
|
+
<p v-if="!run.frames.some(frame => !nodeKey || frame.key === nodeKey)">未捕获到对应截图</p>
|
|
120
|
+
<div class="history-frames">
|
|
121
|
+
<figure v-for="(frame, index) in run.frames.filter(frame => !nodeKey || frame.key === nodeKey)" :key="index">
|
|
122
|
+
<figcaption>{{ frame.label }} · {{ frame.phase === 'before' ? '执行前' : frame.phase === 'after' ? '执行后' : frame.phase }} · {{ time(frame.capturedAt) }}</figcaption>
|
|
123
|
+
<el-image :src="frame.imageUrl" :preview-src-list="[frame.imageUrl]" preview-teleported fit="contain" loading="lazy" />
|
|
124
|
+
</figure>
|
|
125
|
+
</div>
|
|
126
|
+
<h4>节点结果</h4><ul><li v-for="(node, index) in run.nodes.filter(node => !nodeKey || node.key === nodeKey)" :key="index">{{ node.label }} · {{ node.failed ? '失败' : '完成' }} · {{ node.durationMs === null ? '耗时未知' : seconds(node.durationMs) }}</li></ul>
|
|
127
|
+
<h4>关键日志 / 完整日志</h4><el-input v-model="logQuery" clearable placeholder="日志关键字,如 stageLog" aria-label="日志关键字" /><pre>{{ logQuery ? run.output.split('\n').filter(line => line.toLowerCase().includes(logQuery.toLowerCase())).join('\n') || '无匹配日志' : run.output }}</pre>
|
|
128
|
+
</section>
|
|
129
|
+
</div>
|
|
130
|
+
</section>
|
|
131
|
+
</div>
|
|
132
|
+
</el-dialog>
|
|
133
|
+
</template>
|
|
134
|
+
|
|
135
|
+
<style scoped>
|
|
136
|
+
.run-history { max-height: 78vh; overflow: auto; }
|
|
137
|
+
.history-toolbar { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
|
|
138
|
+
.history-toolbar .el-select { width: 190px; }
|
|
139
|
+
.history-trend { width: 100%; height: 160px; background: #f3faf7; }
|
|
140
|
+
.history-trend text { font-size: 12px; fill: #52615b; }
|
|
141
|
+
h3 { font-size: 15px; margin: 18px 0 10px; }
|
|
142
|
+
h4 { font-size: 14px; margin: 12px 0 8px; }
|
|
143
|
+
.history-comparison > .el-select { width: 100%; }
|
|
144
|
+
.comparison-columns { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 20px; }
|
|
145
|
+
.comparison-columns section { min-width: 0; overflow-wrap: anywhere; }
|
|
146
|
+
.history-frames { max-height: 360px; overflow: auto; display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; }
|
|
147
|
+
figure { margin: 0; }
|
|
148
|
+
figcaption { font-size: 12px; }
|
|
149
|
+
.el-image { width: 100%; height: 150px; }
|
|
150
|
+
pre { white-space: pre-wrap; overflow-wrap: anywhere; max-height: 280px; overflow: auto; background: #f1f5f4; padding: 10px; font-size: 12px; }
|
|
151
|
+
ul { max-height: 160px; overflow: auto; padding-left: 20px; }
|
|
152
|
+
@media (max-width: 650px) { .comparison-columns { grid-template-columns: minmax(0, 1fr); } }
|
|
153
|
+
</style>
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import type { AppiumRecordedStep } from './types';
|
|
2
|
+
|
|
3
|
+
export const MAX_LOOP_ITERATIONS = 10000;
|
|
4
|
+
export const defaultLoopConfig = () => ({ maxIterations: 3, exitWhen: 'never' as const });
|
|
5
|
+
|
|
6
|
+
export function validateLoop(step: AppiumRecordedStep) {
|
|
7
|
+
const config = step.loop;
|
|
8
|
+
if (!config || !Number.isInteger(config.maxIterations) || config.maxIterations < 1 || config.maxIterations > MAX_LOOP_ITERATIONS) {
|
|
9
|
+
throw new Error(`循环最大次数必须是 1-${MAX_LOOP_ITERATIONS} 的整数`);
|
|
10
|
+
}
|
|
11
|
+
if (!['never', 'exists', 'notExists'].includes(config.exitWhen)) throw new Error('循环退出条件无效');
|
|
12
|
+
if (config.exitWhen !== 'never' && (!step.selector?.value?.trim() || !['id', 'accessibilityId', 'xpath', 'androidUiAutomator'].includes(step.selector.strategy))) {
|
|
13
|
+
throw new Error('请设置循环退出条件的元素定位内容');
|
|
14
|
+
}
|
|
15
|
+
return config;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Branch ownership, not array adjacency, determines the innermost active loop.
|
|
19
|
+
export function enclosingLoop(steps: readonly AppiumRecordedStep[], step: AppiumRecordedStep): AppiumRecordedStep | undefined {
|
|
20
|
+
return enclosingLoops(steps, step)[0];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function enclosingLoops(steps: readonly AppiumRecordedStep[], step: AppiumRecordedStep): AppiumRecordedStep[] {
|
|
24
|
+
const loops: AppiumRecordedStep[] = [];
|
|
25
|
+
const byId = new Map(steps.map((item) => [item.id, item]));
|
|
26
|
+
const seen = new Set<string>();
|
|
27
|
+
let current = step;
|
|
28
|
+
while (current.flow?.parentConditionId) {
|
|
29
|
+
const parent = byId.get(current.flow.parentConditionId);
|
|
30
|
+
if (!parent || seen.has(parent.id)) break;
|
|
31
|
+
seen.add(parent.id);
|
|
32
|
+
if (parent.type === 'loop' && current.flow.parentBranch === 'yes') loops.push(parent);
|
|
33
|
+
current = parent;
|
|
34
|
+
}
|
|
35
|
+
return loops;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function breakLoopTarget(steps: readonly AppiumRecordedStep[], step: AppiumRecordedStep) {
|
|
39
|
+
const loops = enclosingLoops(steps, step);
|
|
40
|
+
const target = step.breakLoopTargetId ? loops.find((loop) => loop.id === step.breakLoopTargetId) : loops[0];
|
|
41
|
+
if (!target) throw new Error('退出循环目标必须是当前节点所属的循环体,目标可能已删除或不在当前分支');
|
|
42
|
+
return target;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function validateLoopSteps(steps: readonly AppiumRecordedStep[]) {
|
|
46
|
+
if (!steps.some((step) => step.type === 'loop' || step.type === 'breakLoop')) return;
|
|
47
|
+
const byId = new Map(steps.map((step) => [step.id, step]));
|
|
48
|
+
if (byId.size !== steps.length) throw new Error('流程节点 ID 重复');
|
|
49
|
+
for (const step of steps) {
|
|
50
|
+
let current: AppiumRecordedStep | undefined = step;
|
|
51
|
+
const seen = new Set<string>();
|
|
52
|
+
while (current) {
|
|
53
|
+
if (seen.has(current.id)) throw new Error('流程分支归属存在循环');
|
|
54
|
+
seen.add(current.id);
|
|
55
|
+
const id: string | undefined = current.flow?.parentConditionId;
|
|
56
|
+
if (id && !byId.has(id)) throw new Error('流程分支所属节点不存在');
|
|
57
|
+
current = id ? byId.get(id) : undefined;
|
|
58
|
+
}
|
|
59
|
+
if (step.type === 'loop') validateLoop(step);
|
|
60
|
+
if (step.type === 'breakLoop') breakLoopTarget(steps, step);
|
|
61
|
+
if (step.type === 'loop' && step.flow?.yesTargetId) {
|
|
62
|
+
const target = byId.get(step.flow.yesTargetId);
|
|
63
|
+
if (!target || target.flow?.parentConditionId !== step.id || target.flow.parentBranch !== 'yes') throw new Error('循环入口必须指向所属循环体');
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed } from 'vue';
|
|
3
|
+
import type { AppiumRecordedStep } from '../types';
|
|
4
|
+
import { enclosingLoops } from '../bounded-loop';
|
|
5
|
+
|
|
6
|
+
const props = defineProps<{ step: AppiumRecordedStep; steps: AppiumRecordedStep[]; disabled?: boolean }>();
|
|
7
|
+
const emit = defineEmits<{ update: [patch: Partial<AppiumRecordedStep>] }>();
|
|
8
|
+
const loops = computed(() => enclosingLoops(props.steps, props.step));
|
|
9
|
+
</script>
|
|
10
|
+
|
|
11
|
+
<template>
|
|
12
|
+
<el-form-item label="退出目标循环">
|
|
13
|
+
<el-select class="break-loop-target" popper-class="break-loop-target-popper" :model-value="step.breakLoopTargetId || loops[0]?.id" :disabled="disabled" aria-label="退出目标循环" @update:model-value="emit('update', { breakLoopTargetId: $event })">
|
|
14
|
+
<el-option class="break-loop-target-option" v-for="(loop, index) in loops" :key="loop.id" :value="loop.id" :label="`${loop.label}(${index === 0 ? '当前循环' : `外层 ${index}`} · 节点 ${steps.findIndex(item => item.id === loop.id) + 1})`" />
|
|
15
|
+
</el-select>
|
|
16
|
+
</el-form-item>
|
|
17
|
+
</template>
|
|
18
|
+
|
|
19
|
+
<style scoped>
|
|
20
|
+
.break-loop-target { width: 100%; min-width: 0; }
|
|
21
|
+
.break-loop-target :deep(.el-select__wrapper) { height: auto; min-height: 36px; }
|
|
22
|
+
.break-loop-target :deep(.el-select__placeholder) {
|
|
23
|
+
position: relative; top: auto; transform: none; z-index: 0; pointer-events: none;
|
|
24
|
+
white-space: normal; overflow-wrap: anywhere; line-height: 22px;
|
|
25
|
+
}
|
|
26
|
+
.break-loop-target :deep(.el-select__input-wrapper) { position: absolute; width: 100%; }
|
|
27
|
+
.break-loop-target-option { height: auto; min-height: 34px; padding-top: 6px; padding-bottom: 6px; white-space: normal; overflow-wrap: anywhere; line-height: 22px; }
|
|
28
|
+
:global(.break-loop-target-popper) { max-width: calc(100vw - 32px); }
|
|
29
|
+
</style>
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { Refresh } from '@element-plus/icons-vue';
|
|
3
|
+
import type { AppiumNode } from '../types';
|
|
4
|
+
import ComponentTree from './ComponentTree.vue';
|
|
5
|
+
import NodeDetail from './NodeDetail.vue';
|
|
6
|
+
defineProps<{ tree: AppiumNode | null; node: AppiumNode | null; activity: string; loading: boolean; disabled: boolean; recording: boolean }>();
|
|
7
|
+
defineEmits<{ refresh: []; select: [node: AppiumNode] }>();
|
|
8
|
+
</script>
|
|
9
|
+
|
|
10
|
+
<template>
|
|
11
|
+
<el-card shadow="never" class="appium-recorder-card component-inspector">
|
|
12
|
+
<template #header><div class="panel-header"><span>App 组件树</span><el-tooltip content="刷新组件树" placement="top" :show-after="200"><el-button class="recorder-panel-tool" :icon="Refresh" size="small" aria-label="刷新组件树" :loading="loading" :disabled="disabled" @click="$emit('refresh')" /></el-tooltip></div></template>
|
|
13
|
+
<ComponentTree :tree="tree" :selected-id="node?.id || ''" @select="$emit('select', $event)" />
|
|
14
|
+
<NodeDetail :node="node" :current-activity="activity" :loading="recording" />
|
|
15
|
+
<div class="component-inspector__activity"><span>Activity</span><code :title="activity">{{ activity || '-' }}</code></div>
|
|
16
|
+
</el-card>
|
|
17
|
+
</template>
|
|
18
|
+
|
|
19
|
+
<style scoped>
|
|
20
|
+
.component-inspector :deep(> .el-card__body) { display: flex; flex-direction: column; overflow: hidden; gap: 8px; }
|
|
21
|
+
.component-inspector :deep(.appium-tree-container) { flex: 1; min-height: 0; }
|
|
22
|
+
.component-inspector :deep(.appium-node-summary) { flex: none; }
|
|
23
|
+
.panel-header { font-size: 14px; gap: 4px; }
|
|
24
|
+
.component-inspector__activity { flex: none; display: flex; min-width: 0; gap: 6px; padding-top: 8px; border-top: 1px solid #e4e7ed; font-size: 12px; color: #606266; }
|
|
25
|
+
.component-inspector__activity code { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
26
|
+
</style>
|