android-midscene-automation 0.1.37 → 0.1.39
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 +13 -0
- package/README.md +13 -0
- package/package.json +12 -7
- package/server/appium-recorder/ai-recognition.ts +4 -2
- package/server/appium-recorder/appium-runner.ts +90 -37
- package/server/appium-recorder/condition-timeout.ts +4 -0
- package/server/appium-recorder/image-check.ts +16 -2
- package/server/appium-recorder/replay-mp4.ts +72 -0
- package/server/appium-recorder/replay-video.ts +99 -0
- package/server/appium-recorder/report.ts +60 -6
- package/server/appium-recorder/repository.ts +1 -0
- package/server/appium-recorder/routes.ts +13 -17
- package/server/appium-recorder/run-history.ts +7 -1
- package/server/appium-recorder/scrcpy-recording-source.ts +80 -0
- package/server/appium-recorder/video-response.ts +36 -0
- package/src/appium-recorder/AppiumPage.vue +42 -98
- package/src/appium-recorder/RunHistoryDialog.vue +7 -2
- package/src/appium-recorder/action-descriptions.ts +1 -1
- package/src/appium-recorder/api.ts +3 -10
- package/src/appium-recorder/components/BranchTimeoutSettings.vue +23 -0
- package/src/appium-recorder/components/FlowNodeCard.vue +2 -3
- package/src/appium-recorder/components/FlowStepEditor.vue +6 -21
- package/src/appium-recorder/components/ImageCheckDialog.vue +38 -9
- package/src/appium-recorder/components/NestedConditionBranches.vue +0 -1
- package/src/appium-recorder/components/RecordedSteps.vue +0 -1
- package/src/appium-recorder/flow-graph.ts +2 -2
- package/src/appium-recorder/flow-labels.ts +2 -2
- package/src/appium-recorder/image-check.ts +6 -2
- package/src/appium-recorder/node-timeout.ts +1 -0
- package/src/appium-recorder/run-history.ts +1 -0
- package/src/appium-recorder/types.ts +1 -0
- package/src/components/config/AppiumConfigPanel.vue +0 -35
- package/src/appium-recorder/components/AiRecognitionTestDialog.vue +0 -71
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import { computed, h, onMounted, onUnmounted, provide, reactive, shallowRef, watch } from 'vue';
|
|
3
3
|
import { flowBackgroundKey, normalizeFlowBackground } from './flow-appearance';
|
|
4
|
+
import { DEFAULT_NODE_TIMEOUT_MS } from './node-timeout';
|
|
4
5
|
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, Clock } from '@element-plus/icons-vue';
|
|
6
|
+
import { ArrowDown, Check, CircleClose, CopyDocument, Delete, Document, Download, Edit, Plus, Refresh, Upload, VideoPlay, View, Clock } from '@element-plus/icons-vue';
|
|
6
7
|
import RunHistoryDialog from './RunHistoryDialog.vue';
|
|
7
8
|
import type { AndroidDevice, AppPreset, DeviceAction } from '../types';
|
|
8
9
|
import DevicePreviewPanel from '../components/device/DevicePreviewPanel.vue';
|
|
@@ -42,8 +43,6 @@ import NewScriptDialog from './components/NewScriptDialog.vue';
|
|
|
42
43
|
import VisualChangeDialog from './components/VisualChangeDialog.vue';
|
|
43
44
|
import ImageCheckDialog from './components/ImageCheckDialog.vue';
|
|
44
45
|
import { createImageCheckConfig, validateImageCheck, type ImageCheckConfig } from './image-check';
|
|
45
|
-
import AiRecognitionTestDialog from './components/AiRecognitionTestDialog.vue';
|
|
46
|
-
import { validateAiRecognitionPrompt } from './ai-recognition';
|
|
47
46
|
import {
|
|
48
47
|
createFlowClipboard,
|
|
49
48
|
pasteFlowClipboard,
|
|
@@ -95,49 +94,9 @@ const workspaceRef = shallowRef<InstanceType<typeof RecorderWorkspace>>();
|
|
|
95
94
|
const historyScript = shallowRef<{ id: string; name: string } | null>(null);
|
|
96
95
|
const AUTO_TREE_REFRESH_INTERVAL_MS = 1800;
|
|
97
96
|
|
|
98
|
-
const insertableRecorderActions: RecorderAction[] = [
|
|
99
|
-
'extractVariable',
|
|
100
|
-
'delay',
|
|
101
|
-
'tap',
|
|
102
|
-
'input',
|
|
103
|
-
'waitFor',
|
|
104
|
-
'tapIfExists',
|
|
105
|
-
'inputIfExists',
|
|
106
|
-
'clearIfExists',
|
|
107
|
-
'backIfExists',
|
|
108
|
-
'popupCondition',
|
|
109
|
-
'checkboxState',
|
|
110
|
-
'checkedState',
|
|
111
|
-
'radioButtonState',
|
|
112
|
-
'aiRecognition',
|
|
113
|
-
'imageCheck',
|
|
114
|
-
'textClick',
|
|
115
|
-
'runScript',
|
|
116
|
-
'keyBack',
|
|
117
|
-
'keyHome',
|
|
118
|
-
'keyRecent',
|
|
119
|
-
'keyPower',
|
|
120
|
-
'waitActivity',
|
|
121
|
-
'swipe',
|
|
122
|
-
'clearInput',
|
|
123
|
-
'coordinateTap',
|
|
124
|
-
'launchApp',
|
|
125
|
-
'stopApp',
|
|
126
|
-
'openGallery',
|
|
127
|
-
'endFlow',
|
|
128
|
-
'loop',
|
|
129
|
-
'breakLoop',
|
|
130
|
-
'clearAppData',
|
|
131
|
-
'waitDisappear',
|
|
132
|
-
'longPress',
|
|
133
|
-
'pinch',
|
|
134
|
-
'noop',
|
|
135
|
-
'log',
|
|
136
|
-
'visualChangeStart',
|
|
137
|
-
'visualChangeEnd',
|
|
138
|
-
];
|
|
139
97
|
const readonlyFlowActionGroups: FlowActionGroup[] = [];
|
|
140
98
|
const readonlyFlowSelectedIndexes: number[] = [];
|
|
99
|
+
const recordReplayVideo = shallowRef(false);
|
|
141
100
|
|
|
142
101
|
const props = defineProps<{
|
|
143
102
|
aiRecognitionModelConfigured?: boolean;
|
|
@@ -177,7 +136,6 @@ const newScriptRevision = shallowRef(0);
|
|
|
177
136
|
const storedWorkbenchTab = window.localStorage.getItem(WORKBENCH_TAB_STORAGE_KEY);
|
|
178
137
|
const activeWorkbenchTab = shallowRef<'recording' | 'scripts' | 'variables'>(storedWorkbenchTab === 'scripts' ? 'scripts' : storedWorkbenchTab === 'variables' ? 'variables' : 'recording');
|
|
179
138
|
const steps = shallowRef<AppiumRecordedStep[]>([]);
|
|
180
|
-
const aiRecognitionTestStep = shallowRef<AppiumRecordedStep | null>(null);
|
|
181
139
|
const flowClipboard = shallowRef<FlowClipboard | null>(null);
|
|
182
140
|
const rawXml = shallowRef('');
|
|
183
141
|
const currentActivity = shallowRef('');
|
|
@@ -294,7 +252,7 @@ const recordingBusy = computed(() => (
|
|
|
294
252
|
|| resolvingNavigation.value
|
|
295
253
|
|| Boolean(pendingNavigation.value)
|
|
296
254
|
));
|
|
297
|
-
const recordingLocked = computed(() => recordingBusy.value ||
|
|
255
|
+
const recordingLocked = computed(() => recordingBusy.value || replaying.value);
|
|
298
256
|
const newScriptDisabled = computed(() => saving.value || replaying.value || recordingBusy.value || launchingApp.value || importingScript.value);
|
|
299
257
|
const overlayBounds = computed(() => flattenNodes(tree.value).flatMap((node) => (
|
|
300
258
|
node.bounds ? [{ id: node.id, ...node.bounds }] : []
|
|
@@ -510,7 +468,7 @@ function createStep(type: NodeStepType, node: AppiumNode): AppiumRecordedStep {
|
|
|
510
468
|
? [{ strategy: 'xpath', value: node.xpath, unique: true, matchCount: 1 }]
|
|
511
469
|
: undefined,
|
|
512
470
|
fallback: node.bounds ? { strategy: 'bounds', centerX: node.bounds.centerX, centerY: node.bounds.centerY } : undefined,
|
|
513
|
-
timeoutMs: type === 'waitFor' ?
|
|
471
|
+
timeoutMs: type === 'waitFor' ? DEFAULT_NODE_TIMEOUT_MS : undefined,
|
|
514
472
|
pageBefore: currentPageSnapshot(node),
|
|
515
473
|
snapshot: {
|
|
516
474
|
text: node.text,
|
|
@@ -614,7 +572,7 @@ function createWaitActivityStep(activity: string): AppiumRecordedStep {
|
|
|
614
572
|
type: 'waitActivity',
|
|
615
573
|
label: `等待 Activity ${activity}`,
|
|
616
574
|
value: activity,
|
|
617
|
-
timeoutMs:
|
|
575
|
+
timeoutMs: DEFAULT_NODE_TIMEOUT_MS,
|
|
618
576
|
};
|
|
619
577
|
}
|
|
620
578
|
|
|
@@ -928,11 +886,6 @@ async function refreshTree() {
|
|
|
928
886
|
|
|
929
887
|
async function executeFlowStep(index: number) {
|
|
930
888
|
const step = steps.value[index];
|
|
931
|
-
if (step?.type === 'aiRecognition') {
|
|
932
|
-
if (replaying.value || recordingBusy.value) { ElMessage.warning('设备正在执行操作,请稍后测试'); return; }
|
|
933
|
-
aiRecognitionTestStep.value = { ...step };
|
|
934
|
-
return;
|
|
935
|
-
}
|
|
936
889
|
if (!step || (step.type !== 'launchApp' && step.type !== 'clearAppData')) return;
|
|
937
890
|
if (launchingApp.value) return;
|
|
938
891
|
if (!selectedDeviceId.value) {
|
|
@@ -976,7 +929,7 @@ async function executeFlowStep(index: number) {
|
|
|
976
929
|
} else if (scriptActivityMismatch.value) {
|
|
977
930
|
ElMessage.warning(`App 已启动,当前页面仍为 ${currentActivity.value || '-'}`);
|
|
978
931
|
} else {
|
|
979
|
-
ElMessage.success('
|
|
932
|
+
ElMessage.success('已进入脚本绑定页面');
|
|
980
933
|
}
|
|
981
934
|
} catch (error) {
|
|
982
935
|
ElMessage.error(error instanceof Error ? error.message : 'App 操作失败');
|
|
@@ -1431,25 +1384,12 @@ async function addAction(
|
|
|
1431
1384
|
return;
|
|
1432
1385
|
}
|
|
1433
1386
|
if (action === 'aiRecognition') {
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
inputPlaceholder: '例如:检查当前画面有没有显示黑屏',
|
|
1437
|
-
inputValidator: (value) => {
|
|
1438
|
-
try { validateAiRecognitionPrompt(value); return true; }
|
|
1439
|
-
catch (error) { return error instanceof Error ? error.message : '识别内容无效'; }
|
|
1440
|
-
},
|
|
1441
|
-
confirmButtonText: '添加', cancelButtonText: '取消',
|
|
1442
|
-
}).catch(() => null);
|
|
1443
|
-
if (!input) return;
|
|
1444
|
-
return insertStep({
|
|
1445
|
-
id: createStepId(), type: 'aiRecognition', label: 'AI 识别',
|
|
1446
|
-
value: validateAiRecognitionPrompt(input.value), timeoutMs: 60000,
|
|
1447
|
-
flow: { nodeKind: 'condition' },
|
|
1448
|
-
}, index, branchTarget);
|
|
1387
|
+
ElMessage.warning('AI 识别操作已移除,请使用图像判断或原生组件判断');
|
|
1388
|
+
return;
|
|
1449
1389
|
}
|
|
1450
1390
|
if (action === 'textClick') {
|
|
1451
1391
|
const step = reactive<AppiumRecordedStep>({
|
|
1452
|
-
id: createStepId(), type: 'textClick', label: '文字点击', value: '', timeoutMs:
|
|
1392
|
+
id: createStepId(), type: 'textClick', label: '文字点击', value: '', timeoutMs: DEFAULT_NODE_TIMEOUT_MS,
|
|
1453
1393
|
flow: { nodeKind: 'condition', textMatch: 'exact' },
|
|
1454
1394
|
});
|
|
1455
1395
|
const result = await ElMessageBox({
|
|
@@ -1617,7 +1557,7 @@ async function addAction(
|
|
|
1617
1557
|
ElMessage.warning(`请选择原生 ${controlName} 元素,当前选中:${node.className || '未知类型'}`);
|
|
1618
1558
|
return;
|
|
1619
1559
|
}
|
|
1620
|
-
const step = createNodeActionStep(action, action === 'checkedState' ? '判断勾选' : `判断 ${controlName} 状态`, node, { timeoutMs:
|
|
1560
|
+
const step = createNodeActionStep(action, action === 'checkedState' ? '判断勾选' : `判断 ${controlName} 状态`, node, { timeoutMs: DEFAULT_NODE_TIMEOUT_MS });
|
|
1621
1561
|
if ((!step.selector?.value || step.selector.strategy === 'bounds') && node.xpath) {
|
|
1622
1562
|
step.selector = { strategy: 'xpath', value: node.xpath };
|
|
1623
1563
|
step.contextSelector = undefined;
|
|
@@ -1633,13 +1573,13 @@ async function addAction(
|
|
|
1633
1573
|
return insertStep(step, index, branchTarget);
|
|
1634
1574
|
}
|
|
1635
1575
|
if (action === 'popupCondition') {
|
|
1636
|
-
const step = createNodeActionStep('assertExists', '判断存在', node, { timeoutMs:
|
|
1576
|
+
const step = createNodeActionStep('assertExists', '判断存在', node, { timeoutMs: DEFAULT_NODE_TIMEOUT_MS });
|
|
1637
1577
|
const inserted = insertStep({ ...step, flow: { nodeKind: 'condition' } }, index, branchTarget);
|
|
1638
1578
|
ElMessage.success('已添加判断节点,请在节点面板配置是/否分支');
|
|
1639
1579
|
return inserted;
|
|
1640
1580
|
}
|
|
1641
1581
|
if (action === 'tapIfExists') {
|
|
1642
|
-
return insertStep(createNodeActionStep('tapIfExists', '存在则点击', node, { timeoutMs:
|
|
1582
|
+
return insertStep(createNodeActionStep('tapIfExists', '存在则点击', node, { timeoutMs: DEFAULT_NODE_TIMEOUT_MS }), index, branchTarget);
|
|
1643
1583
|
}
|
|
1644
1584
|
if (action === 'inputIfExists') {
|
|
1645
1585
|
const input = await ElMessageBox.prompt('', '存在则输入', {
|
|
@@ -1650,16 +1590,16 @@ async function addAction(
|
|
|
1650
1590
|
}).catch(() => null);
|
|
1651
1591
|
if (!input) return;
|
|
1652
1592
|
return insertStep(
|
|
1653
|
-
createNodeActionStep('inputIfExists', '存在则输入', node, { timeoutMs:
|
|
1593
|
+
createNodeActionStep('inputIfExists', '存在则输入', node, { timeoutMs: DEFAULT_NODE_TIMEOUT_MS, value: input.value }),
|
|
1654
1594
|
index,
|
|
1655
1595
|
branchTarget,
|
|
1656
1596
|
);
|
|
1657
1597
|
}
|
|
1658
1598
|
if (action === 'clearIfExists') {
|
|
1659
|
-
return insertStep(createNodeActionStep('clearIfExists', '存在则清空', node, { timeoutMs:
|
|
1599
|
+
return insertStep(createNodeActionStep('clearIfExists', '存在则清空', node, { timeoutMs: DEFAULT_NODE_TIMEOUT_MS }), index, branchTarget);
|
|
1660
1600
|
}
|
|
1661
1601
|
if (action === 'backIfExists') {
|
|
1662
|
-
return insertStep(createNodeActionStep('backIfExists', '存在则返回', node, { timeoutMs:
|
|
1602
|
+
return insertStep(createNodeActionStep('backIfExists', '存在则返回', node, { timeoutMs: DEFAULT_NODE_TIMEOUT_MS }), index, branchTarget);
|
|
1663
1603
|
}
|
|
1664
1604
|
if (action === 'clearInput') {
|
|
1665
1605
|
return insertStep(createNodeActionStep('clearInput', '清空输入', node), index, branchTarget);
|
|
@@ -1706,7 +1646,7 @@ async function addAction(
|
|
|
1706
1646
|
return insertStep({ ...step }, index, branchTarget);
|
|
1707
1647
|
}
|
|
1708
1648
|
if (action === 'waitDisappear') {
|
|
1709
|
-
return insertStep(createNodeActionStep('waitDisappear', '等待元素消失', node, { timeoutMs:
|
|
1649
|
+
return insertStep(createNodeActionStep('waitDisappear', '等待元素消失', node, { timeoutMs: DEFAULT_NODE_TIMEOUT_MS }), index, branchTarget);
|
|
1710
1650
|
}
|
|
1711
1651
|
}
|
|
1712
1652
|
|
|
@@ -2116,7 +2056,7 @@ async function replayScript() {
|
|
|
2116
2056
|
try {
|
|
2117
2057
|
await pauseAutoTreeRefresh();
|
|
2118
2058
|
const result = await replayAppiumScript(
|
|
2119
|
-
{ id: selectedScript.value.id, deviceId: activeReplayDeviceId.value },
|
|
2059
|
+
{ id: selectedScript.value.id, deviceId: activeReplayDeviceId.value, recordVideo: recordReplayVideo.value },
|
|
2120
2060
|
(line) => {
|
|
2121
2061
|
replayOutput.value += `${replayOutput.value ? '\n' : ''}${line}`;
|
|
2122
2062
|
},
|
|
@@ -2207,15 +2147,23 @@ watch(
|
|
|
2207
2147
|
</el-select>
|
|
2208
2148
|
<el-button :icon="Plus" :disabled="newScriptDisabled || newScriptDialogVisible" @click="requestNewScript">新建</el-button>
|
|
2209
2149
|
<el-button :icon="Check" :loading="saving" @click="saveScript">保存</el-button>
|
|
2210
|
-
<el-button
|
|
2211
|
-
|
|
2212
|
-
|
|
2213
|
-
|
|
2214
|
-
|
|
2215
|
-
|
|
2216
|
-
|
|
2217
|
-
|
|
2218
|
-
|
|
2150
|
+
<el-button-group class="replay-button-group">
|
|
2151
|
+
<el-button
|
|
2152
|
+
type="primary"
|
|
2153
|
+
:icon="VideoPlay"
|
|
2154
|
+
:loading="replaying"
|
|
2155
|
+
:disabled="!selectedScript"
|
|
2156
|
+
@click="replayScript"
|
|
2157
|
+
>
|
|
2158
|
+
回放
|
|
2159
|
+
</el-button>
|
|
2160
|
+
<el-popover trigger="click" placement="bottom" :width="260">
|
|
2161
|
+
<template #reference><el-button type="primary" :icon="ArrowDown" :disabled="replaying" aria-label="回放设置" title="回放设置" class="replay-settings-toggle" /></template>
|
|
2162
|
+
<el-tooltip content="使用内置服务端后台录制,无需安装 scrcpy 或 FFmpeg。含敏感变量的运行不录制;分享 HTML 报告时需同时携带 MP4。" placement="bottom" :show-after="300">
|
|
2163
|
+
<el-checkbox v-model="recordReplayVideo" :disabled="replaying">录制回放视频</el-checkbox>
|
|
2164
|
+
</el-tooltip>
|
|
2165
|
+
</el-popover>
|
|
2166
|
+
</el-button-group>
|
|
2219
2167
|
<el-button
|
|
2220
2168
|
v-if="replaying"
|
|
2221
2169
|
type="danger"
|
|
@@ -2287,7 +2235,7 @@ watch(
|
|
|
2287
2235
|
v-if="scriptActivityMismatch"
|
|
2288
2236
|
class="appium-activity-summary"
|
|
2289
2237
|
>
|
|
2290
|
-
<summary
|
|
2238
|
+
<summary>当前页面与脚本绑定的 Activity 不一致</summary>
|
|
2291
2239
|
<div class="appium-activity-lock-alert__details">
|
|
2292
2240
|
<div>
|
|
2293
2241
|
<strong>脚本绑定</strong>
|
|
@@ -2297,7 +2245,7 @@ watch(
|
|
|
2297
2245
|
<strong>当前 Activity</strong>
|
|
2298
2246
|
<code>{{ currentActivity || '正在获取' }}</code>
|
|
2299
2247
|
</div>
|
|
2300
|
-
<p
|
|
2248
|
+
<p>不影响脚本编辑;如需录制当前页面的组件操作,请确认设备页面及所选组件。</p>
|
|
2301
2249
|
</div>
|
|
2302
2250
|
</details>
|
|
2303
2251
|
</section>
|
|
@@ -2309,9 +2257,8 @@ watch(
|
|
|
2309
2257
|
:steps="steps"
|
|
2310
2258
|
:clipboard-count="flowClipboardCount"
|
|
2311
2259
|
:disabled="recordingLocked"
|
|
2312
|
-
:remove-disabled="
|
|
2260
|
+
:remove-disabled="recordingLocked"
|
|
2313
2261
|
:merge-disabled="recordingBusy || replaying || saving"
|
|
2314
|
-
:allowed-locked-actions="scriptActivityMismatch && !recordingBusy ? insertableRecorderActions : []"
|
|
2315
2262
|
:launching-step-id="executingAppStepId"
|
|
2316
2263
|
@remove="removeStep"
|
|
2317
2264
|
@copy="copyFlowNodes"
|
|
@@ -2424,16 +2371,11 @@ watch(
|
|
|
2424
2371
|
@cancel="newScriptDialogVisible = false"
|
|
2425
2372
|
/>
|
|
2426
2373
|
|
|
2427
|
-
<AiRecognitionTestDialog
|
|
2428
|
-
v-if="aiRecognitionTestStep"
|
|
2429
|
-
:step="aiRecognitionTestStep"
|
|
2430
|
-
:device-id="selectedDeviceId"
|
|
2431
|
-
:model-configured="Boolean(aiRecognitionModelConfigured)"
|
|
2432
|
-
@close="aiRecognitionTestStep = null"
|
|
2433
|
-
/>
|
|
2434
2374
|
|
|
2435
2375
|
<ImageCheckDialog
|
|
2436
2376
|
v-if="imageCheckDraft?.imageCheck"
|
|
2377
|
+
:timeout-branch="imageCheckDraft.timeoutBranch"
|
|
2378
|
+
@timeout-branch="imageCheckDraft = { ...imageCheckDraft!, timeoutBranch: $event }"
|
|
2437
2379
|
:config="imageCheckDraft.imageCheck" :device-id="selectedDeviceId"
|
|
2438
2380
|
:has-element="Boolean(selectedNode?.bounds)" :editing="imageCheckEditing" :picking="imageCheckPicking"
|
|
2439
2381
|
@update="updateImageCheck" @close="imageCheckDraft = null; imageCheckPicking = false"
|
|
@@ -2624,4 +2566,6 @@ watch(
|
|
|
2624
2566
|
.appium-workbench__flow :deep(.appium-recorded-steps-panel) { display: flex; flex-direction: column; flex: 1; min-height: 0; }
|
|
2625
2567
|
.appium-workbench__flow :deep(.appium-flow-toolbar) { flex: none; margin-bottom: 8px; }
|
|
2626
2568
|
.appium-workbench__flow :deep(.appium-flow-canvas--vue:not(.appium-flow-canvas--dialog)) { flex: 1; height: auto; min-height: 120px; }
|
|
2569
|
+
.replay-button-group { display: inline-flex; flex-shrink: 0; }
|
|
2570
|
+
.replay-settings-toggle { padding-inline: 8px; }
|
|
2627
2571
|
</style>
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import { computed, onMounted, ref } from 'vue';
|
|
3
|
-
import { Delete, Refresh } from '@element-plus/icons-vue';
|
|
3
|
+
import { Delete, Refresh, VideoPlay } from '@element-plus/icons-vue';
|
|
4
4
|
import { ElMessage, ElMessageBox } from 'element-plus';
|
|
5
5
|
import { historyStatistics, type RunSummary, type RunDetail } from './run-history';
|
|
6
6
|
|
|
@@ -16,6 +16,7 @@ const device = ref('');
|
|
|
16
16
|
const error = ref('');
|
|
17
17
|
const nodeKey = ref('');
|
|
18
18
|
const logQuery = ref('');
|
|
19
|
+
const videoRun = ref<RunSummary>();
|
|
19
20
|
const base = `/api/appium-recorder/scripts/${encodeURIComponent(props.scriptId)}/history`;
|
|
20
21
|
const labels = { passed: '通过', failed: '失败', stopped: '已终止' };
|
|
21
22
|
const versions = computed(() => [...new Set(runs.value.map(run => run.appVersion || '未知'))]);
|
|
@@ -49,7 +50,7 @@ async function compare() {
|
|
|
49
50
|
finally { comparing.value = false; }
|
|
50
51
|
}
|
|
51
52
|
async function remove() {
|
|
52
|
-
try { await ElMessageBox.confirm(`删除选中的 ${selected.value.length}
|
|
53
|
+
try { await ElMessageBox.confirm(`删除选中的 ${selected.value.length} 条历史及截图、日志快照和回放视频?原始报告将无法再播放已删除的视频。`, '删除历史结果', { type: 'warning', confirmButtonText: '确定', cancelButtonText: '取消' }); }
|
|
53
54
|
catch { return; }
|
|
54
55
|
loading.value = true;
|
|
55
56
|
try {
|
|
@@ -98,6 +99,7 @@ onMounted(load);
|
|
|
98
99
|
<el-table-column prop="deviceId" label="设备" min-width="140" />
|
|
99
100
|
<el-table-column label="结果" width="85"><template #default="{ row }">{{ labels[row.status as keyof typeof labels] }}</template></el-table-column>
|
|
100
101
|
<el-table-column label="耗时" width="100"><template #default="{ row }">{{ seconds(row.durationMs) }}</template></el-table-column>
|
|
102
|
+
<el-table-column label="录像" width="70"><template #default="{ row }"><el-tooltip v-if="row.video" content="播放回放视频"><el-button :icon="VideoPlay" aria-label="播放回放视频" @click="videoRun = row" /></el-tooltip></template></el-table-column>
|
|
101
103
|
</el-table>
|
|
102
104
|
<h3>失败节点分布</h3>
|
|
103
105
|
<el-table :data="stats.failures" max-height="220" empty-text="暂无节点失败记录">
|
|
@@ -129,6 +131,9 @@ onMounted(load);
|
|
|
129
131
|
</div>
|
|
130
132
|
</section>
|
|
131
133
|
</div>
|
|
134
|
+
<el-dialog :model-value="Boolean(videoRun)" title="回放视频" width="min(800px, 90vw)" align-center append-to-body destroy-on-close @close="videoRun = undefined">
|
|
135
|
+
<video v-if="videoRun" :src="`${base}/${encodeURIComponent(videoRun.id)}/video`" controls preload="metadata" style="width:100%;max-height:70vh" />
|
|
136
|
+
</el-dialog>
|
|
132
137
|
</el-dialog>
|
|
133
138
|
</template>
|
|
134
139
|
|
|
@@ -26,7 +26,7 @@ export const actionDescriptions: Record<InsertAction, string> = {
|
|
|
26
26
|
checkboxState: '读取原生 Checkbox 的 checked 状态,按 true/false 分支执行。',
|
|
27
27
|
radioButtonState: '读取原生 RadioButton 的 checked 状态,按 true/false 分支执行。',
|
|
28
28
|
checkedState: '读取 Checkbox、RadioButton 或 Switch 的真实勾选状态,按 true/false 分支执行。不支持仅用图片表示状态的普通 ImageView。',
|
|
29
|
-
aiRecognition: '
|
|
29
|
+
aiRecognition: '此操作已移除,请使用图像判断或原生组件判断。',
|
|
30
30
|
textClick: '在组件树中按指定文字精确或模糊匹配并点击,进入匹配到文字或未匹配到文字分支。不识别图片或自绘画面中的文字。',
|
|
31
31
|
tapIfExists: '找到目标组件时点击,未找到则跳过并继续后续流程。',
|
|
32
32
|
inputIfExists: '找到目标输入组件时写入文字,未找到则跳过。支持 {{变量名}} 引用。',
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { APP_BASE } from '../api';
|
|
2
2
|
import type { AppiumRecordedScript, AppiumRecordedStep } from './types';
|
|
3
|
-
import type { AiRecognitionResult } from './ai-recognition';
|
|
4
3
|
import type { TestVariable } from './variables';
|
|
5
4
|
import type { AppiumVisualChangeRegion } from './types';
|
|
6
5
|
|
|
@@ -73,13 +72,6 @@ export async function getAppiumScripts() {
|
|
|
73
72
|
return readJson<{ scripts: AppiumRecordedScript[] }>(response);
|
|
74
73
|
}
|
|
75
74
|
|
|
76
|
-
export async function testAiRecognition(input: { deviceId: string; prompt: string; timeoutMs?: number }, signal?: AbortSignal) {
|
|
77
|
-
const response = await fetch(`${APP_BASE}/api/appium-recorder/ai-recognition/test`, {
|
|
78
|
-
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(input), signal,
|
|
79
|
-
});
|
|
80
|
-
return readJson<AiRecognitionResult & { imageBase64: string }>(response);
|
|
81
|
-
}
|
|
82
|
-
|
|
83
75
|
export function saveAppiumScript(input: {
|
|
84
76
|
variables?: import('./variables').TestVariable[];
|
|
85
77
|
id?: string;
|
|
@@ -115,6 +107,7 @@ export async function downloadAppiumScript(id: string) {
|
|
|
115
107
|
}
|
|
116
108
|
|
|
117
109
|
type ReplayResult = {
|
|
110
|
+
videoPath?: string;
|
|
118
111
|
success: boolean;
|
|
119
112
|
stopped?: boolean;
|
|
120
113
|
output: string;
|
|
@@ -131,7 +124,7 @@ type ReplayStreamEvent =
|
|
|
131
124
|
| { type: 'error'; message: string };
|
|
132
125
|
|
|
133
126
|
export async function replayAppiumScript(
|
|
134
|
-
input: { id: string; deviceId?: string; parameters?: import('./variables').TestVariable[] },
|
|
127
|
+
input: { id: string; deviceId?: string; recordVideo?: boolean; parameters?: import('./variables').TestVariable[] },
|
|
135
128
|
onOutput?: (line: string) => void,
|
|
136
129
|
) {
|
|
137
130
|
const response = await fetch(`${APP_BASE}/api/appium-recorder/scripts/${encodeURIComponent(input.id)}/replay`, {
|
|
@@ -140,7 +133,7 @@ export async function replayAppiumScript(
|
|
|
140
133
|
'Content-Type': 'application/json',
|
|
141
134
|
Accept: 'application/x-ndjson',
|
|
142
135
|
},
|
|
143
|
-
body: JSON.stringify({ deviceId: input.deviceId, parameters: input.parameters }),
|
|
136
|
+
body: JSON.stringify({ deviceId: input.deviceId, parameters: input.parameters, recordVideo: input.recordVideo }),
|
|
144
137
|
});
|
|
145
138
|
if (!response.ok || !response.body) return readJson<ReplayResult>(response);
|
|
146
139
|
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { AppiumRecordedStep } from '../types';
|
|
3
|
+
import { flowBranchLabel } from '../flow-labels';
|
|
4
|
+
defineProps<{ step: AppiumRecordedStep; disabled?: boolean }>();
|
|
5
|
+
defineEmits<{ update: [value: NonNullable<AppiumRecordedStep['timeoutBranch']>] }>();
|
|
6
|
+
</script>
|
|
7
|
+
|
|
8
|
+
<template>
|
|
9
|
+
<el-form-item label="超时后处理">
|
|
10
|
+
<el-select class="branch-timeout-select" size="small" popper-class="branch-timeout-popper" :model-value="step.timeoutBranch || 'stop'" :disabled="disabled" @update:model-value="$emit('update', $event)">
|
|
11
|
+
<el-option label="未配置分支(超时终止)" value="stop" />
|
|
12
|
+
<el-option :label="`进入左侧分支(${flowBranchLabel(step, 'yes')})`" value="yes" />
|
|
13
|
+
<el-option :label="`进入右侧分支(${flowBranchLabel(step, 'no')})`" value="no" />
|
|
14
|
+
</el-select>
|
|
15
|
+
</el-form-item>
|
|
16
|
+
</template>
|
|
17
|
+
|
|
18
|
+
<style>
|
|
19
|
+
.el-select.branch-timeout-select { width: 240px; max-width: 100%; flex: none; }
|
|
20
|
+
.branch-timeout-popper .el-select-dropdown__list { padding: 4px 0; }
|
|
21
|
+
.branch-timeout-popper .el-select-dropdown__item { height: 28px; line-height: 28px; padding: 0 12px; font-size: 12px; }
|
|
22
|
+
.branch-timeout-popper .el-select-dropdown__item.is-selected { color: var(--el-color-primary); }
|
|
23
|
+
</style>
|
|
@@ -2,7 +2,6 @@
|
|
|
2
2
|
import { computed, nextTick, onBeforeUnmount, onMounted, shallowRef, watch } from 'vue';
|
|
3
3
|
import { Handle, Position } from '@vue-flow/core';
|
|
4
4
|
import { CopyDocument, Delete, Edit, Plus, VideoPlay, View, WarningFilled, Connection, RefreshLeft } from '@element-plus/icons-vue';
|
|
5
|
-
import { AI_MODEL_CONFIG_HINT } from '../ai-recognition';
|
|
6
5
|
import type { AppiumRecordedStep } from '../types';
|
|
7
6
|
import {
|
|
8
7
|
PASTE_COMMAND,
|
|
@@ -176,8 +175,8 @@ watch(() => props.data, () => {
|
|
|
176
175
|
@click="handleStepClick"
|
|
177
176
|
>
|
|
178
177
|
<span class="appium-flow-step-card__content">
|
|
179
|
-
<el-tooltip v-if="data.missingAiModel"
|
|
180
|
-
<el-icon class="appium-ai-model-warning" color="var(--el-color-danger)" role="img" aria-label="AI
|
|
178
|
+
<el-tooltip v-if="data.missingAiModel" content="AI 识别操作已移除,请删除此节点并改用图像判断或原生组件判断" placement="top">
|
|
179
|
+
<el-icon class="appium-ai-model-warning" color="var(--el-color-danger)" role="img" aria-label="AI 识别操作已移除" tabindex="0">
|
|
181
180
|
<WarningFilled />
|
|
182
181
|
</el-icon>
|
|
183
182
|
</el-tooltip>
|
|
@@ -5,6 +5,8 @@ import { normalizeVisualChangeConfig } from '../visual-change';
|
|
|
5
5
|
import { longPressMode } from '../long-press';
|
|
6
6
|
import { defaultFlowKind, isBooleanCondition } from '../flow-labels';
|
|
7
7
|
import TextClickSettings from './TextClickSettings.vue';
|
|
8
|
+
import BranchTimeoutSettings from './BranchTimeoutSettings.vue';
|
|
9
|
+
import { DEFAULT_NODE_TIMEOUT_MS } from '../node-timeout';
|
|
8
10
|
import LongPressSettings from './LongPressSettings.vue';
|
|
9
11
|
import StageLogSettings from './StageLogSettings.vue';
|
|
10
12
|
import LoopSettings from './LoopSettings.vue';
|
|
@@ -116,8 +118,7 @@ function patchVisualRegion(key: keyof VisualChangeConfig['region'], value: unkno
|
|
|
116
118
|
}
|
|
117
119
|
|
|
118
120
|
function patchTimeout(value: unknown) {
|
|
119
|
-
|
|
120
|
-
patchStep({ timeoutMs: timeout || undefined });
|
|
121
|
+
patchStep({ timeoutMs: value == null || value === '' ? undefined : Math.max(0, toInteger(value, DEFAULT_NODE_TIMEOUT_MS)) });
|
|
121
122
|
}
|
|
122
123
|
|
|
123
124
|
const showSelector = computed(() => props.step.type !== 'loop' && props.step.selector && (
|
|
@@ -152,21 +153,10 @@ function patchSelector(patch: Partial<AppiumSelector>) {
|
|
|
152
153
|
@update:model-value="patchStep({ note: String($event) || undefined })"
|
|
153
154
|
/>
|
|
154
155
|
</el-form-item>
|
|
155
|
-
<div
|
|
156
|
-
<el-form-item label="节点类型">
|
|
157
|
-
<el-select
|
|
158
|
-
:model-value="defaultKind()"
|
|
159
|
-
:disabled="disabled || isBooleanCondition(step) || step.type === 'stopApp' || step.type === 'extractVariable' || step.type === 'log' || step.type === 'openGallery' || step.type === 'endFlow' || step.type === 'loop' || step.type === 'breakLoop'"
|
|
160
|
-
@update:model-value="patchFlow({ nodeKind: $event as FlowKind })"
|
|
161
|
-
>
|
|
162
|
-
<el-option label="操作" value="action" />
|
|
163
|
-
<el-option label="判断" value="condition" />
|
|
164
|
-
<el-option label="校验" value="assertion" />
|
|
165
|
-
</el-select>
|
|
166
|
-
</el-form-item>
|
|
156
|
+
<div>
|
|
167
157
|
<el-form-item v-if="!['longPress', 'stopApp', 'log', 'openGallery', 'endFlow', 'loop', 'breakLoop'].includes(step.type)" label="超时时间 ms">
|
|
168
158
|
<el-input-number
|
|
169
|
-
:model-value="step.timeoutMs
|
|
159
|
+
:model-value="step.timeoutMs ?? (step.type === 'delay' ? 1000 : DEFAULT_NODE_TIMEOUT_MS)"
|
|
170
160
|
:disabled="disabled"
|
|
171
161
|
:min="0"
|
|
172
162
|
:max="999999"
|
|
@@ -174,6 +164,7 @@ function patchSelector(patch: Partial<AppiumSelector>) {
|
|
|
174
164
|
@update:model-value="patchTimeout($event)"
|
|
175
165
|
/>
|
|
176
166
|
</el-form-item>
|
|
167
|
+
<BranchTimeoutSettings v-if="defaultKind() === 'condition'" :step="step" :disabled="disabled" @update="patchStep({ timeoutBranch: $event })" />
|
|
177
168
|
</div>
|
|
178
169
|
<LongPressSettings v-if="step.type === 'longPress'" :step="step" :disabled="disabled" @update="patchStep" />
|
|
179
170
|
<el-form-item v-if="['waitActivity', 'launchApp', 'stopApp', 'clearAppData'].includes(step.type)" :label="step.type === 'waitActivity' ? '目标 Activity' : '目标 APP 包名'">
|
|
@@ -206,12 +197,6 @@ function patchSelector(patch: Partial<AppiumSelector>) {
|
|
|
206
197
|
<LoopSettings v-if="step.type === 'loop'" :step="step" :disabled="disabled" @update="patchStep" />
|
|
207
198
|
<BreakLoopSettings v-if="step.type === 'breakLoop'" :step="step" :steps="steps" :disabled="disabled" @update="patchStep" />
|
|
208
199
|
<TextClickSettings v-if="step.type === 'textClick'" :step="step" :disabled="disabled" @update="patchStep" />
|
|
209
|
-
<el-form-item v-if="step.type === 'aiRecognition'" label="识别内容">
|
|
210
|
-
<el-input
|
|
211
|
-
:model-value="step.value || ''" type="textarea" :rows="3" maxlength="4000"
|
|
212
|
-
:disabled="disabled" @update:model-value="patchStep({ value: String($event) })"
|
|
213
|
-
/>
|
|
214
|
-
</el-form-item>
|
|
215
200
|
<el-form-item
|
|
216
201
|
v-if="step.type === 'input' || step.type === 'inputIfExists' || step.type === 'assertText'"
|
|
217
202
|
label="文本内容"
|
|
@@ -4,9 +4,11 @@ import { ElMessage } from 'element-plus';
|
|
|
4
4
|
import { Aim, Camera, Upload, QuestionFilled } from '@element-plus/icons-vue';
|
|
5
5
|
import { IMAGE_CHECK_MODES, validateImageCheck, imageTemplateSize, imageTemplateRegionIssue, expandedImageTemplateRegion, type ImageCheckConfig } from '../image-check';
|
|
6
6
|
import { captureImageCheckRegion } from '../api';
|
|
7
|
+
import BranchTimeoutSettings from './BranchTimeoutSettings.vue';
|
|
8
|
+
import type { AppiumRecordedStep } from '../types';
|
|
7
9
|
|
|
8
|
-
const props = defineProps<{ config: ImageCheckConfig; deviceId: string; hasElement: boolean; editing: boolean; picking: boolean }>();
|
|
9
|
-
const emit = defineEmits<{ update: [config: ImageCheckConfig]; close: []; confirm: []; pick: []; useElement: [] }>();
|
|
10
|
+
const props = defineProps<{ config: ImageCheckConfig; deviceId: string; hasElement: boolean; editing: boolean; picking: boolean; timeoutBranch?: AppiumRecordedStep['timeoutBranch'] }>();
|
|
11
|
+
const emit = defineEmits<{ update: [config: ImageCheckConfig]; timeoutBranch: [value: NonNullable<AppiumRecordedStep['timeoutBranch']>]; close: []; confirm: []; pick: []; useElement: [] }>();
|
|
10
12
|
const busy = ref(false);
|
|
11
13
|
const regionIssue = computed(() => imageTemplateRegionIssue(props.config));
|
|
12
14
|
const expandedRegion = computed(() => expandedImageTemplateRegion(props.config));
|
|
@@ -16,7 +18,7 @@ const slots = computed(() => props.config.mode === 'state'
|
|
|
16
18
|
? [{ key: 'template' as const, label: '选中模板' }, { key: 'negativeTemplate' as const, label: '未选中模板' }]
|
|
17
19
|
: props.config.mode === 'template' ? [{ key: 'template' as const, label: '模板' }] : []);
|
|
18
20
|
const numericFields = computed(() => [
|
|
19
|
-
...(['template', 'state'].includes(props.config.mode) ? [{ key: 'threshold' as const, label: '
|
|
21
|
+
...(['template', 'state'].includes(props.config.mode) ? [{ key: 'threshold' as const, label: '匹配严格度 (%)', min: 1, max: 100, step: 1 }] : []),
|
|
20
22
|
...(props.config.mode === 'state' ? [{ key: 'minScoreGap' as const, label: '两种状态最小得分差', min: 0.001, max: 1, step: 0.01 }] : []),
|
|
21
23
|
...(['black', 'color', 'change'].includes(props.config.mode) ? [
|
|
22
24
|
{ key: 'tolerance' as const, label: props.config.mode === 'black' ? '暗色亮度阈值 (0–255)' : 'RGB 通道容差 (0–255)', min: 0, max: 255, step: 1 },
|
|
@@ -108,10 +110,10 @@ async function upload(event: Event) {
|
|
|
108
110
|
<el-alert v-if="regionIssue" :title="regionIssue" type="warning" show-icon :closable="false" class="image-check-region-warning">
|
|
109
111
|
<el-button v-if="expandedRegion" size="small" @click="patch({ region: expandedRegion })">扩大检测区域</el-button>
|
|
110
112
|
</el-alert>
|
|
111
|
-
<el-form-item v-if="config.mode === 'template' || config.mode === 'change'" label="判断条件">
|
|
113
|
+
<el-form-item v-if="config.mode === 'template' || config.mode === 'change'" :label="config.mode === 'template' ? '预期匹配结果' : '判断条件'">
|
|
112
114
|
<el-radio-group :model-value="config.expectation" @update:model-value="patch({ expectation: $event as 'present' | 'absent' })">
|
|
113
|
-
<el-radio-button value="present">{{ config.mode === 'template' ? '
|
|
114
|
-
<el-radio-button value="absent">{{ config.mode === 'template' ? '
|
|
115
|
+
<el-radio-button value="present">{{ config.mode === 'template' ? '匹配到模板' : '画面有变化' }}</el-radio-button>
|
|
116
|
+
<el-radio-button value="absent">{{ config.mode === 'template' ? '未匹配到模板' : '持续无明显变化' }}</el-radio-button>
|
|
115
117
|
</el-radio-group>
|
|
116
118
|
</el-form-item>
|
|
117
119
|
<el-form-item v-if="config.mode === 'color'" label="目标颜色 #RRGGBB">
|
|
@@ -119,10 +121,29 @@ async function upload(event: Event) {
|
|
|
119
121
|
<el-input :model-value="config.color" maxlength="7" @update:model-value="patch({ color: $event })" />
|
|
120
122
|
</el-form-item>
|
|
121
123
|
<div class="image-check-grid">
|
|
122
|
-
<
|
|
123
|
-
<el-
|
|
124
|
-
|
|
124
|
+
<template v-for="field in numericFields" :key="field.key">
|
|
125
|
+
<el-form-item :label="field.label">
|
|
126
|
+
<template v-if="field.key === 'threshold'" #label>
|
|
127
|
+
<span class="image-check-threshold-label">{{ field.label }}
|
|
128
|
+
<el-tooltip effect="dark" placement="top" :show-after="200">
|
|
129
|
+
<template #content>
|
|
130
|
+
<div class="image-check-threshold-help">
|
|
131
|
+
当前画面与参考模板的匹配得分达到此值,才算匹配成功,得分不是正确概率。
|
|
132
|
+
数值越高越严格,可能漏掉有细微变化的目标;越低越宽松,可能误认相似图案。
|
|
133
|
+
例如设为 98%,需要匹配得分达到 98%。设为 100% 时,极细微的像素差异也会判为不匹配。图片状态判断还会结合两种模板的最小得分差,得分太接近时无法判定。
|
|
134
|
+
</div>
|
|
135
|
+
</template>
|
|
136
|
+
<el-button class="image-check-threshold-button" :icon="QuestionFilled" text aria-label="匹配严格度说明" @click.prevent />
|
|
137
|
+
</el-tooltip>
|
|
138
|
+
</span>
|
|
139
|
+
</template>
|
|
140
|
+
<el-input-number :model-value="field.key === 'threshold' ? Number((config.threshold * 100).toFixed(8)) : config[field.key]" :min="field.min" :max="field.max" :step="field.step" controls-position="right" @update:model-value="$event !== undefined && patch({ [field.key]: field.key === 'threshold' ? $event / 100 : $event })" />
|
|
141
|
+
<el-alert v-if="field.key === 'threshold' && config.threshold === 1" title="100% 要求完全匹配,极细微的像素差异也会判为不匹配。" type="warning" :closable="false" show-icon class="image-check-strict-warning" />
|
|
142
|
+
</el-form-item>
|
|
143
|
+
<BranchTimeoutSettings v-if="field.key === 'durationMs'" style="grid-column: 1 / -1" :step="{ id: '', label: '', type: 'imageCheck', timeoutBranch }" :disabled="busy" @update="emit('timeoutBranch', $event)" />
|
|
144
|
+
</template>
|
|
125
145
|
</div>
|
|
146
|
+
<BranchTimeoutSettings v-if="config.mode === 'state'" :step="{ id: '', label: '', type: 'imageCheck', timeoutBranch }" :disabled="busy" @update="emit('timeoutBranch', $event)" />
|
|
126
147
|
</el-form>
|
|
127
148
|
<template #footer><el-button :disabled="busy" @click="emit('close')">取消</el-button><el-button type="primary" :disabled="busy" @click="confirm">{{ editing ? '保存' : '添加' }}</el-button></template>
|
|
128
149
|
</el-dialog>
|
|
@@ -135,9 +156,17 @@ async function upload(event: Event) {
|
|
|
135
156
|
.image-check-dialog .el-input-number { width: 100%; }
|
|
136
157
|
.image-check-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 12px; }
|
|
137
158
|
.image-check-help { margin-left: 8px; color: #606266; }
|
|
159
|
+
.image-check-grid .el-form-item__label { display: flex; align-items: center; min-height: 20px; }
|
|
160
|
+
.image-check-threshold-label { display: inline-flex; align-items: center; gap: 4px; }
|
|
161
|
+
.image-check-dialog .el-button.image-check-threshold-button { flex: 0 0 20px; width: 20px; height: 20px; min-height: 20px; padding: 0; font-size: 14px; }
|
|
162
|
+
.image-check-threshold-help { max-width: min(300px, calc(100vw - 40px)); font-size: 12px; line-height: 1.7; overflow-wrap: anywhere; }
|
|
163
|
+
.image-check-strict-warning { margin-top: 6px; }
|
|
138
164
|
.image-check-template { display: grid; gap: 6px; margin-bottom: 16px; min-width: 0; }
|
|
139
165
|
.image-check-size { color: #606266; font-size: 12px; }
|
|
140
166
|
.image-check-region-warning { margin-bottom: 16px; }
|
|
141
167
|
.image-check-template .el-image, .image-check-empty { height: 96px; width: 100%; background: #eff5f3; border: 1px solid #d5dfdb; border-radius: 4px; }
|
|
142
168
|
.image-check-empty { display: grid; place-items: center; color: #606266; }
|
|
169
|
+
@media (max-width: 480px) {
|
|
170
|
+
.image-check-grid { grid-template-columns: minmax(0, 1fr); }
|
|
171
|
+
}
|
|
143
172
|
</style>
|
|
@@ -119,7 +119,6 @@ const actionGroups: Array<{ title: string; actions: Array<{ type: InsertAction;
|
|
|
119
119
|
actions: [
|
|
120
120
|
{ type: 'delay', label: '添加延时' },
|
|
121
121
|
{ type: 'popupCondition', label: '判断存在' },
|
|
122
|
-
{ type: 'aiRecognition', label: 'AI 识别' },
|
|
123
122
|
{ type: 'imageCheck', label: '图像判断' },
|
|
124
123
|
{ type: 'tapIfExists', label: '存在则点击' },
|
|
125
124
|
{ type: 'inputIfExists', label: '存在则输入' },
|
|
@@ -89,7 +89,6 @@ const insertActionGroups: FlowActionGroup[] = [
|
|
|
89
89
|
actions: [
|
|
90
90
|
{ type: 'delay', label: '添加延时' },
|
|
91
91
|
{ type: 'popupCondition', label: '判断存在' },
|
|
92
|
-
{ type: 'aiRecognition', label: 'AI 识别' },
|
|
93
92
|
{ type: 'imageCheck', label: '图像判断' },
|
|
94
93
|
{ type: 'tapIfExists', label: '存在则点击' },
|
|
95
94
|
{ type: 'inputIfExists', label: '存在则输入' },
|
|
@@ -460,8 +460,8 @@ export function buildFlowGraph(
|
|
|
460
460
|
&& item.step.type !== 'clearAppData'
|
|
461
461
|
&& item.step.visualChange?.role !== 'end',
|
|
462
462
|
canEditInput: ['input', 'inputIfExists', 'imageCheck', 'runScript'].includes(item.step.type),
|
|
463
|
-
canExecute: item.step.type === 'launchApp' || item.step.type === 'clearAppData'
|
|
464
|
-
missingAiModel: item.step.type === 'aiRecognition'
|
|
463
|
+
canExecute: item.step.type === 'launchApp' || item.step.type === 'clearAppData',
|
|
464
|
+
missingAiModel: item.step.type === 'aiRecognition',
|
|
465
465
|
},
|
|
466
466
|
});
|
|
467
467
|
};
|