android-midscene-automation 0.1.32 → 0.1.34

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.
@@ -9,6 +9,10 @@ import DevicePreviewPanel from '../components/device/DevicePreviewPanel.vue';
9
9
  import LoopSettings from './components/LoopSettings.vue';
10
10
  import BreakLoopSettings from './components/BreakLoopSettings.vue';
11
11
  import VisualChangeEndPicker from './components/VisualChangeEndPicker.vue';
12
+ import PresetVariables from './components/PresetVariables.vue';
13
+ import VariableExtractionSettings from './components/VariableExtractionSettings.vue';
14
+ import ScriptParameterSettings from './components/ScriptParameterSettings.vue';
15
+ import { validateVariables, validateExtraction, validateReturns, type TestVariable } from './variables';
12
16
  import { defaultLoopConfig, enclosingLoops, breakLoopTarget, validateLoop, validateLoopSteps } from './bounded-loop';
13
17
  import {
14
18
  clearAppiumDeviceAppData,
@@ -20,6 +24,7 @@ import {
20
24
  launchAppiumDeviceApp,
21
25
  replayAppiumScript,
22
26
  saveAppiumScript,
27
+ saveVariables,
23
28
  stopAppiumReplay,
24
29
  tapAppiumDevice,
25
30
  } from './api';
@@ -66,7 +71,7 @@ import type {
66
71
  AppiumVisualChangeRegion,
67
72
  } from './types';
68
73
 
69
- type NodeStepType = 'tap' | 'input' | 'assertExists' | 'waitFor';
74
+ type NodeStepType = 'tap' | 'input' | 'waitFor';
70
75
  type BranchName = 'yes' | 'no';
71
76
  type LegacyFlow = NonNullable<AppiumRecordedStep['flow']> & { scope?: string };
72
77
  type SwipeGesture = NonNullable<AppiumRecordedStep['swipe']>;
@@ -86,10 +91,10 @@ const historyScript = shallowRef<{ id: string; name: string } | null>(null);
86
91
  const AUTO_TREE_REFRESH_INTERVAL_MS = 1800;
87
92
 
88
93
  const insertableRecorderActions: RecorderAction[] = [
94
+ 'extractVariable',
89
95
  'delay',
90
96
  'tap',
91
97
  'input',
92
- 'assertExists',
93
98
  'waitFor',
94
99
  'tapIfExists',
95
100
  'inputIfExists',
@@ -117,7 +122,6 @@ const insertableRecorderActions: RecorderAction[] = [
117
122
  'breakLoop',
118
123
  'clearAppData',
119
124
  'waitDisappear',
120
- 'assertText',
121
125
  'longPress',
122
126
  'pinch',
123
127
  'noop',
@@ -164,7 +168,7 @@ const selectedScriptId = shallowRef('');
164
168
  const newScriptDialogVisible = shallowRef(false);
165
169
  const newScriptRevision = shallowRef(0);
166
170
  const storedWorkbenchTab = window.localStorage.getItem(WORKBENCH_TAB_STORAGE_KEY);
167
- const activeWorkbenchTab = shallowRef<'recording' | 'scripts'>(storedWorkbenchTab === 'scripts' ? 'scripts' : 'recording');
171
+ const activeWorkbenchTab = shallowRef<'recording' | 'scripts' | 'variables'>(storedWorkbenchTab === 'scripts' ? 'scripts' : storedWorkbenchTab === 'variables' ? 'variables' : 'recording');
168
172
  const steps = shallowRef<AppiumRecordedStep[]>([]);
169
173
  const aiRecognitionTestStep = shallowRef<AppiumRecordedStep | null>(null);
170
174
  const flowClipboard = shallowRef<FlowClipboard | null>(null);
@@ -174,6 +178,7 @@ const replayOutput = shallowRef('');
174
178
  const replayOutputDialogVisible = shallowRef(false);
175
179
  const linkedScriptDialogVisible = shallowRef(false);
176
180
  const linkedScriptTargetId = shallowRef('');
181
+ const linkedParameters = reactive<AppiumRecordedStep>({ id: 'link-parameters', type: 'runScript', label: '', parameters: [], returns: [] });
177
182
  const linkedScriptInsertIndex = shallowRef<number | undefined>();
178
183
  const linkedScriptBranchTarget = shallowRef<BranchTarget | null>(null);
179
184
  const linkedScriptExpectedActivity = shallowRef('');
@@ -214,6 +219,7 @@ const form = reactive({
214
219
  appPackage: '',
215
220
  appActivity: '',
216
221
  });
222
+ const scriptVariables = shallowRef<TestVariable[]>([]);
217
223
 
218
224
  const savedDraftSnapshot = shallowRef('');
219
225
  const currentDraftSnapshot = computed(() => JSON.stringify({
@@ -221,6 +227,7 @@ const currentDraftSnapshot = computed(() => JSON.stringify({
221
227
  appPackage: form.appPackage,
222
228
  appActivity: form.appActivity,
223
229
  steps: steps.value,
230
+ variables: scriptVariables.value,
224
231
  }));
225
232
  const flowClipboardCount = computed(() => flowClipboard.value?.steps.length || 0);
226
233
  const hasUnsavedChanges = computed(() => currentDraftSnapshot.value !== savedDraftSnapshot.value);
@@ -229,6 +236,16 @@ function markDraftSaved() {
229
236
  savedDraftSnapshot.value = currentDraftSnapshot.value;
230
237
  }
231
238
 
239
+ function onVariablesSaved(id: string, variables: TestVariable[]) {
240
+ const script = scripts.value.find(item => item.id === id);
241
+ if (script) script.variables = variables;
242
+ if (id && selectedScriptId.value === id) {
243
+ // 仅更新变量的已保存基线,不把尚未保存的节点标记为已保存。
244
+ const snapshot = JSON.parse(savedDraftSnapshot.value);
245
+ savedDraftSnapshot.value = JSON.stringify({ ...snapshot, variables });
246
+ }
247
+ }
248
+
232
249
  function warnBeforeUnload(event: BeforeUnloadEvent) {
233
250
  if (!props.active || !hasUnsavedChanges.value) return;
234
251
  event.preventDefault();
@@ -462,7 +479,6 @@ function createStep(type: NodeStepType, node: AppiumNode): AppiumRecordedStep {
462
479
  const labelMap = {
463
480
  tap: '点击',
464
481
  input: '输入',
465
- assertExists: '断言存在',
466
482
  waitFor: '等待出现',
467
483
  };
468
484
  return {
@@ -475,7 +491,7 @@ function createStep(type: NodeStepType, node: AppiumNode): AppiumRecordedStep {
475
491
  ? [{ strategy: 'xpath', value: node.xpath, unique: true, matchCount: 1 }]
476
492
  : undefined,
477
493
  fallback: node.bounds ? { strategy: 'bounds', centerX: node.bounds.centerX, centerY: node.bounds.centerY } : undefined,
478
- timeoutMs: type === 'assertExists' || type === 'waitFor' ? 10000 : undefined,
494
+ timeoutMs: type === 'waitFor' ? 10000 : undefined,
479
495
  pageBefore: currentPageSnapshot(node),
480
496
  snapshot: {
481
497
  text: node.text,
@@ -1126,11 +1142,15 @@ function openLinkedScriptDialog(index?: number, branchTarget?: BranchTarget) {
1126
1142
  return;
1127
1143
  }
1128
1144
  linkedScriptTargetId.value = compatibleLinkableScripts.value[0]?.id || '';
1145
+ linkedParameters.parameters = [];
1146
+ linkedParameters.returns = [];
1129
1147
  linkedScriptInsertIndex.value = index;
1130
1148
  linkedScriptDialogVisible.value = true;
1131
1149
  }
1132
1150
 
1133
1151
  function addLinkedScriptStep() {
1152
+ try { validateVariables(linkedParameters.parameters); validateReturns(linkedParameters.returns); }
1153
+ catch (error) { ElMessage.error(String(error)); return; }
1134
1154
  const script = scripts.value.find((item) => item.id === linkedScriptTargetId.value);
1135
1155
  if (!script) {
1136
1156
  ElMessage.warning('请选择要连接的脚本');
@@ -1151,7 +1171,7 @@ function addLinkedScriptStep() {
1151
1171
  return;
1152
1172
  }
1153
1173
  insertStep(
1154
- createRunScriptStep(script),
1174
+ { ...createRunScriptStep(script), parameters: JSON.parse(JSON.stringify(linkedParameters.parameters || [])), returns: JSON.parse(JSON.stringify(linkedParameters.returns || [])) },
1155
1175
  linkedScriptInsertIndex.value,
1156
1176
  linkedScriptBranchTarget.value || undefined,
1157
1177
  );
@@ -1368,7 +1388,7 @@ async function addAction(
1368
1388
  if (result) return insertStep({ ...step }, index, branchTarget);
1369
1389
  return;
1370
1390
  }
1371
- if (action === 'tap' || action === 'input' || action === 'assertExists' || action === 'waitFor') {
1391
+ if (action === 'tap' || action === 'input' || action === 'waitFor') {
1372
1392
  return addStep(action, index, branchTarget);
1373
1393
  }
1374
1394
  if (action === 'keyBack') {
@@ -1491,6 +1511,22 @@ async function addAction(
1491
1511
  }
1492
1512
  const node = getSelectedNode();
1493
1513
  if (!node) return;
1514
+ if (action === 'extractVariable') {
1515
+ const step = reactive<AppiumRecordedStep>({ ...createNodeActionStep('extractVariable', '提取变量', node), extractVariable: { name: '', attribute: 'text', sensitive: false }, flow: { nodeKind: 'action' } });
1516
+ const result = await ElMessageBox({
1517
+ title: '提取组件到变量',
1518
+ message: h(ElForm, { labelPosition: 'top' }, () => h(VariableExtractionSettings, { step, onUpdate: patch => Object.assign(step, patch) })),
1519
+ showCancelButton: true, confirmButtonText: '添加', cancelButtonText: '取消',
1520
+ beforeClose: (action, _instance, done) => {
1521
+ if (action === 'confirm') {
1522
+ try { validateExtraction(step.extractVariable); } catch (error) { ElMessage.error(String(error)); return; }
1523
+ }
1524
+ done();
1525
+ },
1526
+ }).catch(() => null);
1527
+ if (result) return insertStep(JSON.parse(JSON.stringify(step)), index, branchTarget);
1528
+ return;
1529
+ }
1494
1530
  if (action === 'checkboxState' || action === 'radioButtonState' || action === 'checkedState') {
1495
1531
  const controlName = nativeControlName(action);
1496
1532
  if (!matchesNativeControl(action, node.className) || node.checkable !== true) {
@@ -1588,14 +1624,6 @@ async function addAction(
1588
1624
  if (action === 'waitDisappear') {
1589
1625
  return insertStep(createNodeActionStep('waitDisappear', '等待元素消失', node, { timeoutMs: 10000 }), index, branchTarget);
1590
1626
  }
1591
- if (action === 'assertText') {
1592
- const input = await ElMessageBox.prompt('请输入要断言的文本', '断言文本', {
1593
- inputValue: node.text,
1594
- confirmButtonText: '添加',
1595
- cancelButtonText: '取消',
1596
- }).catch(() => null);
1597
- if (input?.value) return insertStep(createNodeActionStep('assertText', '断言文本', node, { value: input.value }), index, branchTarget);
1598
- }
1599
1627
  }
1600
1628
 
1601
1629
  async function addBranchAction(index: number, branch: BranchName, action: RecorderAction) {
@@ -1643,6 +1671,7 @@ async function saveOnActivityChange() {
1643
1671
  appActivity: pending.beforeActivity || form.appActivity,
1644
1672
  deviceId: selectedDeviceId.value,
1645
1673
  steps: steps.value,
1674
+ variables: scriptVariables.value,
1646
1675
  });
1647
1676
  await loadScripts();
1648
1677
  pendingNavigation.value = null;
@@ -1714,7 +1743,7 @@ async function editInputStep(index: number) {
1714
1743
  }
1715
1744
 
1716
1745
  function setActiveWorkbenchTab(tab: string | number) {
1717
- const nextTab = tab === 'scripts' ? 'scripts' : 'recording';
1746
+ const nextTab = tab === 'scripts' ? 'scripts' : tab === 'variables' ? 'variables' : 'recording';
1718
1747
  activeWorkbenchTab.value = nextTab;
1719
1748
  window.localStorage.setItem(WORKBENCH_TAB_STORAGE_KEY, nextTab);
1720
1749
  }
@@ -1726,6 +1755,7 @@ function loadScript(script: AppiumRecordedScript) {
1726
1755
  form.name = script.name;
1727
1756
  form.appPackage = script.appPackage;
1728
1757
  form.appActivity = script.appActivity;
1758
+ scriptVariables.value = JSON.parse(JSON.stringify(script.variables || []));
1729
1759
  if (script.deviceId && script.deviceId !== selectedDeviceId.value) {
1730
1760
  void switchDevice(script.deviceId);
1731
1761
  }
@@ -1742,6 +1772,8 @@ function loadSelectedScript() {
1742
1772
  }
1743
1773
 
1744
1774
  function resetCurrentScript() {
1775
+ void saveVariables('/api/appium-recorder/variables?draft=1', []).catch(() => ElMessage.error('变量草稿清理失败'));
1776
+ scriptVariables.value = [];
1745
1777
  flowClipboard.value = null;
1746
1778
  selectedScriptId.value = '';
1747
1779
  form.name = '';
@@ -1753,7 +1785,7 @@ function resetCurrentScript() {
1753
1785
 
1754
1786
  function requestNewScript() {
1755
1787
  if (newScriptDisabled.value) return;
1756
- if (selectedScriptId.value || form.name.trim() || steps.value.length) {
1788
+ if (selectedScriptId.value || form.name.trim() || steps.value.length || scriptVariables.value.length) {
1757
1789
  newScriptDialogVisible.value = true;
1758
1790
  return;
1759
1791
  }
@@ -1808,6 +1840,7 @@ async function duplicateScript(script: AppiumRecordedScript) {
1808
1840
  try {
1809
1841
  const payload = await saveAppiumScript({
1810
1842
  name: createDuplicateScriptName(script.name),
1843
+ variables: script.variables || [],
1811
1844
  appPackage: script.appPackage,
1812
1845
  appActivity: script.appActivity,
1813
1846
  deviceId: script.deviceId,
@@ -1939,8 +1972,10 @@ async function importScriptFile(event: Event) {
1939
1972
 
1940
1973
  async function saveScript() {
1941
1974
  if (!ensureAppPackageSelected()) return false;
1975
+ const wasNew = !selectedScriptId.value;
1942
1976
  saving.value = true;
1943
1977
  try {
1978
+ validateVariables(scriptVariables.value);
1944
1979
  const payload = await saveAppiumScript({
1945
1980
  id: selectedScriptId.value || undefined,
1946
1981
  name: form.name,
@@ -1948,9 +1983,11 @@ async function saveScript() {
1948
1983
  appActivity: form.appActivity,
1949
1984
  deviceId: selectedDeviceId.value,
1950
1985
  steps: steps.value,
1986
+ variables: scriptVariables.value,
1951
1987
  });
1952
1988
  await loadScripts();
1953
1989
  selectedScriptId.value = payload.script.id;
1990
+ if (wasNew) void saveVariables('/api/appium-recorder/variables?draft=1', []).catch(() => ElMessage.error('变量草稿清理失败'));
1954
1991
  markDraftSaved();
1955
1992
  ElMessage.success('Appium 脚本已保存');
1956
1993
  return true;
@@ -2192,6 +2229,9 @@ watch(
2192
2229
  </div>
2193
2230
  </el-tab-pane>
2194
2231
 
2232
+ <el-tab-pane label="预设变量" name="variables" lazy>
2233
+ <PresetVariables :key="`${selectedScriptId}:${newScriptRevision}`" v-model:variables="scriptVariables" :script-id="selectedScriptId" :script-name="form.name" :disabled="recordingBusy || saving" @saved="onVariablesSaved" />
2234
+ </el-tab-pane>
2195
2235
  <el-tab-pane label="脚本列表" name="scripts">
2196
2236
  <div class="appium-script-list">
2197
2237
  <div class="appium-script-list__toolbar">
@@ -2365,6 +2405,7 @@ watch(
2365
2405
  </el-button>
2366
2406
  </div>
2367
2407
  </el-form-item>
2408
+ <ScriptParameterSettings :step="linkedParameters" @update="Object.assign(linkedParameters, $event)" />
2368
2409
  </el-form>
2369
2410
  <template #footer>
2370
2411
  <el-button @click="closeLinkedScriptDialog">取消</el-button>
@@ -1,6 +1,26 @@
1
1
  import { APP_BASE } from '../api';
2
2
  import type { AppiumRecordedScript, AppiumRecordedStep } from './types';
3
3
  import type { AiRecognitionResult } from './ai-recognition';
4
+ import type { TestVariable } from './variables';
5
+
6
+ // 队列跨组件挂载保留,切换脚本或清空草稿时仍按编辑顺序写入。
7
+ const variableWrites = new Map<string, Promise<unknown>>();
8
+ export function saveVariables(url: string, variables: TestVariable[]) {
9
+ const body = JSON.stringify({ variables });
10
+ const task = (variableWrites.get(url) || Promise.resolve()).catch(() => {}).then(async () => {
11
+ return readJson<{ variables: TestVariable[] }>(await fetch(url, {
12
+ method: 'PUT', headers: { 'Content-Type': 'application/json' }, body, keepalive: true,
13
+ }));
14
+ });
15
+ variableWrites.set(url, task);
16
+ void task.finally(() => { if (variableWrites.get(url) === task) variableWrites.delete(url); }).catch(() => {});
17
+ return task;
18
+ }
19
+
20
+ export async function loadVariables(url: string) {
21
+ await variableWrites.get(url)?.catch(() => {});
22
+ return readJson<{ variables: TestVariable[] }>(await fetch(url));
23
+ }
4
24
 
5
25
  async function readJson<T>(response: Response) {
6
26
  const payload = (await response.json().catch(() => ({}))) as T & {
@@ -56,6 +76,7 @@ export async function testAiRecognition(input: { deviceId: string; prompt: strin
56
76
  }
57
77
 
58
78
  export function saveAppiumScript(input: {
79
+ variables?: import('./variables').TestVariable[];
59
80
  id?: string;
60
81
  name: string;
61
82
  appPackage: string;
@@ -105,7 +126,7 @@ type ReplayStreamEvent =
105
126
  | { type: 'error'; message: string };
106
127
 
107
128
  export async function replayAppiumScript(
108
- input: { id: string; deviceId?: string },
129
+ input: { id: string; deviceId?: string; parameters?: import('./variables').TestVariable[] },
109
130
  onOutput?: (line: string) => void,
110
131
  ) {
111
132
  const response = await fetch(`${APP_BASE}/api/appium-recorder/scripts/${encodeURIComponent(input.id)}/replay`, {
@@ -114,7 +135,7 @@ export async function replayAppiumScript(
114
135
  'Content-Type': 'application/json',
115
136
  Accept: 'application/x-ndjson',
116
137
  },
117
- body: JSON.stringify({ deviceId: input.deviceId }),
138
+ body: JSON.stringify({ deviceId: input.deviceId, parameters: input.parameters }),
118
139
  });
119
140
  if (!response.ok || !response.body) return readJson<ReplayResult>(response);
120
141
 
@@ -10,6 +10,8 @@ import LongPressSettings from './LongPressSettings.vue';
10
10
  import StageLogSettings from './StageLogSettings.vue';
11
11
  import LoopSettings from './LoopSettings.vue';
12
12
  import BreakLoopSettings from './BreakLoopSettings.vue';
13
+ import VariableExtractionSettings from './VariableExtractionSettings.vue';
14
+ import ScriptParameterSettings from './ScriptParameterSettings.vue';
13
15
 
14
16
  type FlowKind = 'action' | 'condition' | 'assertion';
15
17
  type SwipeGesture = NonNullable<AppiumRecordedStep['swipe']>;
@@ -77,6 +79,20 @@ function patchSwipe(patch: Partial<SwipeGesture>) {
77
79
  });
78
80
  }
79
81
 
82
+ function patchTapCoordinate(axis: 'centerX' | 'centerY', value: number | undefined) {
83
+ if (value === undefined || !Number.isFinite(value)) return;
84
+ const fallback = {
85
+ ...props.step.fallback,
86
+ strategy: 'bounds' as const,
87
+ centerX: props.step.fallback?.centerX ?? 0,
88
+ centerY: props.step.fallback?.centerY ?? 0,
89
+ [axis]: Math.max(0, Math.round(value)),
90
+ };
91
+ // 仅同步自动生成的名称,保留用户自定义的节点名称。
92
+ patchStep({ fallback, ...(/^点击坐标 \d+,\d+$/.test(props.step.label)
93
+ ? { label: `点击坐标 ${fallback.centerX},${fallback.centerY}` } : {}) });
94
+ }
95
+
80
96
  function patchVisualChange(patch: Partial<VisualChangeConfig>) {
81
97
  patchStep({
82
98
  visualChange: normalizeVisualChangeConfig({
@@ -109,7 +125,7 @@ function patchSelector(patch: Partial<AppiumSelector>) {
109
125
  patchStep({
110
126
  selector: { ...props.step.selector!, ...patch },
111
127
  // 修改元素目标后,不能继续优先使用录制时的备用 XPath。
112
- ...(props.step.type === 'longPress' || isNativeStateCondition(props.step) ? { selectorChain: undefined } : {}),
128
+ ...(props.step.type === 'longPress' || props.step.type === 'extractVariable' || isNativeStateCondition(props.step) ? { selectorChain: undefined } : {}),
113
129
  });
114
130
  }
115
131
  </script>
@@ -137,7 +153,7 @@ function patchSelector(patch: Partial<AppiumSelector>) {
137
153
  <el-form-item label="节点类型">
138
154
  <el-select
139
155
  :model-value="defaultKind()"
140
- :disabled="disabled || isBooleanCondition(step) || step.type === 'log' || step.type === 'openGallery' || step.type === 'endFlow' || step.type === 'loop' || step.type === 'breakLoop'"
156
+ :disabled="disabled || isBooleanCondition(step) || step.type === 'extractVariable' || step.type === 'log' || step.type === 'openGallery' || step.type === 'endFlow' || step.type === 'loop' || step.type === 'breakLoop'"
141
157
  @update:model-value="patchFlow({ nodeKind: $event as FlowKind })"
142
158
  >
143
159
  <el-option label="操作" value="action" />
@@ -157,7 +173,17 @@ function patchSelector(patch: Partial<AppiumSelector>) {
157
173
  </el-form-item>
158
174
  </div>
159
175
  <LongPressSettings v-if="step.type === 'longPress'" :step="step" :disabled="disabled" @update="patchStep" />
176
+ <div v-if="step.type === 'coordinateTap'" class="appium-flow-editor__grid">
177
+ <el-form-item label="坐标 X">
178
+ <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)" />
179
+ </el-form-item>
180
+ <el-form-item label="坐标 Y">
181
+ <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)" />
182
+ </el-form-item>
183
+ </div>
160
184
  <StageLogSettings v-if="step.type === 'log'" :step="step" :disabled="disabled" @update="patchStep" />
185
+ <VariableExtractionSettings v-if="step.type === 'extractVariable'" :step="step" :disabled="disabled" @update="patchStep" />
186
+ <ScriptParameterSettings v-if="step.type === 'runScript'" :step="step" :disabled="disabled" @update="patchStep" />
161
187
  <LoopSettings v-if="step.type === 'loop'" :step="step" :disabled="disabled" @update="patchStep" />
162
188
  <BreakLoopSettings v-if="step.type === 'breakLoop'" :step="step" :steps="steps" :disabled="disabled" @update="patchStep" />
163
189
  <TextClickSettings v-if="step.type === 'textClick'" :step="step" :disabled="disabled" @update="patchStep" />
@@ -44,6 +44,7 @@ type InsertAction =
44
44
  | 'waitDisappear'
45
45
  | 'waitActivity'
46
46
  | 'runScript'
47
+ | 'extractVariable'
47
48
  | 'noop'
48
49
  | 'log'
49
50
  | 'visualChangeStart'
@@ -89,6 +90,7 @@ const actionGroups: Array<{ title: string; actions: Array<{ type: InsertAction;
89
90
  actions: [
90
91
  { type: 'tap', label: '录制点击' },
91
92
  { type: 'input', label: '录制输入' },
93
+ { type: 'extractVariable', label: '提取变量' },
92
94
  { type: 'clearInput', label: '清空输入' },
93
95
  { type: 'coordinateTap', label: '点击坐标' },
94
96
  { type: 'longPress', label: '长按' },
@@ -120,8 +122,6 @@ const actionGroups: Array<{ title: string; actions: Array<{ type: InsertAction;
120
122
  { type: 'clearIfExists', label: '存在则清空' },
121
123
  { type: 'backIfExists', label: '存在则返回' },
122
124
  { type: 'waitFor', label: '等待出现' },
123
- { type: 'assertExists', label: '断言存在' },
124
- { type: 'assertText', label: '断言文本' },
125
125
  { type: 'waitDisappear', label: '等待元素消失' },
126
126
  { type: 'waitActivity', label: '等待 Activity' },
127
127
  { type: 'visualChangeStart', label: '检测画面变化开始' },
@@ -0,0 +1,109 @@
1
+ <script setup lang="ts">
2
+ import { onBeforeUnmount, onMounted, ref } from 'vue';
3
+ import { QuestionFilled } from '@element-plus/icons-vue';
4
+ import { loadVariables, saveVariables } from '../api';
5
+ import VariableTable from './VariableTable.vue';
6
+ import { validateVariables, type TestVariable } from '../variables';
7
+ const props = defineProps<{ variables: TestVariable[]; disabled?: boolean; scriptName: string; scriptId: string }>();
8
+ const emit = defineEmits<{ 'update:variables': [TestVariable[]]; saved: [string, TestVariable[]] }>();
9
+ const globals = ref<TestVariable[]>([]);
10
+ const busy = ref(false);
11
+ const loaded = ref(false);
12
+ const error = ref('');
13
+ const status = ref({ global: '', private: '' });
14
+ const invalid = ref({ global: false, private: false });
15
+ const revisions = { global: 0, private: 0 };
16
+ let mounted = true;
17
+ onBeforeUnmount(() => { mounted = false; });
18
+ const privateUrl = props.scriptId ? `/api/appium-recorder/variables?scriptId=${encodeURIComponent(props.scriptId)}` : '/api/appium-recorder/variables?draft=1';
19
+ async function load() {
20
+ busy.value = true; error.value = '';
21
+ try {
22
+ globals.value = (await loadVariables('/api/appium-recorder/variables')).variables; loaded.value = true;
23
+ if (props.scriptId || !props.variables.length) {
24
+ const variables = (await loadVariables(privateUrl)).variables;
25
+ if (mounted) emit('update:variables', variables);
26
+ }
27
+ } catch (cause) { error.value = String(cause); }
28
+ finally { busy.value = false; }
29
+ }
30
+ function update(scope: 'global' | 'private', values: TestVariable[]) {
31
+ if (scope === 'global') globals.value = values;
32
+ else emit('update:variables', values);
33
+ const revision = ++revisions[scope];
34
+ let variables: TestVariable[];
35
+ try {
36
+ variables = validateVariables(values);
37
+ } catch (cause) {
38
+ status.value[scope] = cause instanceof Error ? cause.message : String(cause);
39
+ invalid.value[scope] = true;
40
+ return;
41
+ }
42
+ invalid.value[scope] = false;
43
+ status.value[scope] = '正在保存…';
44
+ // 同一作用域串行写入,慢响应不能覆盖后一次编辑;不锁定输入框。
45
+ void (async () => {
46
+ try {
47
+ await saveVariables(scope === 'global' ? '/api/appium-recorder/variables' : privateUrl, variables);
48
+ if (scope === 'private') emit('saved', props.scriptId, variables);
49
+ if (revision === revisions[scope]) status.value[scope] = scope === 'private' && !props.scriptId ? '草稿已自动保存' : '已自动保存';
50
+ } catch (cause) {
51
+ if (revision === revisions[scope]) {
52
+ status.value[scope] = cause instanceof Error ? cause.message : String(cause);
53
+ invalid.value[scope] = true;
54
+ }
55
+ }
56
+ })();
57
+ }
58
+ onMounted(load);
59
+ </script>
60
+
61
+ <template>
62
+ <div class="preset-variables">
63
+ <section class="preset-variables__section" aria-label="全局变量">
64
+ <div class="preset-variables__title">
65
+ <h3>全局变量</h3>
66
+ <el-tooltip placement="bottom-end" effect="light" :show-after="150">
67
+ <template #content>
68
+ <div class="preset-variables__help">
69
+ <strong>如何使用预设变量</strong>
70
+ <p>全局变量可在所有脚本中使用;私有变量仅在当前脚本中使用,同名时优先使用私有变量。</p>
71
+ <p>编辑后自动保存,变量值可以为空字符串。新脚本的私有变量先保存为草稿,首次保存脚本时归入该脚本。</p>
72
+ <p>使用 <code v-pre>{{变量名}}</code> 在输入、判断、定位、日志中引用,例如 <code v-pre>{{account}}</code>。变量名使用字母、数字和下划线,不能以数字开头。</p>
73
+ <p>敏感值在日志和报告中脱敏,导出时清空敏感值;密码、令牌等名称自动标记为敏感,也可手动勾选。</p>
74
+ <p>含敏感变量的运行不保存截图或 Appium 原始日志。本地变量非加密存储。</p>
75
+ </div>
76
+ </template>
77
+ <el-button class="preset-variables__help-button" :icon="QuestionFilled" text aria-label="预设变量使用说明" />
78
+ </el-tooltip>
79
+ </div>
80
+ <el-alert v-if="error" :title="error" type="error" :closable="false" /><el-button v-if="error" @click="load">重新加载</el-button>
81
+ <VariableTable :model-value="globals" :disabled="disabled || busy || !loaded" @update:model-value="update('global', $event)">
82
+ <span class="preset-variables__status" :class="{ 'is-error': invalid.global }" role="status">{{ status.global }}</span>
83
+ </VariableTable>
84
+ </section>
85
+ <section class="preset-variables__section" aria-label="当前脚本私有变量">
86
+ <div class="preset-variables__title"><h3>当前脚本私有变量</h3></div>
87
+ <div class="preset-variables__script">{{ scriptName || '未命名脚本' }}</div>
88
+ <VariableTable :model-value="variables" :disabled="disabled || busy" @update:model-value="update('private', $event)">
89
+ <span class="preset-variables__status" :class="{ 'is-error': invalid.private }" role="status">{{ status.private }}</span>
90
+ </VariableTable>
91
+ </section>
92
+ </div>
93
+ </template>
94
+
95
+ <style scoped>
96
+ .preset-variables { padding: 12px 16px; overflow: auto; }
97
+ .preset-variables__help-button { width: 28px; height: 28px; padding: 0; font-size: 16px; }
98
+ .preset-variables__section { padding: 16px 0 22px; }
99
+ .preset-variables__section:first-child { padding-top: 0; }
100
+ .preset-variables__section + .preset-variables__section { border-top: 1px solid var(--el-border-color-lighter); }
101
+ .preset-variables__title { display: flex; align-items: center; gap: 8px; margin-bottom: 12px; }
102
+ .preset-variables__title h3 { margin: 0; font-size: 14px; font-weight: 600; }
103
+ .preset-variables__script { font-size: 12px; color: var(--el-text-color-secondary); margin: -5px 0 12px; overflow-wrap: anywhere; }
104
+ .preset-variables__help { width: min(340px, calc(100vw - 64px)); font-size: 13px; line-height: 1.7; overflow-wrap: anywhere; }
105
+ .preset-variables__help p { margin: 8px 0 0; }
106
+ .preset-variables__help code { color: var(--el-color-primary); }
107
+ .preset-variables__status { font-size: 12px; color: var(--el-text-color-secondary); overflow-wrap: anywhere; }
108
+ .preset-variables__status.is-error { color: var(--el-color-danger); }
109
+ </style>
@@ -61,6 +61,7 @@ const insertActionGroups: FlowActionGroup[] = [
61
61
  actions: [
62
62
  { type: 'tap', label: '录制点击' },
63
63
  { type: 'input', label: '录制输入' },
64
+ { type: 'extractVariable', label: '提取变量' },
64
65
  { type: 'clearInput', label: '清空输入' },
65
66
  { type: 'coordinateTap', label: '点击坐标' },
66
67
  { type: 'longPress', label: '长按' },
@@ -93,8 +94,6 @@ const insertActionGroups: FlowActionGroup[] = [
93
94
  { type: 'clearIfExists', label: '存在则清空' },
94
95
  { type: 'backIfExists', label: '存在则返回' },
95
96
  { type: 'waitFor', label: '等待出现' },
96
- { type: 'assertExists', label: '断言存在' },
97
- { type: 'assertText', label: '断言文本' },
98
97
  { type: 'waitDisappear', label: '等待元素消失' },
99
98
  { type: 'waitActivity', label: '等待 Activity' },
100
99
  { type: 'visualChangeStart', label: '检测画面变化开始节点' },
@@ -0,0 +1,31 @@
1
+ <script setup lang="ts">
2
+ import { Delete, Plus } from '@element-plus/icons-vue';
3
+ import VariableTable from './VariableTable.vue';
4
+ import type { AppiumRecordedStep } from '../types';
5
+ import type { ScriptReturn } from '../variables';
6
+ const props = defineProps<{ step: AppiumRecordedStep; disabled?: boolean }>();
7
+ const emit = defineEmits<{ update: [Partial<AppiumRecordedStep>] }>();
8
+ function update(index: number, patch: Partial<ScriptReturn>) {
9
+ emit('update', { returns: (props.step.returns || []).map((item, i) => i === index ? { ...item, ...patch } : item) });
10
+ }
11
+ </script>
12
+
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">
17
+ <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) })" />
21
+ <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
+ </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>
26
+ </template>
27
+
28
+ <style scoped>
29
+ .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; }
31
+ </style>
@@ -0,0 +1,17 @@
1
+ <script setup lang="ts">
2
+ import type { AppiumRecordedStep } from '../types';
3
+ import { sensitiveVariableName, type VariableExtraction } from '../variables';
4
+ const props = defineProps<{ step: AppiumRecordedStep; disabled?: boolean }>();
5
+ const emit = defineEmits<{ update: [Partial<AppiumRecordedStep>] }>();
6
+ function update(patch: Partial<VariableExtraction>) {
7
+ emit('update', { extractVariable: { name: '', attribute: 'text', ...props.step.extractVariable, ...patch } });
8
+ }
9
+ </script>
10
+
11
+ <template>
12
+ <el-form-item label="保存到当前脚本变量"><el-input :model-value="step.extractVariable?.name || ''" :disabled="disabled" aria-label="提取变量名" @update:model-value="update({ name: String($event) })" /></el-form-item>
13
+ <el-form-item label="组件属性"><el-select :model-value="step.extractVariable?.attribute || 'text'" :disabled="disabled" filterable allow-create default-first-option aria-label="提取组件属性" @update:model-value="update({ attribute: String($event) })">
14
+ <el-option v-for="name in ['text', 'content-desc', 'checked', 'selected', 'enabled', 'resource-id', 'class']" :key="name" :label="name" :value="name" />
15
+ </el-select></el-form-item>
16
+ <el-form-item label="敏感变量"><el-checkbox :model-value="step.extractVariable?.sensitive || sensitiveVariableName(step.extractVariable?.name || '')" :disabled="disabled || sensitiveVariableName(step.extractVariable?.name || '')" @update:model-value="update({ sensitive: Boolean($event) })" /></el-form-item>
17
+ </template>
@@ -0,0 +1,43 @@
1
+ <script setup lang="ts">
2
+ import { Delete, Plus, CopyDocument } from '@element-plus/icons-vue';
3
+ import { ElMessage } from 'element-plus';
4
+ import { sensitiveVariableName, type TestVariable } from '../variables';
5
+ const props = defineProps<{ modelValue: TestVariable[]; disabled?: boolean }>();
6
+ const emit = defineEmits<{ 'update:modelValue': [TestVariable[]] }>();
7
+ function update(index: number, patch: Partial<TestVariable>) {
8
+ emit('update:modelValue', props.modelValue.map((item, i) => i === index ? { ...item, ...patch } : item));
9
+ }
10
+ async function copy(name: string) {
11
+ try { await navigator.clipboard.writeText(`{{${name}}}`); ElMessage.success('已复制变量引用'); }
12
+ catch { ElMessage.error('复制失败'); }
13
+ }
14
+ </script>
15
+
16
+ <template>
17
+ <div class="variable-table">
18
+ <el-table :data="modelValue" empty-text="暂无变量" size="small">
19
+ <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
+ <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
+ <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>
22
+ <el-table-column label="操作" width="76"><template #default="{ row, $index }"><div class="variable-table__actions">
23
+ <el-tooltip content="复制引用"><el-button :icon="CopyDocument" text size="small" :disabled="!row.name" aria-label="复制变量引用" @click="copy(row.name)" /></el-tooltip>
24
+ <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
+ </div></template></el-table-column>
26
+ </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>
29
+ <slot />
30
+ </div>
31
+ </div>
32
+ </template>
33
+
34
+ <style scoped>
35
+ .variable-table { min-width: 0; }
36
+ .variable-table__footer { display: flex; align-items: center; justify-content: space-between; gap: 8px; flex-wrap: wrap; margin-top: 12px; }
37
+ .variable-table__actions { display: flex; align-items: center; gap: 2px; }
38
+ .variable-table__actions :deep(.el-button) { width: 26px; height: 28px; padding: 0; }
39
+ .variable-table :deep(.el-table th.el-table__cell) { background: var(--el-fill-color-light); color: var(--el-text-color-secondary); font-size: 12px; padding: 7px 0; }
40
+ .variable-table :deep(.el-table td.el-table__cell) { padding: 8px 0; }
41
+ .variable-table :deep(.el-table .cell) { padding: 0 8px; }
42
+ .variable-table :deep(.el-button + .el-button) { margin-left: 0; }
43
+ </style>