android-midscene-automation 0.1.36 → 0.1.37

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 CHANGED
@@ -1,4 +1,10 @@
1
1
  # 更新记录
2
+ ## v0.1.37
3
+ - 修复元素查找超时未覆盖 Appium 请求的问题,驱动无响应时及时中止并明确报错,不再误判为找不到元素;唯一原生定位优先使用 ID 或 Accessibility ID。
4
+ - 设备预览新增设备信息,展示设备名称、品牌、型号、处理器、Android 版本、物理分辨率及当前分辨率,支持远程设备;问号位于标题右侧,样式与全局变量帮助图标一致。
5
+ - 新增独立“杀死 APP”操作节点,默认使用当前选中应用包名,支持编辑目标包名;停止当前设备用户下的应用进程,不清除应用数据。
6
+ - Midscene 相关模块由 1.12.5 统一升级至 1.12.6。
7
+
2
8
  ## v0.1.36
3
9
  - 新合并支持取消:原合并按钮切换为取消合并,还原分支和被删除的重复节点,保留配置编辑;公共流程新增的普通操作可选择保留左侧、右侧或两侧,结构冲突时阻止还原。本地保存还原记录,导出及节点复制不携带历史记录。
4
10
  - 启动相册前先停止当前设备用户下的目标相册进程,再重新打开;保留相册自动适配,不清除照片及应用数据,停止失败时明确报错。
package/README.md CHANGED
@@ -1229,6 +1229,12 @@ Linux: ~/.local/share/android-midscene-automation
1229
1229
 
1230
1230
  # 项目更新记录
1231
1231
 
1232
+ ## v0.1.37
1233
+ - 修复元素查找超时未覆盖 Appium 请求的问题,驱动无响应时及时中止并明确报错,不再误判为找不到元素;唯一原生定位优先使用 ID 或 Accessibility ID。
1234
+ - 设备预览新增设备信息,展示设备名称、品牌、型号、处理器、Android 版本、物理分辨率及当前分辨率,支持远程设备;问号位于标题右侧,样式与全局变量帮助图标一致。
1235
+ - 新增独立“杀死 APP”操作节点,默认使用当前选中应用包名,支持编辑目标包名;停止当前设备用户下的应用进程,不清除应用数据。
1236
+ - Midscene 相关模块由 1.12.5 统一升级至 1.12.6。
1237
+
1232
1238
  ## v0.1.36
1233
1239
  - 新合并支持取消:原合并按钮切换为取消合并,还原分支和被删除的重复节点,保留配置编辑;公共流程新增的普通操作可选择保留左侧、右侧或两侧,结构冲突时阻止还原。本地保存还原记录,导出及节点复制不携带历史记录。
1234
1240
  - 启动相册前先停止当前设备用户下的目标相册进程,再重新打开;保留相册自动适配,不清除照片及应用数据,停止失败时明确报错。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "android-midscene-automation",
