android-midscene-automation 0.2.2 → 0.2.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.
@@ -0,0 +1,65 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { registerRemoteAgent, makeRemoteDeviceId, sendRemoteCommand, pollRemoteCommand, completeRemoteCommand, stopRemoteReplay } from './registry';
4
+
5
+ test('replay heartbeat renews beyond ten minutes, cancellation survives dispatch, late results save once', async () => {
6
+ const originalSetTimeout = globalThis.setTimeout;
7
+ const originalClearTimeout = globalThis.clearTimeout;
8
+ const originalNow = Date.now;
9
+ const token = process.env.REMOTE_AGENT_TOKEN;
10
+ let now = originalNow();
11
+ const timers = new Set<{ at: number; callback: () => void; refresh: () => void }>();
12
+ const advance = (ms: number) => {
13
+ now += ms;
14
+ for (const timer of [...timers]) if (timer.at <= now) { timers.delete(timer); timer.callback(); }
15
+ };
16
+ try {
17
+ Date.now = () => now;
18
+ globalThis.setTimeout = ((callback: () => void, ms: number) => {
19
+ const timer = { at: now + ms, callback, refresh: () => { timer.at = now + ms; } };
20
+ timers.add(timer); return timer;
21
+ }) as unknown as typeof setTimeout;
22
+ globalThis.clearTimeout = ((timer: any) => { timers.delete(timer); }) as typeof clearTimeout;
23
+ const agentId = 'test-lease-agent';
24
+ const heartbeat = () => registerRemoteAgent({ agentId, token, devices: [{ id: 'device', status: 'device' }] });
25
+ const device = makeRemoteDeviceId(agentId, 'device');
26
+ heartbeat();
27
+ const result = sendRemoteCommand(device, 'replay', { script: { id: 'script' } });
28
+ let completed = false;
29
+ void result.then(() => { completed = true; });
30
+ const command = pollRemoteCommand({ agentId, token })!;
31
+ assert.throws(() => sendRemoteCommand(device, 'replay'), /已有回放任务/);
32
+ for (let i = 0; i < 70; i++) { advance(10000); heartbeat(); }
33
+ assert.equal(completed, false, 'healthy replay remains pending after 11 minutes');
34
+ assert.equal(stopRemoteReplay(device), true);
35
+ assert.deepEqual(heartbeat().cancelledCommandIds, [command.id]);
36
+ assert.throws(() => completeRemoteCommand({ agentId: 'other', token, commandId: command.id, ok: true }), /不属于/);
37
+ let saves = 0;
38
+ const stopped = { stopped: true, history: { status: 'stopped' } };
39
+ completeRemoteCommand({ agentId, token, commandId: command.id, ok: true, data: stopped }, () => { saves++; });
40
+ assert.deepEqual(await result, stopped);
41
+ completeRemoteCommand({ agentId, token, commandId: command.id, ok: true, data: stopped }, () => { saves++; });
42
+ assert.equal(saves, 1, 'result retry must not duplicate history');
43
+ assert.equal(stopRemoteReplay(device), false);
44
+
45
+ const queuedResult = sendRemoteCommand(device, 'replay');
46
+ assert.equal(stopRemoteReplay(device), true);
47
+ const queued = pollRemoteCommand({ agentId, token })!;
48
+ assert.equal(queued.cancelled, true, 'cancel before poll survives delivery');
49
+ completeRemoteCommand({ agentId, token, commandId: queued.id, ok: true }); await queuedResult;
50
+
51
+ const lateResult = sendRemoteCommand(device, 'replay', { script: { id: 'late-script' } });
52
+ const rejected = assert.rejects(lateResult, /心跳超时/);
53
+ const late = pollRemoteCommand({ agentId, token })!;
54
+ advance(45001); await rejected;
55
+ assert.deepEqual(heartbeat().cancelledCommandIds, [late.id], 'reconnecting agent stops expired replay');
56
+ const input = { agentId, token, commandId: late.id, ok: true, data: { history: {} } };
57
+ assert.throws(() => completeRemoteCommand(input, () => { throw new Error('disk busy'); }), /disk busy/);
58
+ completeRemoteCommand(input, command => { assert.equal((command.payload?.script as { id: string }).id, 'late-script'); saves++; });
59
+ completeRemoteCommand(input, () => { saves++; });
60
+ assert.equal(saves, 2, 'late result is saved once even after a temporary persistence failure');
61
+ assert.deepEqual(heartbeat().cancelledCommandIds, []);
62
+ } finally {
63
+ globalThis.setTimeout = originalSetTimeout; globalThis.clearTimeout = originalClearTimeout; Date.now = originalNow;
64
+ }
65
+ });
@@ -3,7 +3,9 @@ import type { RemoteAgentDevice, RemoteAndroidDevice, RemoteCommand, RemoteComma
3
3
  const REMOTE_DEVICE_PREFIX = 'remote:';
4
4
  const AGENT_TTL_MS = 15_000;
5
5
  const COMMAND_TIMEOUT_MS = 15_000;
6
- const REPLAY_COMMAND_TIMEOUT_MS = 10 * 60_000;
6
+ // 回放由代理心跳续期,运行时长不受固定十分钟限制。
7
+ const REPLAY_COMMAND_TIMEOUT_MS = 45_000;
8
+ const LATE_RESULT_TTL_MS = 24 * 60 * 60_000;
7
9
 
8
10
  type RegisteredAgent = {
9
11
  id: string;
@@ -14,6 +16,9 @@ type RegisteredAgent = {
14
16
  };
15
17
 
16
18
  type PendingCommand = RemoteCommand & {
19
+ agentId: string;
20
+ delivered: boolean;
21
+ cancelRequested: boolean;
17
22
  resolve: (result: unknown) => void;
18
23
  reject: (error: Error) => void;
19
24
  timer: ReturnType<typeof setTimeout>;
@@ -22,6 +27,24 @@ type PendingCommand = RemoteCommand & {
22
27
  const agents = new Map<string, RegisteredAgent>();
23
28
  const queues = new Map<string, RemoteCommand[]>();
24
29
  const pending = new Map<string, PendingCommand>();
30
+ // ponytail: 当前进程保留离线回放身份 24 小时;跨服务重启补交需要持久化任务表。
31
+ const lateReplays = new Map<string, { command: RemoteCommand; agentId: string; expiresAt: number }>();
32
+ function pruneLateReplays() {
33
+ for (const [id, receipt] of lateReplays) if (receipt.expiresAt <= Date.now()) lateReplays.delete(id);
34
+ }
35
+
36
+ export function stopRemoteReplay(deviceId: string) {
37
+ const target = parseRemoteDeviceId(deviceId);
38
+ if (!target) return false;
39
+ let stopped = false;
40
+ for (const command of pending.values()) {
41
+ if (command.type === 'replay' && command.agentId === target.agentId && command.deviceId === target.deviceSerial) {
42
+ command.cancelRequested = true;
43
+ stopped = true;
44
+ }
45
+ }
46
+ return stopped;
47
+ }
25
48
 
26
49
  function validateToken(token?: string) {
27
50
  const expected = process.env.REMOTE_AGENT_TOKEN || '';
@@ -70,6 +93,17 @@ export function registerRemoteAgent(input: {
70
93
  devices: input.devices || [],
71
94
  lastSeenAt: Date.now(),
72
95
  });
96
+ pruneLateReplays();
97
+ const cancelledCommandIds: string[] = [];
98
+ for (const command of pending.values()) {
99
+ if (command.agentId !== agentId || command.type !== 'replay') continue;
100
+ command.timer.refresh();
101
+ if (command.cancelRequested) cancelledCommandIds.push(command.id);
102
+ }
103
+ for (const receipt of lateReplays.values()) {
104
+ if (receipt.agentId === agentId) cancelledCommandIds.push(receipt.command.id);
105
+ }
106
+ return { cancelledCommandIds };
73
107
  }
74
108
 
75
109
  export function listRemoteAndroidDevices(): RemoteAndroidDevice[] {
@@ -88,15 +122,27 @@ export function listRemoteAndroidDevices(): RemoteAndroidDevice[] {
88
122
  export function pollRemoteCommand(input: { agentId: string; token?: string }) {
89
123
  validateToken(input.token);
90
124
  const queue = queues.get(input.agentId) || [];
91
- return queue.shift() || null;
125
+ const command = queue.shift();
126
+ if (!command) return null;
127
+ const task = pending.get(command.id);
128
+ if (!task) return null;
129
+ task.delivered = true;
130
+ return { ...command, cancelled: task.cancelRequested };
92
131
  }
93
132
 
94
133
  export function completeRemoteCommand(input: {
95
134
  agentId: string;
96
135
  token?: string;
97
- } & RemoteCommandResult) {
136
+ } & RemoteCommandResult, saveResult?: (command: RemoteCommand) => void) {
98
137
  validateToken(input.token);
138
+ pruneLateReplays();
99
139
  const command = pending.get(input.commandId);
140
+ const late = lateReplays.get(input.commandId);
141
+ if (!command && !late) return;
142
+ if ((command?.agentId ?? late?.agentId) !== input.agentId) throw new Error('远程命令不属于当前代理');
143
+ // 先落库再确认;保存失败时保留任务身份,让代理重试上报。
144
+ if (input.ok) saveResult?.(command ?? late!.command);
145
+ lateReplays.delete(input.commandId);
100
146
  if (!command) return;
101
147
  pending.delete(input.commandId);
102
148
  clearTimeout(command.timer);
@@ -112,6 +158,10 @@ export function sendRemoteCommand(deviceId: string, type: RemoteCommandType, pay
112
158
  if (!parsed) throw new Error('远程设备 ID 无效');
113
159
  const agent = agents.get(parsed.agentId);
114
160
  if (!agent || !isOnline(agent)) throw new Error('远程设备代理已离线');
161
+ if (type === 'replay' && [...pending.values()].some(command =>
162
+ command.type === 'replay' && command.agentId === parsed.agentId && command.deviceId === parsed.deviceSerial)) {
163
+ throw new Error('该设备已有回放任务正在执行');
164
+ }
115
165
  const commandId = `remote_cmd_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
116
166
  const command: RemoteCommand = {
117
167
  id: commandId,
@@ -123,12 +173,17 @@ export function sendRemoteCommand(deviceId: string, type: RemoteCommandType, pay
123
173
  return new Promise<unknown>((resolve, reject) => {
124
174
  const timeoutMs = type === 'replay' ? REPLAY_COMMAND_TIMEOUT_MS : COMMAND_TIMEOUT_MS;
125
175
  const timer = setTimeout(() => {
176
+ const task = pending.get(commandId);
126
177
  pending.delete(commandId);
178
+ if (type === 'replay' && task?.delivered) {
179
+ pruneLateReplays();
180
+ lateReplays.set(commandId, { command, agentId: parsed.agentId, expiresAt: Date.now() + LATE_RESULT_TTL_MS });
181
+ }
127
182
  const queue = queues.get(parsed.agentId) || [];
128
183
  queues.set(parsed.agentId, queue.filter((item) => item.id !== commandId));
129
- reject(new Error('远程设备命令超时'));
184
+ reject(new Error(type === 'replay' ? '远程代理心跳超时,恢复连接后将终止任务并补交结果' : '远程设备命令超时'));
130
185
  }, timeoutMs);
131
- pending.set(commandId, { ...command, resolve, reject, timer });
186
+ pending.set(commandId, { ...command, agentId: parsed.agentId, delivered: false, cancelRequested: false, resolve, reject, timer });
132
187
  const queue = queues.get(parsed.agentId) || [];
133
188
  queue.push(command);
134
189
  queues.set(parsed.agentId, queue);
@@ -0,0 +1,79 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { createServer } from 'node:http';
4
+ import { spawn } from 'node:child_process';
5
+ import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises';
6
+ import { tmpdir } from 'node:os';
7
+ import { join, resolve } from 'node:path';
8
+
9
+ test('real agent receives stop during replay and retries result delivery without replaying', { skip: process.platform === 'win32', timeout: 25000 }, async () => {
10
+ const root = await mkdtemp(join(tmpdir(), 'remote-replay-'));
11
+ const env = { ...process.env };
12
+ process.env.ANDROID_MIDSCENE_DATA_ROOT = join(root, 'central');
13
+ process.env.REMOTE_AGENT_TOKEN = 'test-only-token';
14
+ const { handleRemoteAgentRequest } = await import('./routes');
15
+ const { handleAppiumRecorderRequest } = await import('../appium-recorder/routes');
16
+ const { listRemoteAndroidDevices } = await import('./registry');
17
+ const { saveAppiumRecordedScript } = await import('../appium-recorder/repository');
18
+ const { listRunHistory } = await import('../appium-recorder/run-history');
19
+ let sessions = 0, deletes = 0, keys = 0, resultAttempts = 0;
20
+ const server = createServer(async (req, res) => {
21
+ if (req.url === '/session') {
22
+ sessions++; res.setHeader('Content-Type', 'application/json'); res.end('{"value":{"sessionId":"test-session"}}'); return;
23
+ }
24
+ if (req.url?.startsWith('/session/')) {
25
+ if (req.method === 'DELETE') deletes++;
26
+ if (req.url.endsWith('/press_keycode')) keys++;
27
+ res.setHeader('Content-Type', 'application/json'); res.end('{"value":{}}'); return;
28
+ }
29
+ if (req.url === '/api/remote-agents/result' && ++resultAttempts === 1) {
30
+ req.resume(); res.statusCode = 503; res.end('{"message":"temporary failure"}'); return;
31
+ }
32
+ if (await handleRemoteAgentRequest(req, res)) return;
33
+ if (await handleAppiumRecorderRequest(req, res, '')) return;
34
+ res.statusCode = 404; res.end();
35
+ });
36
+ let agent: ReturnType<typeof spawn> | undefined;
37
+ const waitFor = async (check: () => boolean) => {
38
+ const until = Date.now() + 12000;
39
+ while (!check()) { assert.ok(Date.now() < until, 'agent action completed within deadline'); await new Promise(resolve => setTimeout(resolve, 25)); }
40
+ };
41
+ try {
42
+ const sdk = join(root, 'sdk'), bin = join(sdk, 'platform-tools');
43
+ await mkdir(bin, { recursive: true });
44
+ await writeFile(join(bin, 'adb'), '#!/usr/bin/env node\nif(process.argv.includes("devices")) console.log("List of devices attached\\nfake-device device model:Test");\n', { mode: 0o755 });
45
+ await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve));
46
+ const base = `http://127.0.0.1:${(server.address() as { port: number }).port}`;
47
+ const script = saveAppiumRecordedScript({ name: 'remote-test', appPackage: 'example.test', steps: [
48
+ { id: 'wait', type: 'delay', label: 'wait', timeoutMs: 60000 },
49
+ { id: 'home', type: 'key', label: 'Home', keyCode: 3 },
50
+ ] });
51
+ agent = spawn(process.execPath, ['--import', 'tsx', resolve('remote-agent/index.ts'), '--server', base, '--agent-id', 'integration-agent'], {
52
+ env: { ...process.env, ANDROID_MIDSCENE_DATA_ROOT: join(root, 'agent'), ANDROID_HOME: sdk, ANDROID_SDK_ROOT: sdk, APPIUM_SERVER_URL: base, PATH: `${bin}:${process.env.PATH}` },
53
+ stdio: ['ignore', 'ignore', 'pipe'],
54
+ });
55
+ let stderr = ''; agent.stderr?.on('data', chunk => { stderr += chunk; });
56
+ await waitFor(() => listRemoteAndroidDevices().length > 0);
57
+ const deviceId = listRemoteAndroidDevices()[0]!.id;
58
+ const post = (path: string, data: unknown) => fetch(base + path, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data), signal: AbortSignal.timeout(18000) });
59
+ const running = post(`/api/appium-recorder/scripts/${script.id}/replay`, { deviceId, reportSummaryEnabled: false }).then(response => response.json() as Promise<{ stopped: boolean }>);
60
+ await waitFor(() => sessions === 1);
61
+ const stop = await (await post('/api/appium-recorder/replay/stop', { deviceId })).json() as { stopped: boolean };
62
+ assert.equal(stop.stopped, true);
63
+ const result = await running;
64
+ assert.equal(result.stopped, true, stderr);
65
+ assert.equal(sessions, 1, 'retrying the result never replays device actions');
66
+ assert.equal(deletes, 1); assert.equal(keys, 0, 'Home must not run after cancellation');
67
+ assert.equal(resultAttempts, 2, 'temporary result failure is retried');
68
+ const history = listRunHistory(script.id);
69
+ assert.equal(history.length, 1); assert.equal(history[0]?.status, 'stopped'); assert.equal(history[0]?.deviceId, deviceId);
70
+ } finally {
71
+ if (agent && agent.exitCode === null) {
72
+ const exited = new Promise<void>(resolve => agent!.once('exit', () => resolve()));
73
+ agent.kill('SIGKILL'); await exited;
74
+ }
75
+ server.closeAllConnections(); await new Promise<void>(resolve => server.close(() => resolve()));
76
+ for (const key of ['ANDROID_MIDSCENE_DATA_ROOT', 'REMOTE_AGENT_TOKEN']) { if (env[key] === undefined) delete process.env[key]; else process.env[key] = env[key]; }
77
+ await rm(root, { recursive: true, force: true });
78
+ }
79
+ });
@@ -1,6 +1,8 @@
1
+ import { saveRunHistory } from '../appium-recorder/run-history';
2
+ import type { RunDetail } from '../../src/appium-recorder/run-history';
1
3
  import { readBody, requestErrorStatus } from '../request-body';
2
4
  import type { IncomingMessage, ServerResponse } from 'node:http';
3
- import { completeRemoteCommand, listRemoteAndroidDevices, pollRemoteCommand, registerRemoteAgent } from './registry';
5
+ import { completeRemoteCommand, makeRemoteDeviceId, listRemoteAndroidDevices, pollRemoteCommand, registerRemoteAgent } from './registry';
4
6
  import type { RemoteAgentDevice } from './protocol';
5
7
 
6
8
  function sendJson(res: ServerResponse, payload: unknown, statusCode = 200) {
@@ -29,14 +31,14 @@ export async function handleRemoteAgentRequest(req: IncomingMessage, res: Server
29
31
  token?: string;
30
32
  devices?: RemoteAgentDevice[];
31
33
  }>(req);
32
- registerRemoteAgent({
34
+ const control = registerRemoteAgent({
33
35
  agentId: parsed.agentId || '',
34
36
  agentName: parsed.agentName,
35
37
  version: parsed.version,
36
38
  token: parsed.token,
37
39
  devices: parsed.devices || [],
38
40
  });
39
- sendJson(res, { success: true });
41
+ sendJson(res, { success: true, ...control });
40
42
  return true;
41
43
  }
42
44
 
@@ -65,6 +67,11 @@ export async function handleRemoteAgentRequest(req: IncomingMessage, res: Server
65
67
  ok: parsed.ok === true,
66
68
  data: parsed.data,
67
69
  error: parsed.error,
70
+ }, command => {
71
+ const result = parsed.data as { history?: RunDetail } | undefined;
72
+ if (command.type !== 'replay' || !result?.history) return;
73
+ const script = command.payload?.script as { id: string };
74
+ saveRunHistory({ ...result.history, scriptId: script.id, deviceId: makeRemoteDeviceId(parsed.agentId!, command.deviceId) });
68
75
  });
69
76
  sendJson(res, { success: true });
70
77
  return true;
package/src/App.vue CHANGED
@@ -110,8 +110,9 @@ const { isRunningScript, isStoppingScript, executionLog, executionProcess, lastR
110
110
  const visitedMenus = reactive(new Set<MenuKey>([activeMenu.value]));
111
111
  const openMenus = ref<MenuKey[]>([...menuKeys]);
112
112
  const visibleMenuItems = computed(() => openMenus.value.map(key => menuItems.find(item => item.key === key)!));
113
- function closeMenu(key: MenuKey) {
113
+ async function closeMenu(key: MenuKey) {
114
114
  if (openMenus.value.length === 1) return;
115
+ if (key === 'generator' && !(await confirmUnsavedGeneratorNavigation())) return;
115
116
  const index = openMenus.value.indexOf(key);
116
117
  openMenus.value.splice(index, 1);
117
118
  if (activeMenu.value === key) activeMenu.value = openMenus.value[Math.max(0, index - 1)];
@@ -144,6 +145,23 @@ const appPromptPrefix = computed(() => {
144
145
  const effectiveSourcePrompt = computed(() => `${appPromptPrefix.value}${sourcePrompt.value}`.trim());
145
146
  const generatedCodeFromSteps = computed(() => buildScript(form, steps.value));
146
147
  const generatedCode = computed(() => generatedCodeOverride.value ?? generatedCodeFromSteps.value);
148
+ const generatorContentSnapshot = computed(() => JSON.stringify({
149
+ form: {
150
+ promptTitle: form.promptTitle,
151
+ testName: form.testName,
152
+ appPresetId: form.appPresetId,
153
+ },
154
+ sourcePrompt: sourcePrompt.value,
155
+ steps: steps.value,
156
+ generatedCodeOverride: generatedCodeOverride.value,
157
+ generatedCodeDraft: generatedCodeEditing.value ? generatedCodeDraft.value : '',
158
+ }));
159
+ const savedGeneratorSnapshot = shallowRef('');
160
+ const generatorHasUnsavedContent = computed(() => (
161
+ showGeneratedCode.value
162
+ && Boolean(generatedCode.value.trim())
163
+ && generatorContentSnapshot.value !== savedGeneratorSnapshot.value
164
+ ));
147
165
  const selectedScript = computed(() => savedScripts.value.find((item) => item.id === selectedScriptId.value));
148
166
  const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
149
167
  const deviceActions = [
@@ -155,8 +173,11 @@ const deviceActions = [
155
173
  { key: 'tasks', label: '任务', icon: tasksIcon, keyCode: 187 },
156
174
  ] as const;
157
175
 
158
- const selectMenu = (key: string) => {
159
- activeMenu.value = key as MenuKey;
176
+ const selectMenu = async (key: string) => {
177
+ const nextMenu = key as MenuKey;
178
+ if (nextMenu === activeMenu.value) return;
179
+ if (activeMenu.value === 'generator' && !(await confirmUnsavedGeneratorNavigation())) return;
180
+ activeMenu.value = nextMenu;
160
181
  };
161
182
 
162
183
  const resetGeneratedCodeEditor = () => {
@@ -166,11 +187,6 @@ const resetGeneratedCodeEditor = () => {
166
187
  generatedCodeEditSaving.value = false;
167
188
  };
168
189
 
169
- watch(activeGeneratorMode, () => {
170
- showGeneratedCode.value = false;
171
- resetGeneratedCodeEditor();
172
- });
173
-
174
190
  function formatScriptTime(value: string) {
175
191
  if (!value) return '';
176
192
  const date = new Date(value);
@@ -455,16 +471,16 @@ const buildSourcePromptForSave = () => {
455
471
 
456
472
  const saveCurrentScript = async () => {
457
473
  const validInput = validateCurrentGeneratorInputs();
458
- if (!validInput) return;
474
+ if (!validInput) return false;
459
475
 
460
476
  if (generatedCodeEditing.value) {
461
477
  ElMessage.error('请先保存或撤回代码修改');
462
- return;
478
+ return false;
463
479
  }
464
480
 
465
481
  if (!showGeneratedCode.value || !generatedCode.value.trim()) {
466
482
  ElMessage.error('请先点击 AI 生成,生成代码后再保存脚本');
467
- return;
483
+ return false;
468
484
  }
469
485
 
470
486
  const { scriptName, promptTitle } = validInput;
@@ -478,7 +494,7 @@ const saveCurrentScript = async () => {
478
494
  cancelButtonText: '取消',
479
495
  });
480
496
  } catch {
481
- return;
497
+ return false;
482
498
  }
483
499
  }
484
500
 
@@ -495,10 +511,34 @@ const saveCurrentScript = async () => {
495
511
  });
496
512
 
497
513
  await loadSavedScripts(payload.script?.id || '');
514
+ savedGeneratorSnapshot.value = generatorContentSnapshot.value;
498
515
  await sleep(Math.max(0, 1200 - (Date.now() - startedAt)));
499
516
  closeActionDialog();
517
+ return true;
500
518
  } catch (error) {
501
519
  failActionDialog(error instanceof Error ? error.message : '保存脚本失败');
520
+ return false;
521
+ }
522
+ };
523
+
524
+ const confirmUnsavedGeneratorNavigation = async () => {
525
+ if (!generatorHasUnsavedContent.value) return true;
526
+
527
+ try {
528
+ await ElMessageBox.confirm(
529
+ '当前生成内容尚未保存。可以先保存脚本,或暂不保存并继续切换页面。',
530
+ '未保存的测试脚本',
531
+ {
532
+ type: 'warning',
533
+ confirmButtonText: '保存脚本',
534
+ cancelButtonText: '暂不保存',
535
+ distinguishCancelAndClose: true,
536
+ closeOnClickModal: false,
537
+ },
538
+ );
539
+ return await saveCurrentScript();
540
+ } catch (action) {
541
+ return action === 'cancel';
502
542
  }
503
543
  };
504
544
 
@@ -554,7 +594,7 @@ const generateWithModel = async () => {
554
594
  errorMessage.value = '';
555
595
  const controller = new AbortController();
556
596
  aiGenerateAbortController = controller;
557
- openActionDialog('AI生成', {
597
+ openActionDialog('AI 生成', {
558
598
  canRunInBackground: true,
559
599
  canCancel: true,
560
600
  cancelText: '终止',
@@ -674,7 +714,7 @@ watch(activeMenu, menu => {
674
714
  if (!openMenus.value.includes(menu)) openMenus.value.push(menu);
675
715
  }, { flush: 'sync' });
676
716
  const warnBeforeUnload = (event: BeforeUnloadEvent) => {
677
- if (!appiumAutoSave.dirty.value) return;
717
+ if (!appiumAutoSave.dirty.value && !generatorHasUnsavedContent.value) return;
678
718
  event.preventDefault();
679
719
  event.returnValue = '';
680
720
  };
@@ -695,7 +735,9 @@ onUnmounted(() => {
695
735
  <el-dialog
696
736
  v-model="actionDialog.visible"
697
737
  :title="actionDialog.title"
698
- width="360px"
738
+ width="min(440px, calc(100vw - 32px))"
739
+ align-center
740
+ append-to-body
699
741
  :close-on-click-modal="false"
700
742
  :close-on-press-escape="!actionDialog.loading"
701
743
  :show-close="!actionDialog.loading"
@@ -712,7 +754,7 @@ onUnmounted(() => {
712
754
  <div v-if="actionDialog.loading" class="action-dialog__footer-actions">
713
755
  <el-button
714
756
  v-if="actionDialog.canCancel"
715
- class="action-dialog__footer-button action-dialog__footer-button--secondary"
757
+ class="action-dialog__footer-button"
716
758
  :icon="VideoPause"
717
759
  :loading="actionDialog.canceling"
718
760
  @click="stopGenerateWithModel"
@@ -721,7 +763,8 @@ onUnmounted(() => {
721
763
  </el-button>
722
764
  <el-button
723
765
  v-if="actionDialog.canRunInBackground"
724
- class="action-dialog__footer-button action-dialog__footer-button--primary"
766
+ type="primary"
767
+ class="action-dialog__footer-button"
725
768
  @click="sendActionDialogToBackground"
726
769
  >
727
770
  后台处理
@@ -729,7 +772,8 @@ onUnmounted(() => {
729
772
  </div>
730
773
  <el-button
731
774
  v-else
732
- class="action-dialog__footer-button action-dialog__footer-button--primary"
775
+ type="primary"
776
+ class="action-dialog__footer-button"
733
777
  @click="closeActionDialog"
734
778
  >
735
779
  关闭
package/src/style.css CHANGED
@@ -68,38 +68,33 @@ select {
68
68
  display: flex;
69
69
  align-items: center;
70
70
  justify-content: center;
71
- gap: 8px;
72
- min-height: 56px;
73
- padding: 4px 0;
74
- color: #303133;
71
+ gap: 10px;
72
+ min-height: 72px;
73
+ padding: 8px 0;
74
+ color: var(--ui-text);
75
75
  font-size: 14px;
76
76
  }
77
77
 
78
78
  .action-dialog__spinner {
79
- font-size: 16px;
79
+ color: var(--ui-primary);
80
+ font-size: 18px;
80
81
  animation: action-dialog-spin 1s linear infinite;
81
82
  }
82
83
 
83
84
  .action-dialog__error {
84
- min-height: 0;
85
- padding: 4px 0 2px;
86
- color: #f56c6c;
85
+ min-height: 72px;
86
+ padding: 14px 16px;
87
+ border: 1px solid #ffd6d6;
88
+ border-radius: var(--ui-radius-control);
89
+ background: #fff5f5;
90
+ color: var(--ui-danger);
87
91
  font-size: 13px;
88
92
  line-height: 1.6;
89
93
  white-space: pre-wrap;
90
94
  }
91
95
 
92
- .action-dialog .el-dialog__header {
93
- margin-right: 0;
94
- padding: 14px 16px 6px;
95
- }
96
-
97
96
  .action-dialog .el-dialog__body {
98
- padding: 8px 16px 6px;
99
- }
100
-
101
- .action-dialog .el-dialog__footer {
102
- padding: 6px 16px 14px;
97
+ padding-bottom: 14px;
103
98
  }
104
99
 
105
100
  .action-dialog__footer-actions {
@@ -110,44 +105,13 @@ select {
110
105
  }
111
106
 
112
107
  .action-dialog__footer-button.el-button {
113
- min-width: 88px;
114
- height: 34px;
115
- padding: 0 16px;
116
- border-radius: 7px;
117
- font-size: 14px;
118
- font-weight: 700;
108
+ min-width: 104px;
119
109
  }
120
110
 
121
111
  .action-dialog__footer-button.el-button + .action-dialog__footer-button.el-button {
122
112
  margin-left: 0;
123
113
  }
124
114
 
125
- .action-dialog__footer-button--secondary.el-button {
126
- border-color: #e5e7eb;
127
- background: #fff;
128
- color: #8b95a5;
129
- }
130
-
131
- .action-dialog__footer-button--secondary.el-button:hover,
132
- .action-dialog__footer-button--secondary.el-button:focus {
133
- border-color: #d7dce5;
134
- background: #f8fafc;
135
- color: #6b7280;
136
- }
137
-
138
- .action-dialog__footer-button--primary.el-button {
139
- border-color: #ffd436;
140
- background: #ffd436;
141
- color: #111827;
142
- }
143
-
144
- .action-dialog__footer-button--primary.el-button:hover,
145
- .action-dialog__footer-button--primary.el-button:focus {
146
- border-color: #ffca1f;
147
- background: #ffca1f;
148
- color: #111827;
149
- }
150
-
151
115
  @keyframes action-dialog-spin {
152
116
  from {
153
117
  transform: rotate(0deg);
@@ -1298,7 +1262,7 @@ select {
1298
1262
 
1299
1263
  .appium-replay-dialog.el-dialog {
1300
1264
  display: flex;
1301
- padding: 16px;
1265
+ padding: 0;
1302
1266
  height: min(620px, calc(100vh - 64px));
1303
1267
  min-width: 420px;
1304
1268
  min-height: 280px;
@@ -1318,16 +1282,16 @@ select {
1318
1282
  flex: 1 1 auto;
1319
1283
  min-height: 0;
1320
1284
  overflow: hidden;
1321
- padding: 8px 0 0;
1285
+ padding: 16px 20px 0;
1322
1286
  }
1323
1287
 
1324
1288
  .appium-replay-dialog > .el-dialog__header {
1325
- padding: 0 0 16px;
1326
- border-bottom: 0;
1289
+ padding: 18px 20px 14px;
1290
+ border-bottom: 1px solid var(--ui-border);
1327
1291
  }
1328
1292
 
1329
1293
  .appium-replay-dialog > .el-dialog__footer {
1330
- padding: 16px 0 0;
1294
+ padding: 12px 20px 18px;
1331
1295
  }
1332
1296
 
1333
1297
  .appium-replay-dialog .appium-replay-output {
@@ -3592,22 +3556,22 @@ body {
3592
3556
  flex-direction: column;
3593
3557
  height: 86dvh;
3594
3558
  max-height: calc(100dvh - 32px);
3595
- overflow: visible;
3596
- padding: 16px;
3597
- border: 0;
3598
- border-radius: 4px;
3599
- box-shadow: var(--el-box-shadow);
3559
+ overflow: hidden;
3560
+ padding: 0;
3561
+ border: 1px solid var(--ui-border);
3562
+ border-radius: var(--ui-radius);
3563
+ box-shadow: 0 18px 48px rgba(15, 23, 42, 0.18);
3600
3564
  }
3601
3565
 
3602
3566
  .appium-flow-dialog .el-dialog__header {
3603
3567
  flex: none;
3604
- padding: 0 0 16px;
3605
- border-bottom: 0;
3568
+ padding: 18px 20px 14px;
3569
+ border-bottom: 1px solid var(--ui-border);
3606
3570
  }
3607
3571
 
3608
3572
  .appium-flow-dialog .el-dialog__title {
3609
- font-size: var(--el-dialog-title-font-size);
3610
- font-weight: 400;
3573
+ font-size: 16px;
3574
+ font-weight: 650;
3611
3575
  }
3612
3576
 
3613
3577
  .appium-flow-dialog .el-dialog__body {
@@ -3615,7 +3579,7 @@ body {
3615
3579
  flex-direction: column;
3616
3580
  flex: 1;
3617
3581
  min-height: 0;
3618
- padding: 8px 0 0;
3582
+ padding: 16px 20px 20px;
3619
3583
  }
3620
3584
 
3621
3585
  .appium-flow-dialog__toolbar { flex: none; }