android-midscene-automation 1.1.2 → 1.1.4

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/src/App.vue CHANGED
@@ -790,7 +790,15 @@ onUnmounted(() => {
790
790
  width="760px"
791
791
  class="code-dialog"
792
792
  >
793
- <div class="code-dialog__toolbar">
793
+ <el-input
794
+ v-if="codeDialog.editing"
795
+ v-model="codeDialog.draftCode"
796
+ type="textarea"
797
+ resize="none"
798
+ class="code-dialog__editor"
799
+ />
800
+ <pre v-else class="code-block code-dialog__body"><code>{{ codeDialog.code || '暂无代码' }}</code></pre>
801
+ <template #footer>
794
802
  <el-button :icon="CopyDocument" @click="copyDialogCode">复制</el-button>
795
803
  <el-button v-if="!codeDialog.editing" type="primary" :icon="Edit" @click="editCodeDialog">
796
804
  编辑
@@ -803,15 +811,7 @@ onUnmounted(() => {
803
811
  保存
804
812
  </el-button>
805
813
  </template>
806
- </div>
807
- <el-input
808
- v-if="codeDialog.editing"
809
- v-model="codeDialog.draftCode"
810
- type="textarea"
811
- resize="none"
812
- class="code-dialog__editor"
813
- />
814
- <pre v-else class="code-block code-dialog__body"><code>{{ codeDialog.code || '暂无代码' }}</code></pre>
814
+ </template>
815
815
  </el-dialog>
816
816
 
817
817
  <AppSidebar :active-menu="activeMenu" :collapsed="sidebarCollapsed"
@@ -26,7 +26,7 @@ import {
26
26
  downloadAppiumScript,
27
27
  getAppiumScripts,
28
28
  getAppiumTree,
29
- importAppiumScript,
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 file = input.files?.[0];
2062
+ const files = Array.from(input.files || []);
2062
2063
  input.value = '';
2063
- if (!file) return;
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 payload = JSON.parse(await file.text()) as unknown;
2067
- const result = await importAppiumScript(payload);
2068
- await loadScripts();
2069
- loadScript(result.script);
2070
- ElMessage.success(`脚本「${result.script.name}」已导入`);
2071
- } catch (error) {
2072
- ElMessage.error(error instanceof Error ? error.message : '导入脚本失败');
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="导入脚本" placement="top" :show-after="200"><span class="appium-script-icon">
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="520px"
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, 96vw)" align-center append-to-body @closed="emit('close')">
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
- <p>{{ filtered.length }} 次运行 · 通过率 {{ stats.passRate === null ? '—' : `${(stats.passRate * 100).toFixed(1)}%` }}(不含手动终止)</p>
88
- <h3>耗时趋势</h3>
89
- <svg class="history-trend" viewBox="0 0 800 160" role="img" aria-label="按时间从早到晚的运行耗时趋势">
90
- <path d="M20 10V130H790" fill="none" stroke="#a0a8ad" />
91
- <polyline :points="points" fill="none" stroke="#168a75" stroke-width="2" />
92
- <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'">
93
- <title>{{ time(run.startedAt) }} · {{ seconds(run.durationMs) }} · {{ run.appVersion || '未知版本' }}</title>
94
- </circle>
95
- <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>
96
- </svg>
97
- <el-table :data="filtered" row-key="id" max-height="270" @selection-change="selected = $event">
98
- <el-table-column type="selection" width="42" />
99
- <el-table-column label="运行时间" min-width="180"><template #default="{ row }">{{ time(row.startedAt) }}</template></el-table-column>
100
- <el-table-column prop="appVersion" label="应用版本" min-width="140"><template #default="{ row }">{{ row.appVersion || '未知' }}</template></el-table-column>
101
- <el-table-column prop="deviceId" label="设备" min-width="140" />
102
- <el-table-column label="结果" width="85"><template #default="{ row }">{{ labels[row.status as keyof typeof labels] }}</template></el-table-column>
103
- <el-table-column label="耗时" width="100"><template #default="{ row }">{{ seconds(row.durationMs) }}</template></el-table-column>
104
- <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>
105
- </el-table>
106
- <h3>失败节点分布</h3>
107
- <el-table :data="stats.failures" max-height="220" empty-text="暂无节点失败记录">
108
- <el-table-column prop="label" label="节点" min-width="220" />
109
- <el-table-column label="失败 / 到达次数" width="145"><template #default="{ row }">{{ row.failures }} / {{ row.runs }}</template></el-table-column>
110
- <el-table-column label="观察结果" width="130"><template #default="{ row }">{{ row.runs < 2 ? '样本不足' : row.failures === row.runs ? '持续失败' : '偶发失败' }}</template></el-table-column>
111
- <el-table-column label="首次失败 / 版本" min-width="190"><template #default="{ row }">{{ time(row.firstFailure) }} · {{ row.version || '未知' }}</template></el-table-column>
112
- </el-table>
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>运行对比 · 总耗时差 {{ seconds(details[1].durationMs - details[0].durationMs) }}</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) }} · {{ labels[run.status] }}</h4>
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: 78vh; overflow: auto; }
147
- .history-toolbar { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
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-trend { width: 100%; height: 160px; background: #f3faf7; }
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 { font-size: 15px; margin: 18px 0 10px; }
152
- h4 { font-size: 14px; margin: 12px 0 8px; }
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: 20px; }
155
- .comparison-columns section { min-width: 0; overflow-wrap: anywhere; }
156
- .history-frames { max-height: 360px; overflow: auto; display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; }
157
- figure { margin: 0; }
158
- figcaption { font-size: 12px; }
159
- .el-image { width: 100%; height: 150px; }
160
- pre { white-space: pre-wrap; overflow-wrap: anywhere; max-height: 280px; overflow: auto; background: #f1f5f4; padding: 10px; font-size: 12px; }
161
- ul { max-height: 160px; overflow: auto; padding-left: 20px; }
162
- @media (max-width: 650px) { .comparison-columns { grid-template-columns: minmax(0, 1fr); } }
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);
@@ -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>
@@ -367,6 +367,7 @@ function updateStep(index: number, step: AppiumRecordedStep) {
367
367
  class="appium-flow-dialog"
368
368
  align-center
369
369
  append-to-body
370
+ draggable
370
371
  >
371
372
  <div class="appium-flow-toolbar appium-flow-dialog__toolbar">
372
373
  <FlowNodeSearch v-model:query="searchQuery" :results="searchResults" :selected-id="searchTargetId" @select="focusSearchResult" />
@@ -11,21 +11,55 @@ function update(index: number, patch: Partial<ScriptReturn>) {
11
11
  </script>
12
12
 
13
13
  <template>
14
- <el-form-item label="传入子脚本参数"><VariableTable :model-value="step.parameters || []" :disabled="disabled" @update:model-value="emit('update', { parameters: $event })" /></el-form-item>
15
- <el-form-item label="返回值映射">
16
- <div class="return-mappings">
14
+ <div class="script-parameter-settings">
15
+ <section class="script-parameter-settings__section">
16
+ <div class="script-parameter-settings__heading">
17
+ <strong>传入子脚本参数</strong>
18
+ <span>同名参数会覆盖子脚本中的变量值</span>
19
+ </div>
20
+ <VariableTable layout="cards" empty-text="暂无传入参数" :model-value="step.parameters || []" :disabled="disabled" @update:model-value="emit('update', { parameters: $event })" />
21
+ </section>
22
+ <section class="script-parameter-settings__section">
23
+ <div class="script-parameter-settings__heading">
24
+ <strong>返回值映射</strong>
25
+ <span>将子脚本执行后的变量保存到当前脚本</span>
26
+ </div>
27
+ <div class="return-mappings">
17
28
  <div v-for="(item, index) in step.returns || []" :key="index" class="return-row">
18
- <el-input :model-value="item.name" placeholder="子脚本变量名" aria-label="子脚本返回变量" :disabled="disabled" @update:model-value="update(index, { name: String($event) })" />
19
- <span>→</span>
20
- <el-input :model-value="item.target" placeholder="当前脚本变量名" aria-label="返回目标变量" :disabled="disabled" @update:model-value="update(index, { target: String($event) })" />
29
+ <label><span>子脚本变量</span><el-input :model-value="item.name" placeholder="例如:result" aria-label="子脚本返回变量" :disabled="disabled" @update:model-value="update(index, { name: String($event) })" /></label>
30
+ <span class="return-row__arrow">→</span>
31
+ <label><span>当前脚本变量</span><el-input :model-value="item.target" placeholder="例如:deviceResult" aria-label="返回目标变量" :disabled="disabled" @update:model-value="update(index, { target: String($event) })" /></label>
21
32
  <el-tooltip content="删除映射"><el-button :icon="Delete" text :disabled="disabled" aria-label="删除返回映射" @click="emit('update', { returns: step.returns!.filter((_, i) => i !== index) })" /></el-tooltip>
22
33
  </div>
23
- <el-button :icon="Plus" :disabled="disabled" size="small" @click="emit('update', { returns: [...(step.returns || []), { name: '', target: '' }] })">新增返回映射</el-button>
24
- </div>
25
- </el-form-item>
34
+ <div v-if="!(step.returns || []).length" class="return-mappings__empty">
35
+ <span>无需获取子脚本返回值</span>
36
+ <small>需要继续使用子脚本产生的变量时再添加映射</small>
37
+ </div>
38
+ <el-button class="return-mappings__add" :icon="Plus" :disabled="disabled" size="small" @click="emit('update', { returns: [...(step.returns || []), { name: '', target: '' }] })">新增返回映射</el-button>
39
+ </div>
40
+ </section>
41
+ </div>
26
42
  </template>
27
43
 
28
44
  <style scoped>
45
+ .script-parameter-settings { container-type: inline-size; display: grid; width: 100%; gap: 18px; }
46
+ .script-parameter-settings__section { min-width: 0; }
47
+ .script-parameter-settings__section + .script-parameter-settings__section { padding-top: 18px; border-top: 1px solid var(--ui-border); }
48
+ .script-parameter-settings__heading { display: grid; gap: 4px; margin-bottom: 10px; }
49
+ .script-parameter-settings__heading strong { color: var(--ui-text); font-size: 14px; font-weight: 600; }
50
+ .script-parameter-settings__heading span { color: var(--ui-text-muted); font-size: 12px; line-height: 1.5; }
29
51
  .return-mappings { width: 100%; }
30
- .return-row { display: grid; grid-template-columns: minmax(0, 1fr) 15px minmax(0, 1fr) 30px; gap: 5px; align-items: center; margin-bottom: 8px; }
52
+ .return-row { display: grid; grid-template-columns: minmax(0, 1fr) 18px minmax(0, 1fr) 28px; gap: 8px; align-items: end; margin-bottom: 10px; padding: 12px; border: 1px solid var(--ui-border); border-radius: var(--ui-radius-control); background: var(--ui-surface); }
53
+ .return-row label { display: grid; min-width: 0; gap: 6px; }
54
+ .return-row label > span { color: var(--ui-text-secondary); font-size: 12px; line-height: 1; }
55
+ .return-row__arrow { align-self: end; padding-bottom: 7px; color: var(--ui-text-muted); text-align: center; }
56
+ .return-mappings__empty { display: grid; place-items: center; gap: 7px; min-height: 76px; padding: 14px; border: 1px dashed var(--ui-border-strong); border-radius: var(--ui-radius-control); background: var(--ui-bg-soft); color: var(--ui-text-secondary); text-align: center; }
57
+ .return-mappings__empty small { color: var(--ui-text-muted); font-size: 12px; }
58
+ .return-mappings__add { margin-top: 10px; }
59
+ @container (max-width: 390px) {
60
+ .return-row { grid-template-columns: minmax(0, 1fr) 28px; }
61
+ .return-row label { grid-column: 1; }
62
+ .return-row__arrow { display: none; }
63
+ .return-row > .el-button { grid-column: 2; grid-row: 1 / span 2; align-self: center; }
64
+ }
31
65
  </style>
@@ -2,7 +2,7 @@
2
2
  import { Delete, Plus, CopyDocument } from '@element-plus/icons-vue';
3
3
  import { ElMessage } from 'element-plus';
4
4
  import { sensitiveVariableName, type TestVariable } from '../variables';
5
- const props = defineProps<{ modelValue: TestVariable[]; disabled?: boolean }>();
5
+ const props = defineProps<{ modelValue: TestVariable[]; disabled?: boolean; layout?: 'table' | 'cards'; emptyText?: string }>();
6
6
  const emit = defineEmits<{ 'update:modelValue': [TestVariable[]] }>();
7
7
  function update(index: number, patch: Partial<TestVariable>) {
8
8
  emit('update:modelValue', props.modelValue.map((item, i) => i === index ? { ...item, ...patch } : item));
@@ -14,8 +14,34 @@ async function copy(name: string) {
14
14
  </script>
15
15
 
16
16
  <template>
17
- <div class="variable-table">
18
- <el-table :data="modelValue" empty-text="暂无变量" size="small">
17
+ <div class="variable-table" :class="{ 'variable-table--cards': layout === 'cards' }">
18
+ <template v-if="layout === 'cards'">
19
+ <div v-if="modelValue.length" class="variable-table__cards">
20
+ <div v-for="(row, index) in modelValue" :key="index" class="variable-table__card">
21
+ <label class="variable-table__field">
22
+ <span>变量名</span>
23
+ <el-input :model-value="row.name" :disabled="disabled" placeholder="例如:account" aria-label="变量名" @update:model-value="update(index, { name: String($event) })" />
24
+ </label>
25
+ <label class="variable-table__field">
26
+ <span>传入值</span>
27
+ <el-input :model-value="row.value" :type="row.sensitive || sensitiveVariableName(row.name) ? 'password' : 'text'" :show-password="row.sensitive || sensitiveVariableName(row.name)" :disabled="disabled" placeholder="输入固定值或变量引用" aria-label="变量值" @update:model-value="update(index, { value: String($event) })" />
28
+ </label>
29
+ <div class="variable-table__card-footer">
30
+ <el-checkbox :model-value="row.sensitive || sensitiveVariableName(row.name)" :disabled="disabled || sensitiveVariableName(row.name)" @update:model-value="update(index, { sensitive: Boolean($event) })">敏感值</el-checkbox>
31
+ <div class="variable-table__actions">
32
+ <el-tooltip content="复制变量引用"><el-button :icon="CopyDocument" text size="small" :disabled="!row.name" aria-label="复制变量引用" @click="copy(row.name)" /></el-tooltip>
33
+ <el-tooltip content="删除参数"><el-button :icon="Delete" text size="small" :disabled="disabled" aria-label="删除变量" @click="emit('update:modelValue', modelValue.filter((_, i) => i !== index))" /></el-tooltip>
34
+ </div>
35
+ </div>
36
+ </div>
37
+ </div>
38
+ <div v-else class="variable-table__empty">
39
+ <span>{{ emptyText || '暂无变量' }}</span>
40
+ <small>子脚本将使用自身已有的变量值</small>
41
+ <el-button :icon="Plus" :disabled="disabled" size="small" @click="emit('update:modelValue', [{ name: '', value: '', sensitive: false }])">新增参数</el-button>
42
+ </div>
43
+ </template>
44
+ <el-table v-else :data="modelValue" empty-text="暂无变量" size="small">
19
45
  <el-table-column label="变量名" min-width="110"><template #default="{ row, $index }"><el-input :model-value="row.name" :disabled="disabled" aria-label="变量名" @update:model-value="update($index, { name: String($event) })" /></template></el-table-column>
20
46
  <el-table-column label="值" min-width="125"><template #default="{ row, $index }"><el-input :model-value="row.value" :type="row.sensitive || sensitiveVariableName(row.name) ? 'password' : 'text'" :show-password="row.sensitive || sensitiveVariableName(row.name)" :disabled="disabled" aria-label="变量值" @update:model-value="update($index, { value: String($event) })" /></template></el-table-column>
21
47
  <el-table-column label="敏感" width="65"><template #default="{ row, $index }"><el-checkbox :model-value="row.sensitive || sensitiveVariableName(row.name)" :disabled="disabled || sensitiveVariableName(row.name)" aria-label="敏感变量" @update:model-value="update($index, { sensitive: Boolean($event) })" /></template></el-table-column>
@@ -24,8 +50,8 @@ async function copy(name: string) {
24
50
  <el-tooltip content="删除变量"><el-button :icon="Delete" text size="small" :disabled="disabled" aria-label="删除变量" @click="emit('update:modelValue', modelValue.filter((_, i) => i !== $index))" /></el-tooltip>
25
51
  </div></template></el-table-column>
26
52
  </el-table>
27
- <div class="variable-table__footer">
28
- <el-button :icon="Plus" :disabled="disabled" size="small" @click="emit('update:modelValue', [...modelValue, { name: '', value: '', sensitive: false }])">新增变量</el-button>
53
+ <div v-if="layout !== 'cards' || modelValue.length" class="variable-table__footer">
54
+ <el-button :icon="Plus" :disabled="disabled" size="small" @click="emit('update:modelValue', [...modelValue, { name: '', value: '', sensitive: false }])">{{ layout === 'cards' ? '新增参数' : '新增变量' }}</el-button>
29
55
  <slot />
30
56
  </div>
31
57
  </div>
@@ -33,6 +59,13 @@ async function copy(name: string) {
33
59
 
34
60
  <style scoped>
35
61
  .variable-table { min-width: 0; }
62
+ .variable-table__cards { display: grid; gap: 10px; }
63
+ .variable-table__card { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1.35fr); gap: 10px 12px; padding: 12px; border: 1px solid var(--ui-border); border-radius: var(--ui-radius-control); background: var(--ui-surface); }
64
+ .variable-table__field { display: grid; min-width: 0; gap: 6px; }
65
+ .variable-table__field > span { color: var(--ui-text-secondary); font-size: 12px; line-height: 1; }
66
+ .variable-table__card-footer { display: flex; grid-column: 1 / -1; align-items: center; justify-content: space-between; min-height: 28px; padding-top: 2px; }
67
+ .variable-table__empty { display: grid; place-items: center; gap: 7px; min-height: 112px; padding: 16px; border: 1px dashed var(--ui-border-strong); border-radius: var(--ui-radius-control); background: var(--ui-bg-soft); color: var(--ui-text-secondary); text-align: center; }
68
+ .variable-table__empty small { color: var(--ui-text-muted); font-size: 12px; }
36
69
  .variable-table__footer { display: flex; align-items: center; justify-content: space-between; gap: 8px; flex-wrap: wrap; margin-top: 12px; }
37
70
  .variable-table__actions { display: flex; align-items: center; gap: 2px; }
38
71
  .variable-table__actions :deep(.el-button) { width: 26px; height: 28px; padding: 0; }
@@ -40,4 +73,10 @@ async function copy(name: string) {
40
73
  .variable-table :deep(.el-table td.el-table__cell) { padding: 8px 0; }
41
74
  .variable-table :deep(.el-table .cell) { padding: 0 8px; }
42
75
  .variable-table :deep(.el-button + .el-button) { margin-left: 0; }
76
+ .variable-table--cards .variable-table__footer :deep(.el-button) { margin-left: 0; }
77
+ @container (max-width: 390px) {
78
+ .variable-table__card { grid-template-columns: minmax(0, 1fr); }
79
+ .variable-table__card-footer { grid-column: 1; }
80
+ }
81
+ .variable-table--cards { container-type: inline-size; }
43
82
  </style>
@@ -19,6 +19,7 @@ function parseBounds(value: string): AppiumBounds | undefined {
19
19
  const top = Number(match[2]);
20
20
  const right = Number(match[3]);
21
21
  const bottom = Number(match[4]);
22
+ if (right <= left || bottom <= top) return undefined;
22
23
  return {
23
24
  left,
24
25
  top,