3
- "version": "0.1.36",
3
+ "version": "0.1.37",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/oooooooko/android-midscene-automation.git"
@@ -38,12 +38,12 @@
38
38
  "dependencies": {
39
39
  "@dagrejs/dagre": "1.1.5",
40
40
  "@element-plus/icons-vue": "2.3.2",
41
- "@midscene/android": "https://registry.npmjs.org/@midscene/android/-/android-1.12.5.tgz",
42
- "@midscene/android-playground": "https://registry.npmjs.org/@midscene/android-playground/-/android-playground-1.12.5.tgz",
43
- "@midscene/core": "https://registry.npmjs.org/@midscene/core/-/core-1.12.5.tgz",
44
- "@midscene/playground": "https://registry.npmjs.org/@midscene/playground/-/playground-1.12.5.tgz",
45
- "@midscene/shared": "https://registry.npmjs.org/@midscene/shared/-/shared-1.12.5.tgz",
46
- "@midscene/web": "https://registry.npmjs.org/@midscene/web/-/web-1.12.5.tgz",
41
+ "@midscene/android": "https://registry.npmjs.org/@midscene/android/-/android-1.12.6.tgz",
42
+ "@midscene/android-playground": "https://registry.npmjs.org/@midscene/android-playground/-/android-playground-1.12.6.tgz",
43
+ "@midscene/core": "https://registry.npmjs.org/@midscene/core/-/core-1.12.6.tgz",
44
+ "@midscene/playground": "https://registry.npmjs.org/@midscene/playground/-/playground-1.12.6.tgz",
45
+ "@midscene/shared": "https://registry.npmjs.org/@midscene/shared/-/shared-1.12.6.tgz",
46
+ "@midscene/web": "https://registry.npmjs.org/@midscene/web/-/web-1.12.6.tgz",
47
47
  "@techstark/opencv-js": "5.0.0-release.1",
48
48
  "@vitejs/plugin-vue": "6.0.7",
49
49
  "@vue-flow/core": "1.48.2",
@@ -1,5 +1,6 @@
1
1
  import { execFile } from 'node:child_process';
2
2
  import os from 'node:os';
3
+ import { readAndroidDeviceInfo } from '../server/android-device-info';
3
4
  import { readFreshWindowHierarchy } from '../server/appium-recorder/tree-dump';
4
5
  import type { RemoteAgentDevice, RemoteCommand } from '../server/remote-agents/protocol';
5
6
  import { replayAppiumScript } from '../server/appium-recorder/appium-runner';
@@ -100,6 +101,7 @@ async function handleCommand(command: RemoteCommand) {
100
101
  return { base64: image.toString('base64') };
101
102
  }
102
103
  if (command.type === 'displayInfo') return await getDisplayInfo(command.deviceId);
104
+ if (command.type === 'deviceInfo') return await readAndroidDeviceInfo(command.deviceId, 'adb');
103
105
  if (command.type === 'tree') {
104
106
  const [xml, activity] = await Promise.all([
105
107
  dumpWindowHierarchy(command.deviceId),
@@ -0,0 +1,28 @@
1
+ import { execFile } from 'node:child_process';
2
+ import type { AndroidDeviceDetails } from '../src/types';
3
+
4
+ export function parseAndroidDeviceInfo(properties: string, size: string, deviceName = ''): AndroidDeviceDetails {
5
+ const values = new Map([...properties.matchAll(/^\[([^\]]+)\]: \[(.*)\]\s*$/gm)].map(match => [match[1], match[2]]));
6
+ const get = (...keys: string[]) => keys.map(key => values.get(key)?.trim()).find(Boolean) || '';
7
+ const physical = size.match(/Physical size:\s*(\d+)x(\d+)/i);
8
+ const current = size.match(/Override size:\s*(\d+)x(\d+)/i) || physical;
9
+ const resolution = (match: RegExpMatchArray | null) => match ? `${match[1]} × ${match[2]}` : '';
10
+ return {
11
+ name: deviceName.trim() && deviceName.trim() !== 'null' ? deviceName.trim() : get('persist.sys.device_name', 'ro.product.marketname', 'ro.product.model'),
12
+ brand: get('ro.product.brand', 'ro.product.manufacturer'), model: get('ro.product.model'),
13
+ processor: get('ro.soc.model', 'ro.board.platform', 'ro.hardware'),
14
+ androidVersion: get('ro.build.version.release'), physicalResolution: resolution(physical), resolution: resolution(current),
15
+ };
16
+ }
17
+
18
+ export async function readAndroidDeviceInfo(deviceId: string, adb: string) {
19
+ const run = (args: string[]) => new Promise<string>((resolve, reject) => {
20
+ execFile(adb, ['-s', deviceId, 'shell', ...args], { timeout: 4000, maxBuffer: 1024 * 1024 }, (error, stdout) => {
21
+ if (error) reject(error); else resolve(stdout);
22
+ });
23
+ });
24
+ const properties = await run(['getprop']);
25
+ const size = await run(['wm', 'size']).catch(() => '');
26
+ const name = await run(['settings', 'get', 'global', 'device_name']).catch(() => '');
27
+ return parseAndroidDeviceInfo(properties, size, name);
28
+ }
@@ -11,6 +11,8 @@ import { checkImage } from './image-check';
11
11
  import { IMAGE_CHECK_MODES, type ImageCheckResult } from '../../src/appium-recorder/image-check';
12
12
  import { formatStageLog } from '../../src/appium-recorder/stage-log';
13
13
  import { openGalleryOnDevice } from './open-gallery';
14
+ import { stopAppOnDevice } from './stop-app';
15
+ import { AppiumRequestTimeoutError, timedAppiumFetch, withElementDeadline } from './request-timeout';
14
16
  import { textClickSelector } from '../../src/appium-recorder/text-click';
15
17
  import { AsyncLocalStorage } from 'node:async_hooks';
16
18
  import { mkdir, writeFile } from 'node:fs/promises';
@@ -83,6 +85,8 @@ function isReplayStopped(error?: unknown) {
83
85
 
84
86
  function throwIfReplayStopped(error?: unknown) {
85
87
  if (isReplayStopped(error)) throw new ReplayStoppedError();
88
+ // 驱动失去响应不是“元素不存在”,不能被备用定位或可选步骤吞掉。
89
+ if (error instanceof AppiumRequestTimeoutError) throw error;
86
90
  }
87
91
 
88
92
  function errorDetail(error: unknown) {
@@ -276,30 +280,31 @@ async function appiumRequest<T>(
276
280
  if (!options?.ignoreAbort) throwIfReplayStopped();
277
281
  context?.appiumLog(`[Appium] 请求:${method} ${path}`);
278
282
  let response: Response;
283
+ let responseText: string;
279
284
  try {
280
- response = await fetch(requestUrl, {
285
+ const result = await timedAppiumFetch(requestUrl, {
281
286
  ...init,
282
- signal: options?.ignoreAbort
283
- ? (options.timeoutMs ? AbortSignal.timeout(options.timeoutMs) : undefined)
284
- : context?.signal || init?.signal,
287
+ signal: options?.ignoreAbort ? undefined : context?.signal || init?.signal,
285
288
  headers: {
286
289
  'Content-Type': 'application/json',
287
290
  ...(init?.headers || {}),
288
291
  },
289
- });
292
+ }, options?.timeoutMs ?? (path === '/session' && method === 'POST' ? 120000 : 30000));
293
+ response = result.response;
294
+ responseText = result.text;
290
295
  } catch (error) {
291
296
  if (isReplayStopped(error) && !options?.ignoreAbort) {
292
297
  context?.appiumLog(`[Appium] 取消:${method} ${path}`);
293
298
  throw new ReplayStoppedError();
294
299
  }
295
300
  context?.appiumLog(`[Appium] 异常:${method} ${path}(${errorDetail(error)})`);
301
+ if (error instanceof AppiumRequestTimeoutError) throw error;
296
302
  if (isTimeoutError(error)) {
297
303
  throw new Error(`Appium 请求超时 ${requestUrl},${errorDetail(error)}`);
298
304
  }
299
305
  throw new Error(`无法连接 Appium 服务 ${requestUrl},${errorDetail(error)}`);
300
306
  }
301
307
 
302
- const responseText = await response.text();
303
308
  context?.appiumLog(`[Appium] 响应:HTTP ${response.status} ${method} ${path}(${Date.now() - startedAt}ms)`);
304
309
  const payload = (() => {
305
310
  try {
@@ -439,7 +444,11 @@ async function findElementBySelector(
439
444
  return elementId;
440
445
  }
441
446
 
442
- async function findElement(sessionId: string, step: AppiumRecordedStepRecord) {
447
+ async function findElement(sessionId: string, step: AppiumRecordedStepRecord, timeoutMs = step.timeoutMs ?? 10000) {
448
+ return withElementDeadline(timeoutMs, () => findElementWithinDeadline(sessionId, step));
449
+ }
450
+
451
+ async function findElementWithinDeadline(sessionId: string, step: AppiumRecordedStepRecord) {
443
452
  if (!step.selector) throw new Error(`${step.label} 缺少 selector`);
444
453
  const attempts: string[] = [];
445
454
 
@@ -454,7 +463,9 @@ async function findElement(sessionId: string, step: AppiumRecordedStepRecord) {
454
463
  }
455
464
  }
456
465
 
457
- for (const selector of step.selectorChain || []) {
466
+ // 唯一原生定位优先;非唯一元素仍保留父级与备用 XPath 的精确匹配。
467
+ const preferPrimary = step.selector.unique === true && ['id', 'accessibilityId'].includes(step.selector.strategy);
468
+ for (const selector of [...(preferPrimary ? [step.selector] : []), ...(step.selectorChain || [])]) {
458
469
  try {
459
470
  attempts.push(`${selector.strategy} ${selector.value || ''}`);
460
471
  return await findElementBySelector(sessionId, selector);
@@ -506,7 +517,7 @@ async function waitForElement(sessionId: string, step: AppiumRecordedStepRecord)
506
517
  let lastError: unknown;
507
518
  while (Date.now() - startedAt <= timeoutMs) {
508
519
  try {
509
- return await findElement(sessionId, step);
520
+ return await findElement(sessionId, step, Math.max(1, timeoutMs - (Date.now() - startedAt)));
510
521
  } catch (error) {
511
522
  throwIfReplayStopped(error);
512
523
  lastError = error;
@@ -521,7 +532,7 @@ async function findOptionalElement(sessionId: string, step: AppiumRecordedStepRe
521
532
  const timeoutMs = step.timeoutMs ?? 2000;
522
533
  while (Date.now() - startedAt <= timeoutMs) {
523
534
  try {
524
- return await findElement(sessionId, step);
535
+ return await findElement(sessionId, step, Math.max(1, timeoutMs - (Date.now() - startedAt)));
525
536
  } catch (error) {
526
537
  throwIfReplayStopped(error);
527
538
  await wait(250);
@@ -535,7 +546,7 @@ async function waitForElementGone(sessionId: string, step: AppiumRecordedStepRec
535
546
  const timeoutMs = step.timeoutMs || 10000;
536
547
  while (Date.now() - startedAt <= timeoutMs) {
537
548
  try {
538
- await findElement(sessionId, step);
549
+ await findElement(sessionId, step, Math.max(1, timeoutMs - (Date.now() - startedAt)));
539
550
  } catch (error) {
540
551
  throwIfReplayStopped(error);
541
552
  return;
@@ -768,6 +779,10 @@ async function runStep(
768
779
  return `ADB 已启动 ${packageName}`;
769
780
  }
770
781
 
782
+ if (step.type === 'stopApp') {
783
+ return stopAppOnDevice(deviceId, step.value || '', replayContext.getStore()?.signal);
784
+ }
785
+
771
786
  if (step.type === 'clearAppData') {
772
787
  const packageName = step.value || '';
773
788
  await clearAppDataOnDevice(deviceId, packageName);
@@ -38,6 +38,7 @@ export type AppiumRecordedStepRecord = {
38
38
  | 'swipe'
39
39
  | 'screenshot'
40
40
  | 'launchApp'
41
+ | 'stopApp'
41
42
  | 'openGallery'
42
43
  | 'endFlow'
43
44
  | 'loop'
@@ -0,0 +1,25 @@
1
+ import { AsyncLocalStorage } from 'node:async_hooks';
2
+
3
+ const deadline = new AsyncLocalStorage<number>();
4
+ export class AppiumRequestTimeoutError extends Error {}
5
+
6
+ export function withElementDeadline<T>(timeoutMs: number, action: () => Promise<T>) {
7
+ return deadline.run(Date.now() + Math.max(1, timeoutMs), action);
8
+ }
9
+
10
+ // 响应体也受同一个期限约束,避免收到 HTTP 头后继续无限等待。
11
+ export async function timedAppiumFetch(url: string, init: RequestInit, timeoutMs: number) {
12
+ const remaining = Math.max(1, Math.min(timeoutMs, (deadline.getStore() ?? Infinity) - Date.now()));
13
+ const timeout = AbortSignal.timeout(Math.ceil(remaining));
14
+ const signal = init.signal ? AbortSignal.any([init.signal, timeout]) : timeout;
15
+ try {
16
+ const response = await fetch(url, { ...init, signal });
17
+ const text = await response.text();
18
+ return { response, text };
19
+ } catch (error) {
20
+ if (timeout.aborted && !init.signal?.aborted) {
21
+ throw new AppiumRequestTimeoutError(`设备自动化服务无响应:Appium 请求超过 ${Math.ceil(remaining)}ms,已停止等待(${url})`);
22
+ }
23
+ throw error;
24
+ }
25
+ }
@@ -0,0 +1,18 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { getAdbCommand } from '../android-sdk';
3
+
4
+ export function stopAppOnDevice(deviceId: string, packageName: string, signal?: AbortSignal) {
5
+ if (!/^[A-Za-z][\w]*(?:\.[A-Za-z][\w]*)+$/.test(packageName)) {
6
+ throw new Error('杀死 APP 需要有效的应用包名');
7
+ }
8
+ // force-stop 只停止指定应用,不使用 pm clear,不删除应用数据。
9
+ return new Promise<string>((resolve, reject) => {
10
+ execFile(getAdbCommand(), ['-s', deviceId, 'shell', 'am', 'force-stop', '--user', 'current', packageName],
11
+ { timeout: 10000, maxBuffer: 1024 * 1024, signal }, (error, stdout, stderr) => {
12
+ const output = `${stdout || ''}\n${stderr || ''}`.trim();
13
+ if (error || /Error:|Exception|Permission Denial/i.test(output)) {
14
+ reject(new Error(`停止 APP 失败(${packageName}):${output || error?.message}`));
15
+ } else resolve(`已停止 APP 进程:${packageName}(保留应用数据)`);
16
+ });
17
+ });
18
+ }
@@ -38,6 +38,7 @@ import { handleAppiumRecorderRequest } from './appium-recorder/routes';
38
38
  import { handleRemoteAgentRequest } from './remote-agents/routes';
39
39
  import { isRemoteDeviceId, listRemoteAndroidDevices, sendRemoteCommand } from './remote-agents/registry';
40
40
  import { getAdbCommand } from './android-sdk';
41
+ import { readAndroidDeviceInfo } from './android-device-info';
41
42
 
42
43
  async function readBody<T>(req: IncomingMessage) {
43
44
  let body = '';
@@ -873,6 +874,24 @@ export function createApiMiddleware() {
873
874
  return;
874
875
  }
875
876
 
877
+ if (req.url?.startsWith('/api/android-device-info') && req.method === 'GET') {
878
+ try {
879
+ const deviceId = getRequestedDeviceId(req) || selectedDeviceId;
880
+ if (!deviceId) throw new Error('未检测到可用设备');
881
+ assertDeviceAllowedForRequest(req, deviceId);
882
+ const details = isRemoteDeviceId(deviceId)
883
+ ? await sendRemoteCommand(deviceId, 'deviceInfo')
884
+ : await readAndroidDeviceInfo(deviceId, getAdbCommand());
885
+ res.setHeader('Content-Type', 'application/json; charset=utf-8');
886
+ res.end(JSON.stringify({ deviceId, details }));
887
+ } catch (error) {
888
+ res.statusCode = 500;
889
+ res.setHeader('Content-Type', 'application/json; charset=utf-8');
890
+ res.end(JSON.stringify({ message: error instanceof Error ? error.message : '读取设备信息失败' }));
891
+ }
892
+ return;
893
+ }
894
+
876
895
  if (req.url?.startsWith('/api/android-display-info') && req.method === 'GET') {
877
896
  try {
878
897
  const deviceId = getRequestedDeviceId(req) || selectedDeviceId;
@@ -8,6 +8,7 @@ export type RemoteAgentDevice = {
8
8
  export type RemoteCommandType =
9
9
  | 'screenshot'
10
10
  | 'displayInfo'
11
+ | 'deviceInfo'
11
12
  | 'tree'
12
13
  | 'tap'
13
14
  | 'swipe'
@@ -122,6 +122,7 @@ const insertableRecorderActions: RecorderAction[] = [
122
122
  'clearInput',
123
123
  'coordinateTap',
124
124
  'launchApp',
125
+ 'stopApp',
125
126
  'openGallery',
126
127
  'endFlow',
127
128
  'loop',
@@ -1580,6 +1581,10 @@ async function addAction(
1580
1581
  value: form.appPackage,
1581
1582
  }, index, branchTarget);
1582
1583
  }
1584
+ if (action === 'stopApp') {
1585
+ return insertStep({ id: createStepId(), type: 'stopApp', label: `杀死 APP ${form.appPackage}`,
1586
+ value: form.appPackage, flow: { nodeKind: 'action' } }, index, branchTarget);
1587
+ }
1583
1588
  if (action === 'clearAppData') {
1584
1589
  return insertStep({
1585
1590
  id: `step_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`,
@@ -16,6 +16,7 @@ export const actionDescriptions: Record<InsertAction, string> = {
16
16
  swipe: '按指定起点、终点和时长滑动屏幕。',
17
17
  pinch: '执行双指放大或缩小手势,需要当前页面支持缩放。',
18
18
  launchApp: '启动指定应用,可放在流程中途或分支中执行。',
19
+ stopApp: '停止当前所选包名的 APP 进程,不删除应用或清除数据;可在节点配置中修改目标包名。',
19
20
  openGallery: '打开设备相册应用,不自动选择照片或视频。',
20
21
  endFlow: '立即结束当前回放流程,后续节点不再执行。',
21
22
  loop: '重复执行循环体,支持固定次数或条件退出,必须设置最大次数。',
@@ -156,7 +156,7 @@ function patchSelector(patch: Partial<AppiumSelector>) {
156
156
  <el-form-item label="节点类型">
157
157
  <el-select
158
158
  :model-value="defaultKind()"
159
- :disabled="disabled || isBooleanCondition(step) || step.type === 'extractVariable' || step.type === 'log' || step.type === 'openGallery' || step.type === 'endFlow' || step.type === 'loop' || step.type === 'breakLoop'"
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
160
  @update:model-value="patchFlow({ nodeKind: $event as FlowKind })"
161
161
  >
162
162
  <el-option label="操作" value="action" />
@@ -164,7 +164,7 @@ function patchSelector(patch: Partial<AppiumSelector>) {
164
164
  <el-option label="校验" value="assertion" />
165
165
  </el-select>
166
166
  </el-form-item>
167
- <el-form-item v-if="!['longPress', 'log', 'openGallery', 'endFlow', 'loop', 'breakLoop'].includes(step.type)" label="超时时间 ms">
167
+ <el-form-item v-if="!['longPress', 'stopApp', 'log', 'openGallery', 'endFlow', 'loop', 'breakLoop'].includes(step.type)" label="超时时间 ms">
168
168
  <el-input-number
169
169
  :model-value="step.timeoutMs || undefined"
170
170
  :disabled="disabled"
@@ -176,7 +176,7 @@ function patchSelector(patch: Partial<AppiumSelector>) {
176
176
  </el-form-item>
177
177
  </div>
178
178
  <LongPressSettings v-if="step.type === 'longPress'" :step="step" :disabled="disabled" @update="patchStep" />
179
- <el-form-item v-if="['waitActivity', 'launchApp', 'clearAppData'].includes(step.type)" :label="step.type === 'waitActivity' ? '目标 Activity' : '目标 APP 包名'">
179
+ <el-form-item v-if="['waitActivity', 'launchApp', 'stopApp', 'clearAppData'].includes(step.type)" :label="step.type === 'waitActivity' ? '目标 Activity' : '目标 APP 包名'">
180
180
  <el-input :model-value="step.value || ''" :disabled="disabled" @update:model-value="patchStep({ value: String($event) })" />
181
181
  </el-form-item>
182
182
  <template v-if="step.type === 'pinch'">
@@ -23,6 +23,7 @@ type InsertAction =
23
23
  | 'swipe'
24
24
  | 'pinch'
25
25
  | 'launchApp'
26
+ | 'stopApp'
26
27
  | 'openGallery'
27
28
  | 'endFlow'
28
29
  | 'loop'
@@ -103,6 +104,7 @@ const actionGroups: Array<{ title: string; actions: Array<{ type: InsertAction;
103
104
  title: '设备操作',
104
105
  actions: [
105
106
  { type: 'launchApp', label: '启动 App' },
107
+ { type: 'stopApp', label: '杀死 APP' },
106
108
  { type: 'keyBack', label: '系统返回' },
107
109
  { type: 'keyHome', label: 'Home 键' },
108
110
  { type: 'openGallery', label: '启动相册' },
@@ -153,7 +155,7 @@ function typeLabel(step: AppiumRecordedStep) {
153
155
  clearIfExists: '存在则清空', backIfExists: '存在则返回', clearInput: '清空',
154
156
  waitFor: '等待出现', waitDisappear: '等待消失', assertExists: '断言存在',
155
157
  assertText: '断言文本', key: '按键', waitActivity: '等待 Activity', delay: '延时',
156
- coordinateTap: '坐标点击', swipe: '滑动', launchApp: '启动 APP', clearAppData: '清理 APP 缓存', longPress: '长按',
158
+ coordinateTap: '坐标点击', swipe: '滑动', launchApp: '启动 APP', stopApp: '杀死 APP', clearAppData: '清理 APP 缓存', longPress: '长按',
157
159
  pinch: '双指缩放', runScript: '连接脚本', screenshot: '截图', visualChange: '检测画面变化',
158
160
  noop: '空节点',
159
161
  endFlow: '终止流程',
@@ -80,6 +80,7 @@ const insertActionGroups: FlowActionGroup[] = [
80
80
  { type: 'pinch', label: '双指缩放' },
81
81
  { type: 'clearAppData', label: '清理 App 缓存' },
82
82
  { type: 'launchApp', label: '启动 App' },
83
+ { type: 'stopApp', label: '杀死 APP' },
83
84
  { type: 'openGallery', label: '启动相册' },
84
85
  ],
85
86
  },
@@ -24,6 +24,7 @@ export type InsertAction =
24
24
  | 'swipe'
25
25
  | 'pinch'
26
26
  | 'launchApp'
27
+ | 'stopApp'
27
28
  | 'openGallery'
28
29
  | 'endFlow'
29
30
  | 'loop'
@@ -58,6 +58,7 @@ export function flowTypeLabel(step: AppiumRecordedStep) {
58
58
  swipe: '滑动',
59
59
  screenshot: '截图',
60
60
  launchApp: '启动 APP',
61
+ stopApp: '杀死 APP',
61
62
  openGallery: '启动相册',
62
63
  endFlow: '终止流程',
63
64
  loop: '有界循环',
@@ -98,6 +99,7 @@ export function flowStepMeta(step: AppiumRecordedStep) {
98
99
  if (step.type === 'key') return `keyCode ${step.keyCode || ''}`;
99
100
  if (step.type === 'waitActivity') return step.value || '';
100
101
  if (step.type === 'launchApp') return step.value || '';
102
+ if (step.type === 'stopApp') return `${step.value || ''}(仅停止进程)`;
101
103
  if (step.type === 'clearAppData') return `${step.value || ''}(清除数据与缓存)`;
102
104
  if (step.type === 'runScript') return step.value ? `脚本 ${step.value}` : '';
103
105
  if (step.type === 'noop') return '';
@@ -93,6 +93,7 @@ export type AppiumRecordedStep = {
93
93
  | 'swipe'
94
94
  | 'screenshot'
95
95
  | 'launchApp'
96
+ | 'stopApp'
96
97
  | 'openGallery'
97
98
  | 'endFlow'
98
99
  | 'loop'
@@ -0,0 +1,55 @@
1
+ <script setup lang="ts">
2
+ import { shallowRef, watch, onBeforeUnmount } from 'vue';
3
+ import { QuestionFilled } from '@element-plus/icons-vue';
4
+ import { APP_BASE } from '../../api';
5
+ import type { AndroidDeviceDetails } from '../../types';
6
+
7
+ const props = defineProps<{ deviceId: string }>();
8
+ const details = shallowRef<AndroidDeviceDetails>();
9
+ const loading = shallowRef(false);
10
+ const error = shallowRef('');
11
+ let request: AbortController | undefined;
12
+ const fields: [keyof AndroidDeviceDetails, string][] = [
13
+ ['name', '设备名称'], ['brand', '品牌'], ['model', '型号'], ['processor', '处理器'],
14
+ ['androidVersion', 'Android 版本'], ['physicalResolution', '物理分辨率'], ['resolution', '分辨率'],
15
+ ];
16
+ watch(() => props.deviceId, () => { request?.abort(); request = undefined; details.value = undefined; error.value = ''; loading.value = false; });
17
+ onBeforeUnmount(() => request?.abort());
18
+ async function load() {
19
+ if (!props.deviceId || loading.value) return;
20
+ const controller = new AbortController();
21
+ request = controller; loading.value = true; error.value = '';
22
+ try {
23
+ const response = await fetch(`${APP_BASE}/api/android-device-info?deviceId=${encodeURIComponent(props.deviceId)}`, { signal: controller.signal });
24
+ const payload = await response.json();
25
+ if (!response.ok) throw new Error(payload.message || '读取设备信息失败');
26
+ if (request === controller) details.value = payload.details;
27
+ } catch (cause) {
28
+ if (request === controller && !controller.signal.aborted) error.value = cause instanceof Error ? cause.message : '读取失败';
29
+ } finally { if (request === controller) loading.value = false; }
30
+ }
31
+ </script>
32
+
33
+ <template>
34
+ <el-tooltip placement="bottom" effect="dark" :show-after="200" @before-show="load">
35
+ <template #content>
36
+ <div class="device-info">
37
+ <div v-if="!deviceId">未选择设备</div>
38
+ <template v-else>
39
+ <div v-if="loading">正在读取设备信息…</div>
40
+ <div v-if="error" role="status">{{ error }}</div>
41
+ <dl><template v-for="[key, label] in fields" :key="key"><dt>{{ label }}</dt><dd>{{ details?.[key] || '未知' }}</dd></template></dl>
42
+ </template>
43
+ </div>
44
+ </template>
45
+ <el-button class="device-info-help" :icon="QuestionFilled" text aria-label="设备信息" />
46
+ </el-tooltip>
47
+ </template>
48
+
49
+ <style scoped>
50
+ .device-info-help { width: 28px; height: 28px; padding: 0; font-size: 16px; }
51
+ .device-info { max-width: min(340px, calc(100vw - 40px)); font-size: 12px; overflow-wrap: anywhere; }
52
+ .device-info dl { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 6px 14px; margin: 4px 0; }
53
+ .device-info dt { color: #cbd5e1; }
54
+ .device-info dd { margin: 0; }
55
+ </style>
@@ -2,6 +2,7 @@
2
2
  import { computed, onBeforeUnmount, onMounted, ref, shallowRef, watch } from 'vue';
3
3
  import { Close, Cpu, FullScreen, Refresh } from '@element-plus/icons-vue';
4
4
  import type { AndroidDevice, DeviceAction } from '../../types';
5
+ import DeviceInfoTooltip from './DeviceInfoTooltip.vue';
5
6
 
6
7
  type DeviceOverlayBounds = {
7
8
  id: string;
@@ -293,7 +294,7 @@ function handlePointerCancel(event: PointerEvent) {
293
294
  <el-card shadow="never" class="automation-card device-preview-card" :class="{ 'device-preview-card--compact': compact, 'device-preview-card--enlarged': enlarged }">
294
295
  <template #header>
295
296
  <div class="panel-header">
296
- <span>设备预览</span>
297
+ <div class="device-preview-title"><span>设备预览</span><DeviceInfoTooltip :device-id="selectedDeviceId" /></div>
297
298
  <div class="device-status">
298
299
  <el-tag v-if="compact" :type="available ? 'success' : 'info'">
299
300
  {{ available ? '已连接' : '未连接' }}
@@ -444,6 +445,7 @@ function handlePointerCancel(event: PointerEvent) {
444
445
  </template>
445
446
 
446
447
  <style scoped>
448
+ .device-preview-title { display: flex; align-items: center; gap: 8px; }
447
449
  .device-preview-card--compact { display: flex; flex-direction: column; min-height: 0; }
448
450
  .device-preview-card--compact :deep(> .el-card__body) { display: flex; flex-direction: column; flex: 1; min-height: 0; overflow: hidden; gap: 8px; }
449
451
  .device-preview-card--compact .panel-header { flex-wrap: wrap; gap: 8px; font-size: 14px; }
package/src/types.ts CHANGED
@@ -147,3 +147,12 @@ export type DeviceAction = {
147
147
  icon: string;
148
148
  keyCode: number;
149
149
  };
150
+ export type AndroidDeviceDetails = {
151
+ name: string;
152
+ brand: string;
153
+ model: string;
154
+ processor: string;
155
+ androidVersion: string;
156
+ physicalResolution: string;
157
+ resolution: string;
158
+ };