android-midscene-automation 1.1.3 → 1.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (28) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/README.md +9 -0
  3. package/artemis-main/apps/admin_console/server.py +33 -0
  4. package/package.json +1 -1
  5. package/server/appium-recorder/appium-runner.ts +231 -78
  6. package/server/appium-recorder/element-tap.test.ts +171 -6
  7. package/server/appium-recorder/managed-cleanup.test.ts +7 -7
  8. package/server/appium-recorder/replay-lifecycle.test.ts +4 -4
  9. package/server/appium-recorder/repository.ts +39 -9
  10. package/server/appium-recorder/request-timeout.ts +5 -0
  11. package/server/appium-recorder/routes.ts +38 -15
  12. package/server/appium-recorder/script-import.test.ts +45 -0
  13. package/server/appium-recorder/tree-dump.ts +44 -0
  14. package/server/remote-agents/replay-integration.test.ts +4 -4
  15. package/src/App.vue +10 -10
  16. package/src/appium-recorder/AppiumPage.vue +58 -12
  17. package/src/appium-recorder/RunHistoryDialog.vue +91 -51
  18. package/src/appium-recorder/api.ts +4 -0
  19. package/src/appium-recorder/components/ComponentTree.vue +72 -10
  20. package/src/appium-recorder/components/FlowStepEditor.vue +51 -9
  21. package/src/appium-recorder/components/NodeDetail.vue +129 -52
  22. package/src/appium-recorder/components/RecordedSteps.vue +1 -0
  23. package/src/appium-recorder/components/ScriptParameterSettings.vue +44 -10
  24. package/src/appium-recorder/components/VariableTable.vue +44 -5
  25. package/src/appium-recorder/tree.ts +1 -0
  26. package/src/components/config/AiDeduplicationSettings.vue +0 -1
  27. package/src/components/config/MidsceneConfigPanel.vue +12 -2
  28. package/src/style.css +296 -55
@@ -11,7 +11,32 @@ const props = defineProps<{
11
11
  loading?: boolean;
12
12
  }>();
13
13
  const detailsVisible = ref(false);
