android-midscene-automation 1.1.3 → 1.1.5
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 +9 -0
- package/README.md +9 -0
- package/artemis-main/apps/admin_console/server.py +33 -0
- package/package.json +1 -1
- package/server/appium-recorder/appium-runner.ts +231 -78
- package/server/appium-recorder/element-tap.test.ts +171 -6
- package/server/appium-recorder/managed-cleanup.test.ts +7 -7
- package/server/appium-recorder/replay-lifecycle.test.ts +4 -4
- package/server/appium-recorder/repository.ts +39 -9
- package/server/appium-recorder/request-timeout.ts +5 -0
- package/server/appium-recorder/routes.ts +38 -15
- package/server/appium-recorder/script-import.test.ts +45 -0
- package/server/appium-recorder/tree-dump.ts +44 -0
- package/server/remote-agents/replay-integration.test.ts +4 -4
- package/src/App.vue +10 -10
- package/src/appium-recorder/AppiumPage.vue +58 -12
- package/src/appium-recorder/RunHistoryDialog.vue +91 -51
- package/src/appium-recorder/api.ts +4 -0
- package/src/appium-recorder/components/ComponentTree.vue +72 -10
- package/src/appium-recorder/components/FlowStepEditor.vue +51 -9
- package/src/appium-recorder/components/NodeDetail.vue +129 -52
- package/src/appium-recorder/components/RecordedSteps.vue +1 -0
- package/src/appium-recorder/components/ScriptParameterSettings.vue +44 -10
- package/src/appium-recorder/components/VariableTable.vue +44 -5
- package/src/appium-recorder/tree.ts +1 -0
- package/src/components/config/AiDeduplicationSettings.vue +0 -1
- package/src/components/config/MidsceneConfigPanel.vue +12 -2
- package/src/style.css +296 -55
|
@@ -26,7 +26,7 @@ import {
|
|
|
26
26
|
downloadAppiumScript,
|
|
27
27
|
getAppiumScripts,
|
|
28
28
|
getAppiumTree,
|
|
29
|
-
|
|
29
|
+
importAppiumScripts,
|
|
30
30
|
launchAppiumDeviceApp,
|
|
31
31
|
replayAppiumScript,
|
|
32
32
|
saveAppiumScript,
|
|
@@ -2053,23 +2053,67 @@ async function downloadScript(script: AppiumRecordedScript) {
|
|
|
2053
2053
|
}
|
|
2054
2054
|
|
|
2055
2055
|
function chooseScriptImportFile() {
|
|
2056
|
+
if (importingScript.value) return;
|
|
2056
2057
|
scriptImportInput.value?.click();
|
|
2057
2058
|
}
|
|
2058
2059
|
|
|
2059
2060
|
async function importScriptFile(event: Event) {
|
|
2060
2061
|
const input = event.currentTarget as HTMLInputElement;
|
|
2061
|
-
const
|
|
2062
|
+
const files = Array.from(input.files || []);
|
|
2062
2063
|
input.value = '';
|
|
2063
|
-
if (!
|
|
2064
|
+
if (!files.length) return;
|
|
2064
2065
|
importingScript.value = true;
|
|
2066
|
+
|
|
2067
|
+
const importedScripts: AppiumRecordedScript[] = [];
|
|
2068
|
+
const failedFiles: Array<{ name: string; reason: string }> = [];
|
|
2065
2069
|
try {
|
|
2066
|
-
const
|
|
2067
|
-
const
|
|
2068
|
-
|
|
2069
|
-
|
|
2070
|
-
|
|
2071
|
-
|
|
2072
|
-
|
|
2070
|
+
const parsedFiles: Array<{ name: string; payload: unknown }> = [];
|
|
2071
|
+
for (const file of files) {
|
|
2072
|
+
try {
|
|
2073
|
+
const payload = JSON.parse(await file.text()) as unknown;
|
|
2074
|
+
parsedFiles.push({ name: file.name, payload });
|
|
2075
|
+
} catch (error) {
|
|
2076
|
+
failedFiles.push({
|
|
2077
|
+
name: file.name,
|
|
2078
|
+
reason: error instanceof Error ? error.message : '文件格式无效',
|
|
2079
|
+
});
|
|
2080
|
+
}
|
|
2081
|
+
}
|
|
2082
|
+
|
|
2083
|
+
if (parsedFiles.length) {
|
|
2084
|
+
try {
|
|
2085
|
+
const result = await importAppiumScripts(parsedFiles.map(item => item.payload));
|
|
2086
|
+
importedScripts.push(...result.scripts);
|
|
2087
|
+
} catch (error) {
|
|
2088
|
+
const reason = error instanceof Error ? error.message : '导入失败';
|
|
2089
|
+
failedFiles.push(...parsedFiles.map(file => ({ name: file.name, reason })));
|
|
2090
|
+
}
|
|
2091
|
+
}
|
|
2092
|
+
|
|
2093
|
+
if (importedScripts.length) {
|
|
2094
|
+
try {
|
|
2095
|
+
await loadScripts();
|
|
2096
|
+
} catch (error) {
|
|
2097
|
+
ElMessage.warning(`脚本已导入,但刷新列表失败:${error instanceof Error ? error.message : '未知错误'}`);
|
|
2098
|
+
return;
|
|
2099
|
+
}
|
|
2100
|
+
}
|
|
2101
|
+
|
|
2102
|
+
if (!failedFiles.length) {
|
|
2103
|
+
ElMessage.success(files.length === 1
|
|
2104
|
+
? `脚本「${importedScripts[0].name}」已导入`
|
|
2105
|
+
: `已成功导入 ${importedScripts.length} 个脚本`);
|
|
2106
|
+
return;
|
|
2107
|
+
}
|
|
2108
|
+
|
|
2109
|
+
const failedSummary = failedFiles
|
|
2110
|
+
.map(({ name, reason }) => `${name}(${reason})`)
|
|
2111
|
+
.join(';');
|
|
2112
|
+
if (importedScripts.length) {
|
|
2113
|
+
ElMessage.warning(`已导入 ${importedScripts.length} 个脚本,${failedFiles.length} 个失败:${failedSummary}`);
|
|
2114
|
+
} else {
|
|
2115
|
+
ElMessage.error(`导入失败:${failedSummary}`);
|
|
2116
|
+
}
|
|
2073
2117
|
} finally {
|
|
2074
2118
|
importingScript.value = false;
|
|
2075
2119
|
}
|
|
@@ -2374,7 +2418,7 @@ watch(
|
|
|
2374
2418
|
<el-tab-pane label="脚本列表" name="scripts">
|
|
2375
2419
|
<div class="appium-script-list">
|
|
2376
2420
|
<div class="appium-script-list__toolbar">
|
|
2377
|
-
<el-tooltip content="
|
|
2421
|
+
<el-tooltip content="导入脚本(支持多选)" placement="top" :show-after="200"><span class="appium-script-icon">
|
|
2378
2422
|
<el-button size="small" :icon="Upload" aria-label="导入脚本" :loading="importingScript" @click="chooseScriptImportFile" />
|
|
2379
2423
|
</span></el-tooltip>
|
|
2380
2424
|
<input
|
|
@@ -2382,6 +2426,7 @@ watch(
|
|
|
2382
2426
|
class="appium-script-list__file-input"
|
|
2383
2427
|
type="file"
|
|
2384
2428
|
accept="application/json,.json"
|
|
2429
|
+
multiple
|
|
2385
2430
|
@change="importScriptFile"
|
|
2386
2431
|
>
|
|
2387
2432
|
</div>
|
|
@@ -2520,7 +2565,8 @@ watch(
|
|
|
2520
2565
|
<el-dialog
|
|
2521
2566
|
v-model="linkedScriptDialogVisible"
|
|
2522
2567
|
title="连接脚本"
|
|
2523
|
-
width="
|
|
2568
|
+
width="640px"
|
|
2569
|
+
class="appium-linked-script-dialog"
|
|
2524
2570
|
align-center
|
|
2525
2571
|
@closed="closeLinkedScriptDialog"
|
|
2526
2572
|
>
|
|
@@ -72,63 +72,74 @@ onMounted(load);
|
|
|
72
72
|
</script>
|
|
73
73
|
|
|
74
74
|
<template>
|
|
75
|
-
<el-dialog :model-value="true" :title="`${scriptName} · 历史结果对比`" width="min(1200px,
|
|
75
|
+
<el-dialog :model-value="true" :title="`${scriptName} · 历史结果对比`" width="min(1200px, calc(100vw - 32px))" class="run-history-dialog" align-center append-to-body draggable overflow @closed="emit('close')">
|
|
76
76
|
<div v-loading="loading" class="run-history">
|
|
77
77
|
<div class="history-toolbar">
|
|
78
78
|
<el-select v-model="version" clearable placeholder="全部应用版本" aria-label="应用版本"><el-option v-for="item in versions" :key="item" :value="item" /></el-select>
|
|
79
79
|
<el-select v-model="device" clearable placeholder="全部设备" aria-label="设备"><el-option v-for="item in devices" :key="item" :value="item" /></el-select>
|
|
80
80
|
<el-tooltip content="刷新历史"><el-button :icon="Refresh" aria-label="刷新历史" @click="load" /></el-tooltip>
|
|
81
|
-
<el-button :disabled="selected.length !== 2" :loading="comparing" @click="compare">对比两次运行</el-button>
|
|
81
|
+
<el-button type="primary" :disabled="selected.length !== 2" :loading="comparing" @click="compare">对比两次运行</el-button>
|
|
82
82
|
<el-tooltip content="删除选中历史"><el-button :icon="Delete" type="danger" plain :disabled="!selected.length" aria-label="删除选中历史" @click="remove" /></el-tooltip>
|
|
83
83
|
</div>
|
|
84
84
|
<el-alert v-if="error" :title="error" type="error" :closable="false" />
|
|
85
85
|
<el-empty v-else-if="!runs.length && !loading" description="暂无历史结果,后续运行会自动记录" />
|
|
86
86
|
<template v-else>
|
|
87
|
-
<
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
<
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
<
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
87
|
+
<div class="history-summary">
|
|
88
|
+
<strong>{{ filtered.length }}</strong><span>次运行</span>
|
|
89
|
+
<i />
|
|
90
|
+
<strong>{{ stats.passRate === null ? '—' : `${(stats.passRate * 100).toFixed(1)}%` }}</strong><span>通过率(不含手动终止)</span>
|
|
91
|
+
</div>
|
|
92
|
+
<section class="history-panel">
|
|
93
|
+
<h3>耗时趋势</h3>
|
|
94
|
+
<svg class="history-trend" viewBox="0 0 800 160" role="img" aria-label="按时间从早到晚的运行耗时趋势">
|
|
95
|
+
<path d="M20 10V130H790" fill="none" stroke="#a0a8ad" />
|
|
96
|
+
<polyline :points="points" fill="none" stroke="#168a75" stroke-width="2" />
|
|
97
|
+
<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'">
|
|
98
|
+
<title>{{ time(run.startedAt) }} · {{ seconds(run.durationMs) }} · {{ run.appVersion || '未知版本' }}</title>
|
|
99
|
+
</circle>
|
|
100
|
+
<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>
|
|
101
|
+
</svg>
|
|
102
|
+
</section>
|
|
103
|
+
<section class="history-panel">
|
|
104
|
+
<h3>运行记录</h3>
|
|
105
|
+
<el-table :data="filtered" row-key="id" max-height="270" @selection-change="selected = $event">
|
|
106
|
+
<el-table-column type="selection" width="42" />
|
|
107
|
+
<el-table-column label="运行时间" min-width="180"><template #default="{ row }">{{ time(row.startedAt) }}</template></el-table-column>
|
|
108
|
+
<el-table-column prop="appVersion" label="应用版本" min-width="140"><template #default="{ row }">{{ row.appVersion || '未知' }}</template></el-table-column>
|
|
109
|
+
<el-table-column prop="deviceId" label="设备" min-width="140" />
|
|
110
|
+
<el-table-column label="结果" width="90"><template #default="{ row }"><el-tag :type="row.status === 'passed' ? 'success' : row.status === 'failed' ? 'danger' : 'info'" size="small">{{ labels[row.status as keyof typeof labels] }}</el-tag></template></el-table-column>
|
|
111
|
+
<el-table-column label="耗时" width="100"><template #default="{ row }">{{ seconds(row.durationMs) }}</template></el-table-column>
|
|
112
|
+
<el-table-column label="录像" width="70"><template #default="{ row }"><el-tooltip v-if="row.video" content="播放回放视频"><el-button :icon="VideoPlay" text aria-label="播放回放视频" @click="videoRun = row" /></el-tooltip></template></el-table-column>
|
|
113
|
+
</el-table>
|
|
114
|
+
</section>
|
|
115
|
+
<section class="history-panel">
|
|
116
|
+
<h3>失败节点分布</h3>
|
|
117
|
+
<el-table :data="stats.failures" max-height="220" empty-text="暂无节点失败记录">
|
|
118
|
+
<el-table-column prop="label" label="节点" min-width="220" />
|
|
119
|
+
<el-table-column label="失败 / 到达次数" width="145"><template #default="{ row }">{{ row.failures }} / {{ row.runs }}</template></el-table-column>
|
|
120
|
+
<el-table-column label="观察结果" width="130"><template #default="{ row }">{{ row.runs < 2 ? '样本不足' : row.failures === row.runs ? '持续失败' : '偶发失败' }}</template></el-table-column>
|
|
121
|
+
<el-table-column label="首次失败 / 版本" min-width="190"><template #default="{ row }">{{ time(row.firstFailure) }} · {{ row.version || '未知' }}</template></el-table-column>
|
|
122
|
+
</el-table>
|
|
123
|
+
</section>
|
|
113
124
|
</template>
|
|
114
|
-
<section v-if="details.length === 2" class="history-comparison">
|
|
115
|
-
<h3
|
|
125
|
+
<section v-if="details.length === 2" class="history-panel history-comparison">
|
|
126
|
+
<div class="history-comparison__header"><h3>运行对比</h3><el-tag type="info">总耗时差 {{ seconds(details[1].durationMs - details[0].durationMs) }}</el-tag></div>
|
|
116
127
|
<el-alert v-if="details[0].deviceId !== details[1].deviceId || details[0].appPackage !== details[1].appPackage" title="两次运行的设备或应用不同,耗时差不能直接归因于版本变化" type="warning" :closable="false" />
|
|
117
128
|
<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>
|
|
118
129
|
<div class="comparison-columns">
|
|
119
|
-
<section v-for="run in details" :key="run.id">
|
|
120
|
-
<h4>{{ time(run.startedAt) }}
|
|
121
|
-
<p>{{ run.appVersion || '未知版本' }} · {{ run.deviceId }} · {{ seconds(run.durationMs) }}</p>
|
|
122
|
-
<p v-if="nodeKey">节点累计耗时(含截图开销):{{ nodeDuration(run) }}</p>
|
|
123
|
-
<p v-if="!run.frames.some(frame => !nodeKey || frame.key === nodeKey)">未捕获到对应截图</p>
|
|
130
|
+
<section v-for="run in details" :key="run.id" class="comparison-run-card">
|
|
131
|
+
<div class="comparison-run-card__header"><h4>{{ time(run.startedAt) }}</h4><el-tag :type="run.status === 'passed' ? 'success' : run.status === 'failed' ? 'danger' : 'info'" size="small">{{ labels[run.status] }}</el-tag></div>
|
|
132
|
+
<p class="comparison-run-card__meta">{{ run.appVersion || '未知版本' }} · {{ run.deviceId }} · {{ seconds(run.durationMs) }}</p>
|
|
133
|
+
<p v-if="nodeKey" class="comparison-run-card__note">节点累计耗时(含截图开销):{{ nodeDuration(run) }}</p>
|
|
134
|
+
<p v-if="!run.frames.some(frame => !nodeKey || frame.key === nodeKey)" class="comparison-run-card__note">未捕获到对应截图</p>
|
|
124
135
|
<div class="history-frames">
|
|
125
136
|
<figure v-for="(frame, index) in run.frames.filter(frame => !nodeKey || frame.key === nodeKey)" :key="index">
|
|
126
137
|
<figcaption>{{ frame.label }} · {{ frame.phase === 'before' ? '执行前' : frame.phase === 'after' ? '执行后' : frame.phase === 'observation' ? '观察采样' : frame.phase }} · {{ time(frame.capturedAt) }}</figcaption>
|
|
127
138
|
<el-image :src="frame.imageUrl" :preview-src-list="[frame.imageUrl]" preview-teleported fit="contain" loading="lazy" />
|
|
128
139
|
</figure>
|
|
129
140
|
</div>
|
|
130
|
-
<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>
|
|
131
|
-
<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>
|
|
141
|
+
<h4>节点结果</h4><ul class="history-node-results"><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>
|
|
142
|
+
<h4>关键日志 / 完整日志</h4><el-input v-model="logQuery" class="history-log-search" clearable placeholder="日志关键字,如 stageLog" aria-label="日志关键字" /><pre class="history-log-output">{{ logQuery ? run.output.split('\n').filter(line => line.toLowerCase().includes(logQuery.toLowerCase())).join('\n') || '无匹配日志' : run.output }}</pre>
|
|
132
143
|
</section>
|
|
133
144
|
</div>
|
|
134
145
|
</section>
|
|
@@ -139,25 +150,54 @@ onMounted(load);
|
|
|
139
150
|
</el-select>
|
|
140
151
|
<video v-if="videoRun" :src="`${base}/${encodeURIComponent(videoRun.id)}/video?segment=${videoSegment}`" controls preload="metadata" style="width:100%;max-height:70vh" />
|
|
141
152
|
</el-dialog>
|
|
153
|
+
<template #footer><el-button type="primary" @click="emit('close')">关闭</el-button></template>
|
|
142
154
|
</el-dialog>
|
|
143
155
|
</template>
|
|
144
156
|
|
|
145
157
|
<style scoped>
|
|
146
|
-
.run-history { max-height:
|
|
147
|
-
.history-
|
|
158
|
+
:global(.run-history-dialog.el-dialog) { display: flex; flex-direction: column; max-height: calc(100dvh - 32px); overflow: hidden; padding: 0; }
|
|
159
|
+
:global(.run-history-dialog > .el-dialog__body) { flex: 1; min-height: 0; overflow: hidden; padding: 20px; }
|
|
160
|
+
.run-history { display: flex; flex-direction: column; gap: 16px; height: 100%; min-height: 0; overflow: auto; padding: 1px 8px 12px 1px; scrollbar-gutter: stable; }
|
|
161
|
+
.history-toolbar { display: flex; gap: 10px; flex-wrap: wrap; align-items: center; padding: 12px; border: 1px solid var(--ui-border); border-radius: var(--ui-radius-control); background: var(--ui-bg-soft); box-shadow: var(--ui-shadow-inset); }
|
|
148
162
|
.history-toolbar .el-select { width: 190px; }
|
|
149
|
-
.history-
|
|
163
|
+
.history-summary { display: flex; align-items: baseline; gap: 6px; padding: 12px 16px; border: 1px solid #dbeafe; border-radius: var(--ui-radius-control); background: #eff6ff; color: var(--ui-text-secondary); font-size: 13px; }
|
|
164
|
+
.history-summary strong { color: var(--ui-primary); font-size: 20px; line-height: 1; }
|
|
165
|
+
.history-summary i { align-self: stretch; width: 1px; margin: 0 8px; background: #bfdbfe; }
|
|
166
|
+
.history-panel { padding: 16px; border: 1px solid var(--ui-border); border-radius: var(--ui-radius); background: var(--ui-surface); box-shadow: var(--ui-shadow-card); }
|
|
167
|
+
.history-trend { display: block; box-sizing: border-box; width: 100%; height: 160px; border: 1px solid var(--ui-border); border-radius: var(--ui-radius-control); background: linear-gradient(180deg, #f8fafc, #f3faf7); box-shadow: var(--ui-shadow-inset); }
|
|
150
168
|
.history-trend text { font-size: 12px; fill: #52615b; }
|
|
151
|
-
h3 {
|
|
152
|
-
h4 {
|
|
169
|
+
h3 { margin: 0 0 12px; color: var(--ui-text); font-size: 15px; }
|
|
170
|
+
h4 { margin: 16px 0 8px; color: var(--ui-text); font-size: 14px; }
|
|
171
|
+
.history-comparison { display: grid; gap: 14px; }
|
|
172
|
+
.history-comparison__header { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
|
|
173
|
+
.history-comparison__header h3 { margin: 0; }
|
|
153
174
|
.history-comparison > .el-select { width: 100%; }
|
|
154
|
-
.comparison-columns { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap:
|
|
155
|
-
.comparison-
|
|
156
|
-
.
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
.
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
175
|
+
.comparison-columns { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 16px; }
|
|
176
|
+
.comparison-run-card { min-width: 0; padding: 16px; border: 1px solid var(--ui-border); border-radius: var(--ui-radius-control); background: #fbfcfe; box-shadow: 0 3px 10px rgb(15 23 42 / 6%); overflow-wrap: anywhere; }
|
|
177
|
+
.comparison-run-card__header { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
|
|
178
|
+
.comparison-run-card__header h4 { margin: 0; }
|
|
179
|
+
.comparison-run-card__meta { margin: 6px 0 0; color: var(--ui-text-muted); font-size: 12px; }
|
|
180
|
+
.comparison-run-card__note { margin: 10px 0 0; padding: 8px 10px; border-radius: 8px; background: var(--ui-bg-soft); color: var(--ui-text-secondary); font-size: 12px; }
|
|
181
|
+
.history-frames { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; max-height: 360px; overflow: auto; padding: 2px; }
|
|
182
|
+
figure { margin: 0; overflow: hidden; border: 1px solid var(--ui-border); border-radius: 10px; background: var(--ui-surface); box-shadow: 0 2px 6px rgb(15 23 42 / 6%); }
|
|
183
|
+
figcaption { min-height: 34px; padding: 8px 10px; border-bottom: 1px solid var(--ui-border); background: var(--ui-bg-soft); color: var(--ui-text-secondary); font-size: 12px; line-height: 1.5; }
|
|
184
|
+
.el-image { display: block; width: 100%; height: 150px; }
|
|
185
|
+
.history-node-results { box-sizing: border-box; max-height: 160px; margin: 0; overflow: auto; padding: 10px 12px 10px 30px; border: 1px solid var(--ui-border); border-radius: 10px; background: var(--ui-surface); color: var(--ui-text-secondary); font-size: 12px; line-height: 1.8; }
|
|
186
|
+
.history-log-search { margin-bottom: 10px; }
|
|
187
|
+
.history-log-output { box-sizing: border-box; white-space: pre-wrap; overflow-wrap: anywhere; max-height: 280px; overflow: auto; margin: 0; padding: 12px; border: 1px solid #3c3c3c; border-radius: 6px; background: #1e1e1e; box-shadow: inset 0 1px 0 rgb(255 255 255 / 6%), inset 0 4px 12px rgb(0 0 0 / 32%), 0 4px 10px rgb(15 23 42 / 12%), 0 18px 30px -20px rgb(15 23 42 / 45%); color: #d4d4d4; font: 12px/1.6 "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace; scrollbar-color: #424242 #1e1e1e; }
|
|
188
|
+
.history-log-output::-webkit-scrollbar { width: 10px; height: 10px; }
|
|
189
|
+
.history-log-output::-webkit-scrollbar-track { background: #1e1e1e; }
|
|
190
|
+
.history-log-output::-webkit-scrollbar-thumb { border: 2px solid #1e1e1e; border-radius: 6px; background: #424242; }
|
|
191
|
+
.history-log-output::-webkit-scrollbar-thumb:hover { background: #4f4f4f; }
|
|
192
|
+
.history-log-output::selection { background: #264f78; color: #fff; }
|
|
193
|
+
@media (max-width: 760px) {
|
|
194
|
+
:global(.run-history-dialog > .el-dialog__body) { padding: 14px; }
|
|
195
|
+
.run-history { padding-right: 3px; }
|
|
196
|
+
.history-toolbar .el-select { width: 100%; }
|
|
197
|
+
.history-summary { flex-wrap: wrap; }
|
|
198
|
+
.history-summary i { display: none; }
|
|
199
|
+
.history-panel { padding: 12px; }
|
|
200
|
+
.comparison-columns { grid-template-columns: minmax(0, 1fr); }
|
|
201
|
+
.history-frames { grid-template-columns: minmax(0, 1fr); }
|
|
202
|
+
}
|
|
163
203
|
</style>
|
|
@@ -118,6 +118,10 @@ export function importAppiumScript(input: unknown) {
|
|
|
118
118
|
return postJson<{ script: AppiumRecordedScript }>(`${APP_BASE}/api/appium-recorder/scripts/import`, input);
|
|
119
119
|
}
|
|
120
120
|
|
|
121
|
+
export function importAppiumScripts(inputs: unknown[]) {
|
|
122
|
+
return postJson<{ scripts: AppiumRecordedScript[] }>(`${APP_BASE}/api/appium-recorder/scripts/import`, { files: inputs });
|
|
123
|
+
}
|
|
124
|
+
|
|
121
125
|
export async function downloadAppiumScript(id: string) {
|
|
122
126
|
const response = await fetch(`${APP_BASE}/api/appium-recorder/scripts/${encodeURIComponent(id)}/export`);
|
|
123
127
|
if (!response.ok) await readJson(response);
|
|
@@ -1,42 +1,99 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import { computed, nextTick, ref, watch } from 'vue';
|
|
3
3
|
import type { AppiumNode } from '../types';
|
|
4
|
-
import { Search } from '@element-plus/icons-vue';
|
|
4
|
+
import { ArrowDown, ArrowUp, Search } from '@element-plus/icons-vue';
|
|
5
|
+
import { flattenNodes } from '../tree';
|
|
5
6
|
|
|
6
7
|
const props = defineProps<{
|
|
7
8
|
tree: AppiumNode | null;
|
|
8
9
|
selectedId: string;
|
|
9
10
|
}>();
|
|
10
11
|
|
|
11
|
-
defineEmits<{
|
|
12
|
+
const emit = defineEmits<{
|
|
12
13
|
select: [node: AppiumNode];
|
|
13
14
|
}>();
|
|
14
15
|
|
|
15
16
|
const containerRef = ref<HTMLElement | null>(null);
|
|
16
17
|
const query = ref('');
|
|
18
|
+
const currentMatchIndex = ref(-1);
|
|
17
19
|
// Keep the tree data identity stable while typing so filtering is not reset.
|
|
18
20
|
const treeData = computed(() => props.tree ? [props.tree] : []);
|
|
19
21
|
const treeRef = ref<{ setCurrentKey: (key: string) => void; filter: (value: string) => void } | null>(null);
|
|
20
22
|
const filterNode = (value: string, node: AppiumNode) => !value || [node.label, node.resourceId, node.text, node.contentDesc, node.className].some((text) => text?.toLowerCase().includes(value.toLowerCase()));
|
|
21
|
-
|
|
22
|
-
|
|
23
|
+
const matches = computed(() => {
|
|
24
|
+
const value = query.value.trim();
|
|
25
|
+
return value ? flattenNodes(props.tree).filter(node => filterNode(value, node)) : [];
|
|
26
|
+
});
|
|
27
|
+
const matchPosition = computed(() => matches.value.length && currentMatchIndex.value >= 0
|
|
28
|
+
? `${currentMatchIndex.value + 1}/${matches.value.length}`
|
|
29
|
+
: `0/${matches.value.length}`);
|
|
30
|
+
|
|
31
|
+
const scrollToCurrentNode = async () => {
|
|
32
|
+
await nextTick();
|
|
33
|
+
containerRef.value
|
|
34
|
+
?.querySelector('.el-tree-node.is-current')
|
|
35
|
+
?.scrollIntoView({ block: 'center', inline: 'nearest' });
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const focusMatch = (index: number) => {
|
|
39
|
+
if (!matches.value.length) return;
|
|
40
|
+
currentMatchIndex.value = (index + matches.value.length) % matches.value.length;
|
|
41
|
+
const node = matches.value[currentMatchIndex.value];
|
|
42
|
+
treeRef.value?.setCurrentKey(node.id);
|
|
43
|
+
emit('select', node);
|
|
44
|
+
void scrollToCurrentNode();
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const refreshSearch = async () => {
|
|
48
|
+
treeRef.value?.filter(query.value);
|
|
49
|
+
await nextTick();
|
|
50
|
+
if (!query.value.trim() || !matches.value.length) {
|
|
51
|
+
currentMatchIndex.value = -1;
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
const selectedMatchIndex = matches.value.findIndex(node => node.id === props.selectedId);
|
|
55
|
+
focusMatch(selectedMatchIndex >= 0 ? selectedMatchIndex : 0);
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
watch(query, refreshSearch);
|
|
59
|
+
watch(() => props.tree, refreshSearch);
|
|
23
60
|
|
|
24
61
|
watch(
|
|
25
62
|
() => props.selectedId,
|
|
26
63
|
async () => {
|
|
27
|
-
query.value
|
|
64
|
+
if (query.value.trim()) {
|
|
65
|
+
const selectedMatchIndex = matches.value.findIndex(node => node.id === props.selectedId);
|
|
66
|
+
if (selectedMatchIndex >= 0) currentMatchIndex.value = selectedMatchIndex;
|
|
67
|
+
else query.value = '';
|
|
68
|
+
}
|
|
28
69
|
treeRef.value?.setCurrentKey(props.selectedId);
|
|
29
|
-
await
|
|
30
|
-
containerRef.value
|
|
31
|
-
?.querySelector('.el-tree-node.is-current')
|
|
32
|
-
?.scrollIntoView({ block: 'center', inline: 'nearest' });
|
|
70
|
+
await scrollToCurrentNode();
|
|
33
71
|
},
|
|
34
72
|
);
|
|
35
73
|
</script>
|
|
36
74
|
|
|
37
75
|
<template>
|
|
38
76
|
<div class="appium-tree-container">
|
|
39
|
-
<
|
|
77
|
+
<div class="appium-tree-search">
|
|
78
|
+
<el-input
|
|
79
|
+
v-model="query"
|
|
80
|
+
size="small"
|
|
81
|
+
:prefix-icon="Search"
|
|
82
|
+
clearable
|
|
83
|
+
placeholder="搜索文字、ID、组件类型"
|
|
84
|
+
aria-label="搜索组件"
|
|
85
|
+
@keydown.enter.prevent="focusMatch(currentMatchIndex + ($event.shiftKey ? -1 : 1))"
|
|
86
|
+
@keydown.up.prevent="focusMatch(currentMatchIndex - 1)"
|
|
87
|
+
@keydown.down.prevent="focusMatch(currentMatchIndex + 1)"
|
|
88
|
+
/>
|
|
89
|
+
<span class="appium-tree-search__count" aria-live="polite">{{ matchPosition }}</span>
|
|
90
|
+
<el-tooltip content="上一个匹配项(Shift + Enter)" placement="top" :show-after="200">
|
|
91
|
+
<el-button :icon="ArrowUp" size="small" aria-label="上一个匹配项" :disabled="!matches.length" @click="focusMatch(currentMatchIndex - 1)" />
|
|
92
|
+
</el-tooltip>
|
|
93
|
+
<el-tooltip content="下一个匹配项(Enter)" placement="top" :show-after="200">
|
|
94
|
+
<el-button :icon="ArrowDown" size="small" aria-label="下一个匹配项" :disabled="!matches.length" @click="focusMatch(currentMatchIndex + 1)" />
|
|
95
|
+
</el-tooltip>
|
|
96
|
+
</div>
|
|
40
97
|
<div ref="containerRef" class="appium-tree">
|
|
41
98
|
<el-empty v-if="!tree" description="暂无组件树" />
|
|
42
99
|
<el-tree
|
|
@@ -70,6 +127,11 @@ watch(
|
|
|
70
127
|
|
|
71
128
|
<style scoped>
|
|
72
129
|
.appium-tree-container { display: flex; flex-direction: column; gap: 8px; min-width: 0; min-height: 0; }
|
|
130
|
+
.appium-tree-search { display: flex; align-items: center; gap: 2px; }
|
|
131
|
+
.appium-tree-search .el-input { flex: 1; min-width: 0; }
|
|
132
|
+
.appium-tree-search__count { min-width: 28px; padding: 0 2px; text-align: center; font-size: 11px; color: var(--ui-text-secondary); font-variant-numeric: tabular-nums; }
|
|
133
|
+
.appium-tree-search .el-button { flex: none; width: 24px; height: 24px; min-height: 24px; padding: 0; border-radius: 5px; }
|
|
134
|
+
.appium-tree-search .el-button + .el-button { margin-left: 0; }
|
|
73
135
|
.appium-tree { flex: 1; height: auto; overflow: auto; }
|
|
74
136
|
.appium-tree :deep(.el-tree) { width: max-content; min-width: 100%; }
|
|
75
137
|
.appium-tree-node { flex: 1 0 auto; }
|
|
@@ -42,6 +42,15 @@ const visualChangeRoleLabel = computed(() => (
|
|
|
42
42
|
const visualChangeConfigDisabled = computed(() => Boolean(
|
|
43
43
|
props.disabled || visualChangeConfig.value.role === 'end',
|
|
44
44
|
));
|
|
45
|
+
const showTapCoordinates = computed(() => (
|
|
46
|
+
props.step.type === 'coordinateTap'
|
|
47
|
+
|| (['tap', 'tapIfExists'].includes(props.step.type) && props.step.fallback?.strategy === 'bounds')
|
|
48
|
+
));
|
|
49
|
+
const invalidTapCoordinates = computed(() => (
|
|
50
|
+
showTapCoordinates.value
|
|
51
|
+
&& Number(props.step.fallback?.centerX) === 0
|
|
52
|
+
&& Number(props.step.fallback?.centerY) === 0
|
|
53
|
+
));
|
|
45
54
|
|
|
46
55
|
function defaultKind(): FlowKind {
|
|
47
56
|
return defaultFlowKind(props.step);
|
|
@@ -195,15 +204,6 @@ function patchSelector(patch: Partial<AppiumSelector>) {
|
|
|
195
204
|
<el-input-number :model-value="step.pinch?.percent ?? 0.5" :disabled="disabled" :min="0.01" :max="1" :step="0.05" controls-position="right" @update:model-value="patchPinch({ percent: $event ?? 0.5 })" />
|
|
196
205
|
</el-form-item>
|
|
197
206
|
</template>
|
|
198
|
-
<div v-if="step.type === 'coordinateTap'" class="appium-flow-editor__grid">
|
|
199
|
-
<el-form-item label="坐标 X">
|
|
200
|
-
<el-input-number :model-value="step.fallback?.centerX ?? 0" :disabled="disabled" :min="0" :max="99999" :precision="0" controls-position="right" aria-label="坐标 X" @update:model-value="patchTapCoordinate('centerX', $event)" />
|
|
201
|
-
</el-form-item>
|
|
202
|
-
<el-form-item label="坐标 Y">
|
|
203
|
-
<el-input-number :model-value="step.fallback?.centerY ?? 0" :disabled="disabled" :min="0" :max="99999" :precision="0" controls-position="right" aria-label="坐标 Y" @update:model-value="patchTapCoordinate('centerY', $event)" />
|
|
204
|
-
</el-form-item>
|
|
205
|
-
</div>
|
|
206
|
-
<CoordinatePointPicker v-if="step.type === 'coordinateTap'" :disabled="disabled" @select="applyTapPoint" />
|
|
207
207
|
<StageLogSettings v-if="step.type === 'log'" :step="step" :disabled="disabled" @update="patchStep" />
|
|
208
208
|
<VariableExtractionSettings v-if="step.type === 'extractVariable'" :step="step" :disabled="disabled" @update="patchStep" />
|
|
209
209
|
<ScriptParameterSettings v-if="step.type === 'runScript'" :step="step" :disabled="disabled" @update="patchStep" />
|
|
@@ -428,6 +428,48 @@ function patchSelector(patch: Partial<AppiumSelector>) {
|
|
|
428
428
|
</el-select>
|
|
429
429
|
</el-form-item>
|
|
430
430
|
</div>
|
|
431
|
+
<section v-if="showTapCoordinates" class="appium-flow-editor__fallback">
|
|
432
|
+
<div class="appium-flow-editor__grid">
|
|
433
|
+
<el-form-item :label="step.type === 'coordinateTap' ? '坐标 X' : '备用坐标 X'">
|
|
434
|
+
<el-input-number :model-value="step.fallback?.centerX ?? 0" :disabled="disabled" :min="0" :max="99999" :precision="0" controls-position="right" aria-label="坐标 X" @update:model-value="patchTapCoordinate('centerX', $event)" />
|
|
435
|
+
</el-form-item>
|
|
436
|
+
<el-form-item :label="step.type === 'coordinateTap' ? '坐标 Y' : '备用坐标 Y'">
|
|
437
|
+
<el-input-number :model-value="step.fallback?.centerY ?? 0" :disabled="disabled" :min="0" :max="99999" :precision="0" controls-position="right" aria-label="坐标 Y" @update:model-value="patchTapCoordinate('centerY', $event)" />
|
|
438
|
+
</el-form-item>
|
|
439
|
+
</div>
|
|
440
|
+
<el-alert
|
|
441
|
+
v-if="invalidTapCoordinates"
|
|
442
|
+
class="appium-flow-editor__fallback-alert"
|
|
443
|
+
type="warning"
|
|
444
|
+
show-icon
|
|
445
|
+
:closable="false"
|
|
446
|
+
title="备用坐标无效;组件定位失败时无法使用坐标兜底"
|
|
447
|
+
/>
|
|
448
|
+
<CoordinatePointPicker :disabled="disabled" @select="applyTapPoint" />
|
|
449
|
+
</section>
|
|
431
450
|
</el-form>
|
|
432
451
|
</div>
|
|
433
452
|
</template>
|
|
453
|
+
|
|
454
|
+
<style scoped>
|
|
455
|
+
.appium-flow-editor__fallback {
|
|
456
|
+
display: grid;
|
|
457
|
+
gap: 10px;
|
|
458
|
+
margin-top: 8px;
|
|
459
|
+
padding-top: 12px;
|
|
460
|
+
border-top: 1px solid var(--el-border-color-lighter);
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
.appium-flow-editor__fallback :deep(.el-form-item) {
|
|
464
|
+
margin-bottom: 0;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
.appium-flow-editor__fallback-alert {
|
|
468
|
+
padding: 8px 12px;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
.appium-flow-editor__fallback-alert :deep(.el-alert__title) {
|
|
472
|
+
font-size: 13px;
|
|
473
|
+
line-height: 20px;
|
|
474
|
+
}
|
|
475
|
+
</style>
|