14
- const locators = computed(() => props.node ? nodeLocators(props.node) : []);
14
+ const locatorItems = computed(() => {
15
+ const node = props.node;
16
+ if (!node) return [];
17
+ const available = nodeLocators(node).filter(locator => Boolean(locator.value));
18
+ const preferredNames: Partial<Record<AppiumNode['selector']['strategy'], string>> = {
19
+ id: 'Resource ID',
20
+ accessibilityId: 'Accessibility ID',
21
+ xpath: 'XPath',
22
+ androidUiAutomator: 'UiAutomator',
23
+ };
24
+ const preferredName = preferredNames[node.selector.strategy];
25
+ const preferredIndex = Math.max(0, available.findIndex(locator => locator.name === preferredName));
26
+ const descriptions: Record<string, string> = {
27
+ 'Resource ID': '通常最稳定,页面文案变化时也可继续定位',
28
+ 'Accessibility ID': '使用 content-desc 定位,适合无 Resource ID 的组件',
29
+ XPath: '依赖组件层级,页面结构变化后可能需要重新获取',
30
+ UiAutomator: 'Android 原生定位表达式,可组合组件属性',
31
+ };
32
+ return available
33
+ .map((locator, index) => ({
34
+ ...locator,
35
+ description: descriptions[locator.name] || '',
36
+ recommended: index === preferredIndex,
37
+ }))
38
+ .sort((left, right) => Number(right.recommended) - Number(left.recommended));
39
+ });
15
40
  async function copyLocator(value: string) {
16
41
  if (!value) return;
17
42
  try { await navigator.clipboard.writeText(value); ElMessage.success('定位内容已复制'); }
@@ -33,53 +58,77 @@ async function copyLocator(value: string) {
33
58
  <span v-else class="appium-node-summary__empty">请选择组件</span>
34
59
  <el-button size="small" type="primary" :icon="View" :disabled="!node" @click="detailsVisible = true">详情</el-button>
35
60
  </div>
36
- <el-drawer v-model="detailsVisible" title="组件详情" size="520px" style="max-width: 94vw" append-to-body>
37
- <template v-if="node">
38
- <div class="appium-node-detail__title">{{ node.label }}</div>
39
- <el-descriptions class="appium-node-properties" :column="1" border size="small">
40
- <el-descriptions-item v-for="locator in locators" :key="locator.name">
41
- <template #label><span class="appium-node-locator-label">{{ locator.name }}<small v-if="locator.attribute">{{ locator.attribute }}</small></span></template>
42
- <div class="appium-node-locator">
43
- <code>{{ locator.value || '未提供' }}</code>
44
- <el-tooltip :content="`复制 ${locator.name}`" placement="top" :show-after="200">
45
- <el-button class="appium-node-locator-copy" size="small" :icon="CopyDocument" :disabled="!locator.value" :aria-label="`复制 ${locator.name}`" @click="copyLocator(locator.value)" />
46
- </el-tooltip>
47
- </div>
48
- <small v-if="locator.warning" class="appium-node-locator-warning">{{ locator.warning }}</small>
49
- </el-descriptions-item>
50
- <el-descriptions-item label="text">{{ node.text || '-' }}</el-descriptions-item>
51
- <el-descriptions-item label="class">{{ node.className || '-' }}</el-descriptions-item>
52
- <el-descriptions-item v-if="node.checkable !== undefined" label="checkable">{{ String(node.checkable) }}</el-descriptions-item>
53
- <el-descriptions-item v-if="node.checkable" label="checked">{{ node.checked === undefined ? '-' : String(node.checked) }}</el-descriptions-item>
54
- <el-descriptions-item label="当前 Activity">{{ currentActivity || '-' }}</el-descriptions-item>
55
- <el-descriptions-item label="selector">
56
- {{ node.selector.strategy }} {{ node.selector.value || '' }}
57
- <el-tag
58
- v-if="node.selector.matchCount"
59
- size="small"
60
- :type="node.selector.matchCount > 1 ? 'warning' : 'success'"
61
- >
62
- {{ node.selector.matchCount > 1 ? `重复 ${node.selector.matchCount}` : '唯一' }}
63
- </el-tag>
64
- </el-descriptions-item>
65
- <el-descriptions-item label="推荐定位">
66
- <span v-if="node.contextSelector">
67
- 父级 {{ node.contextSelector.strategy }} {{ node.contextSelector.value || '' }}
68
- + 子级 {{ node.selector.strategy }} {{ node.selector.value || '' }}
69
- </span>
70
- <span v-else>
71
- {{ node.selector.unique ? '当前 selector 唯一' : '使用当前 selector 或坐标兜底' }}
72
- </span>
73
- </el-descriptions-item>
74
- <el-descriptions-item label="bounds">
75
- <span v-if="node.bounds">
76
- [{{ node.bounds.left }},{{ node.bounds.top }}][{{ node.bounds.right }},{{ node.bounds.bottom }}]
77
- </span>
78
- <span v-else>-</span>
79
- </el-descriptions-item>
80
- </el-descriptions>
81
- </template>
82
- <p v-else>请选择组件</p>
61
+ <el-drawer v-model="detailsVisible" title="组件详情" size="560px" class="appium-node-detail-drawer" append-to-body destroy-on-close>
62
+ <template v-if="node">
63
+ <div class="appium-node-detail__content">
64
+ <section class="appium-node-detail__summary">
65
+ <div>
66
+ <span>当前组件</span>
67
+ <strong>{{ node.label }}</strong>
68
+ <code>{{ node.className || '未知组件类型' }}</code>
69
+ </div>
70
+ <div class="appium-node-detail__tags">
71
+ <el-tag :type="node.enabled ? 'success' : 'info'" size="small">{{ node.enabled ? '可用' : '不可用' }}</el-tag>
72
+ <el-tag :type="node.clickable ? 'primary' : 'info'" size="small">{{ node.clickable ? '可点击' : '不可点击' }}</el-tag>
73
+ <el-tag v-if="node.selector.matchCount" :type="node.selector.matchCount > 1 ? 'warning' : 'success'" size="small">{{ node.selector.matchCount > 1 ? `定位重复 ${node.selector.matchCount}` : '定位唯一' }}</el-tag>
74
+ </div>
75
+ </section>
76
+
77
+ <section class="appium-node-detail__panel appium-node-detail__panel--locators">
78
+ <div class="appium-node-detail__heading">
79
+ <strong>定位方式</strong>
80
+ <span>已按当前脚本使用优先级排列,未提供的定位属性自动隐藏</span>
81
+ </div>
82
+ <div class="appium-node-locators">
83
+ <article v-for="locator in locatorItems" :key="locator.name" class="appium-node-locator-card" :class="{ 'is-recommended': locator.recommended }">
84
+ <div class="appium-node-locator-card__header">
85
+ <el-tooltip :content="locator.description" placement="top" :show-after="300">
86
+ <span class="appium-node-locator-label"><strong>{{ locator.name }}</strong><small v-if="locator.attribute">{{ locator.attribute }}</small></span>
87
+ </el-tooltip>
88
+ <el-tag v-if="locator.recommended" type="primary" size="small">当前使用</el-tag>
89
+ </div>
90
+ <div class="appium-node-locator-value">
91
+ <code>{{ locator.value }}</code>
92
+ <el-tooltip :content="`复制 ${locator.name}`" placement="top" :show-after="200">
93
+ <el-button class="appium-node-locator-copy" text size="small" :icon="CopyDocument" :aria-label="`复制 ${locator.name}`" @click="copyLocator(locator.value)" />
94
+ </el-tooltip>
95
+ </div>
96
+ <small v-if="locator.warning" class="appium-node-locator-warning">{{ locator.warning }}</small>
97
+ </article>
98
+ <el-empty v-if="!locatorItems.length" :image-size="48" description="当前组件没有可用定位信息" />
99
+ </div>
100
+ </section>
101
+
102
+ <section class="appium-node-detail__panel">
103
+ <div class="appium-node-detail__heading"><strong>组件属性</strong><span>当前组件树快照中的原始信息</span></div>
104
+ <el-descriptions class="appium-node-properties" :column="1" border size="small">
105
+ <el-descriptions-item label="text">{{ node.text || '-' }}</el-descriptions-item>
106
+ <el-descriptions-item label="content-desc">{{ node.contentDesc || '-' }}</el-descriptions-item>
107
+ <el-descriptions-item label="resource-id">{{ node.resourceId || '-' }}</el-descriptions-item>
108
+ <el-descriptions-item label="class">{{ node.className || '-' }}</el-descriptions-item>
109
+ <el-descriptions-item label="package">{{ node.packageName || '-' }}</el-descriptions-item>
110
+ <el-descriptions-item label="enabled">{{ String(node.enabled) }}</el-descriptions-item>
111
+ <el-descriptions-item label="clickable">{{ String(node.clickable) }}</el-descriptions-item>
112
+ <el-descriptions-item v-if="node.checkable !== undefined" label="checkable">{{ String(node.checkable) }}</el-descriptions-item>
113
+ <el-descriptions-item v-if="node.checkable" label="checked">{{ node.checked === undefined ? '-' : String(node.checked) }}</el-descriptions-item>
114
+ <el-descriptions-item label="当前 Activity">{{ currentActivity || '-' }}</el-descriptions-item>
115
+ <el-descriptions-item label="selector">
116
+ <code>{{ node.selector.strategy }} {{ node.selector.value || '' }}</code>
117
+ </el-descriptions-item>
118
+ <el-descriptions-item label="推荐定位">
119
+ <span v-if="node.contextSelector">父级 {{ node.contextSelector.strategy }} {{ node.contextSelector.value || '' }} + 子级 {{ node.selector.strategy }} {{ node.selector.value || '' }}</span>
120
+ <span v-else>{{ node.selector.unique ? '当前 selector 唯一' : '使用当前 selector,定位失败时使用坐标兜底' }}</span>
121
+ </el-descriptions-item>
122
+ <el-descriptions-item label="bounds">
123
+ <code v-if="node.bounds">[{{ node.bounds.left }},{{ node.bounds.top }}][{{ node.bounds.right }},{{ node.bounds.bottom }}]</code>
124
+ <span v-else>-</span>
125
+ </el-descriptions-item>
126
+ </el-descriptions>
127
+ </section>
128
+ </div>
129
+ </template>
130
+ <el-empty v-else description="请选择组件" />
131
+ <template #footer><el-button type="primary" @click="detailsVisible = false">关闭</el-button></template>
83
132
  </el-drawer>
84
133
 
85
134
  </div>
@@ -90,13 +139,41 @@ async function copyLocator(value: string) {
90
139
  .appium-node-summary__text { display: grid; gap: 2px; min-width: 0; font-size: 12px; }
91
140
  .appium-node-summary__text strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 13px; }
92
141
  .appium-node-summary__text span, .appium-node-summary__empty { color: var(--el-text-color-secondary); font-size: 12px; }
142
+ :global(.appium-node-detail-drawer.el-drawer) { max-width: calc(100vw - 24px); border-left: 1px solid var(--ui-border); background: var(--ui-bg); box-shadow: -18px 0 48px rgb(15 23 42 / 18%); }
143
+ :global(.appium-node-detail-drawer .el-drawer__header) { min-height: 56px; margin: 0; padding: 16px 20px; border-bottom: 1px solid var(--ui-border); background: linear-gradient(180deg, #fff, #f8fafc); color: var(--ui-text); }
144
+ :global(.appium-node-detail-drawer .el-drawer__title) { font-size: 16px; font-weight: 650; }
145
+ :global(.appium-node-detail-drawer .el-drawer__body) { padding: 20px; background: var(--ui-bg); }
146
+ :global(.appium-node-detail-drawer .el-drawer__footer) { padding: 14px 20px; border-top: 1px solid var(--ui-border); background: #f8fafc; }
147
+ .appium-node-detail__content { display: grid; gap: 16px; }
148
+ .appium-node-detail__summary { display: flex; align-items: flex-start; justify-content: space-between; gap: 14px; padding: 16px; border: 1px solid #dbeafe; border-radius: var(--ui-radius); background: linear-gradient(135deg, #f8fbff, #eff6ff); box-shadow: var(--ui-shadow-card); }
149
+ .appium-node-detail__summary > div:first-child { display: grid; min-width: 0; gap: 5px; }
150
+ .appium-node-detail__summary span { color: var(--ui-text-muted); font-size: 12px; }
151
+ .appium-node-detail__summary strong { color: var(--ui-text); font-size: 16px; overflow-wrap: anywhere; }
152
+ .appium-node-detail__summary code { color: var(--ui-text-secondary); font-size: 12px; overflow-wrap: anywhere; }
153
+ .appium-node-detail__tags { display: flex; flex: none; justify-content: flex-end; gap: 6px; flex-wrap: wrap; max-width: 220px; }
154
+ .appium-node-detail__panel { padding: 16px; border: 1px solid var(--ui-border); border-radius: var(--ui-radius); background: var(--ui-surface); box-shadow: var(--ui-shadow-card); }
155
+ .appium-node-detail__panel--locators { padding: 12px; }
156
+ .appium-node-detail__panel--locators .appium-node-detail__heading { margin-bottom: 8px; }
157
+ .appium-node-detail__heading { display: grid; gap: 4px; margin-bottom: 12px; }
158
+ .appium-node-detail__heading strong { color: var(--ui-text); font-size: 14px; }
159
+ .appium-node-detail__heading span { color: var(--ui-text-muted); font-size: 12px; }
160
+ .appium-node-locators { display: grid; gap: 7px; }
161
+ .appium-node-locator-card { display: grid; gap: 6px; min-width: 0; padding: 9px 10px; border: 1px solid var(--ui-border); border-radius: 9px; background: #fbfcfe; box-shadow: 0 2px 6px rgb(15 23 42 / 5%); }
162
+ .appium-node-locator-card.is-recommended { border-color: #bfdbfe; background: linear-gradient(135deg, #f8fbff, #eff6ff); box-shadow: 0 0 0 2px rgb(26 115 232 / 8%), 0 4px 10px rgb(26 115 232 / 8%); }
163
+ .appium-node-locator-card__header { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
164
+ .appium-node-locator-value { display: grid; grid-template-columns: minmax(0, 1fr) 28px; align-items: center; gap: 6px; }
165
+ .appium-node-locator-value code { min-height: 26px; padding: 5px 8px; border: 1px solid rgb(203 213 225 / 65%); border-radius: 7px; background: #f1f5f9; color: var(--ui-text-secondary); font-size: 11px; line-height: 1.45; white-space: pre-wrap; overflow-wrap: anywhere; user-select: text; }
93
166
  :deep(.el-descriptions__content) { overflow-wrap: anywhere; word-break: break-word; }
94
167
  .appium-node-properties :deep(.el-descriptions__table) { table-layout: fixed; width: 100%; }
95
168
  .appium-node-properties :deep(.el-descriptions__label) { width: 118px; overflow-wrap: anywhere; }
96
- .appium-node-locator-label { display: grid; gap: 2px; }
169
+ .appium-node-locator-label { display: inline-flex; align-items: baseline; gap: 6px; color: var(--ui-text); cursor: help; }
97
170
  .appium-node-locator-label small { font-weight: 400; color: var(--el-text-color-secondary); }
98
- .appium-node-locator { display: flex; align-items: flex-start; gap: 8px; }
99
- .appium-node-locator code { flex: 1; min-width: 0; white-space: pre-wrap; overflow-wrap: anywhere; user-select: text; }
100
- .appium-node-locator .appium-node-locator-copy { flex: none; width: 26px; height: 26px; min-height: 26px; padding: 0; }
171
+ .appium-node-locator-copy { width: 28px; min-width: 28px; height: 28px; min-height: 28px; padding: 0; margin: 0; }
101
172
  .appium-node-locator-warning { display: block; margin-top: 4px; color: var(--el-color-warning); }
173
+ @media (max-width: 520px) {
174
+ :global(.appium-node-detail-drawer .el-drawer__body) { padding: 14px; }
175
+ .appium-node-detail__summary { display: grid; padding: 14px; }
176
+ .appium-node-detail__tags { justify-content: flex-start; max-width: none; }
177
+ .appium-node-detail__panel { padding: 12px; }
178
+ }
102
179
  </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,
@@ -56,5 +56,4 @@ function patch(value: Partial<AiDeduplicationConfig>) {
56
56
  .ai-dedup-explanation { margin-top: 18px; padding-top: 14px; border-top: 1px solid var(--ui-border); color: var(--el-text-color-regular); font-size: 13px; line-height: 1.7; }
57
57
  .ai-dedup-explanation h4 { margin: 0 0 8px; color: var(--el-text-color-primary); font-size: 13px; }
58
58
  .ai-dedup-explanation p { margin: 6px 0; }
59
- .ai-dedup-settings :deep(.el-radio-group) { gap: 4px 0; }
60
59
  </style>
@@ -118,7 +118,12 @@ const openModelConfigGuide = () => {
118
118
  />
119
119
  </el-form-item>
120
120
  <el-form-item label="API Key">
121
- <el-input v-model="configForm.midscene.model.apiKey" placeholder="已保存的 Key 显示为掩码,可输入新 Key 替换" show-password />
121
+ <el-input
122
+ v-model="configForm.midscene.model.apiKey"
123
+ type="password"
124
+ placeholder="已保存的 Key 显示为掩码,可输入新 Key 替换"
125
+ show-password
126
+ />
122
127
  </el-form-item>
123
128
  </template>
124
129
  <template v-else>
@@ -181,7 +186,12 @@ const openModelConfigGuide = () => {
181
186
  <el-input v-model="configForm.scriptOptimizer.model.baseUrl" />
182
187
  </el-form-item>
183
188
  <el-form-item label="API Key">
184
- <el-input v-model="configForm.scriptOptimizer.model.apiKey" placeholder="已保存的 Key 显示为掩码,可输入新 Key 替换" show-password />
189
+ <el-input
190
+ v-model="configForm.scriptOptimizer.model.apiKey"
191
+ type="password"
192
+ placeholder="已保存的 Key 显示为掩码,可输入新 Key 替换"
193
+ show-password
194
+ />
185
195
  </el-form-item>
186
196
  <el-form-item label="Model Name">
187
197
  <el-input v-model="configForm.scriptOptimizer.model.name" />