android-midscene-automation 1.1.4 → 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.
package/CHANGELOG.md CHANGED
@@ -1,4 +1,7 @@
1
1
  # 更新记录
2
+ ## v1.1.5
3
+ - UI优化
4
+
2
5
  ## v1.1.4
3
6
  - [Appium]修复持续刷新页面中的操作超时,以及可选元素未出现时误报失败的问题。
4
7
  - [Appium]脚本列表支持批量导入并自动重建连接脚本关系;导入后保留列表页,不自动加载脚本。
package/README.md CHANGED
@@ -1502,6 +1502,9 @@ Linux: ~/.local/share/android-midscene-automation
1502
1502
 
1503
1503
  # 项目更新记录
1504
1504
 
1505
+ ## v1.1.5
1506
+ - UI优化
1507
+
1505
1508
  ## v1.1.4
1506
1509
  - [Appium]修复持续刷新页面中的操作超时,以及可选元素未出现时误报失败的问题。
1507
1510
  - [Appium]脚本列表支持批量导入并自动重建连接脚本关系;导入后保留列表页,不自动加载脚本。
@@ -19,7 +19,9 @@ Modular entrypoint for full trace inspection, step replay, and task execution ma
19
19
 
20
20
  import argparse
21
21
  import asyncio
22
+ import copy
22
23
  from contextlib import asynccontextmanager
24
+ import logging
23
25
  import os
24
26
  from pathlib import Path
25
27
  import secrets
@@ -452,6 +454,35 @@ class ArtemisUvicornServer(uvicorn.Server):
452
454
  super().handle_exit(sig, frame)
453
455
 
454
456
 
457
+ class QuietProbeAccessFilter(logging.Filter):
458
+ """Hide successful high-frequency probe requests from Uvicorn access logs."""
459
+
460
+ _quiet_paths = frozenset({"/api/status", "/api/system/readiness"})
461
+
462
+ def filter(self, record: logging.LogRecord) -> bool:
463
+ args = record.args
464
+ if not isinstance(args, tuple) or len(args) < 5:
465
+ return True
466
+ method = str(args[1]).upper()
467
+ path = str(args[2]).partition("?")[0]
468
+ try:
469
+ status_code = int(args[4])
470
+ except (TypeError, ValueError):
471
+ return True
472
+ return not (method == "GET" and path in self._quiet_paths and status_code == 200)
473
+
474
+
475
+ def _uvicorn_log_config() -> dict:
476
+ """Keep normal access logs while silencing successful background probes."""
477
+ config = copy.deepcopy(uvicorn.config.LOGGING_CONFIG)
478
+ filter_name = "quiet_artemis_probes"
479
+ config.setdefault("filters", {})[filter_name] = {"()": QuietProbeAccessFilter}
480
+ access_handler = config.get("handlers", {}).get("access")
481
+ if access_handler is not None:
482
+ access_handler["filters"] = [*access_handler.get("filters", []), filter_name]
483
+ return config
484
+
485
+
455
486
  def run_ui_server(host: str, port: int, reload: bool = False) -> None:
456
487
  """Run the UI server with bounded, signal-aware graceful shutdown."""
457
488
  state.host = host
@@ -466,6 +497,7 @@ def run_ui_server(host: str, port: int, reload: bool = False) -> None:
466
497
  port=port,
467
498
  reload=True,
468
499
  timeout_graceful_shutdown=5,
500
+ log_config=_uvicorn_log_config(),
469
501
  )
470
502
  return
471
503
 
@@ -474,6 +506,7 @@ def run_ui_server(host: str, port: int, reload: bool = False) -> None:
474
506
  host=host,
475
507
  port=port,
476
508
  timeout_graceful_shutdown=5,
509
+ log_config=_uvicorn_log_config(),
477
510
  )
478
511
  server = ArtemisUvicornServer(config)
479
512
  app.state.uvicorn_server = server
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "android-midscene-automation",
3
- "version": "1.1.4",
3
+ "version": "1.1.5",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/oooooooko/android-midscene-automation.git"
@@ -1,42 +1,99 @@
1
1
  <script setup lang="ts">
2
2
  import { computed, nextTick, ref, watch } from 'vue';
3
3
  import type { AppiumNode } from '../types';
4
- import { Search } from '@element-plus/icons-vue';
4
+ import { ArrowDown, ArrowUp, Search } from '@element-plus/icons-vue';
5
+ import { flattenNodes } from '../tree';
5
6
 
6
7
  const props = defineProps<{
7
8
  tree: AppiumNode | null;
8
9
  selectedId: string;
9
10
  }>();
10
11
 
11
- defineEmits<{
12
+ const emit = defineEmits<{
12
13
  select: [node: AppiumNode];
13
14
  }>();
14
15
 
15
16
  const containerRef = ref<HTMLElement | null>(null);
16
17
  const query = ref('');
18
+ const currentMatchIndex = ref(-1);
17
19
  // Keep the tree data identity stable while typing so filtering is not reset.
18
20
  const treeData = computed(() => props.tree ? [props.tree] : []);
19
21
  const treeRef = ref<{ setCurrentKey: (key: string) => void; filter: (value: string) => void } | null>(null);
20
22
  const filterNode = (value: string, node: AppiumNode) => !value || [node.label, node.resourceId, node.text, node.contentDesc, node.className].some((text) => text?.toLowerCase().includes(value.toLowerCase()));
21
- watch(query, (value) => treeRef.value?.filter(value));
22
- watch(() => props.tree, async () => { await nextTick(); treeRef.value?.filter(query.value); });
23
+ const matches = computed(() => {
24
+ const value = query.value.trim();
25
+ return value ? flattenNodes(props.tree).filter(node => filterNode(value, node)) : [];
26
+ });
27
+ const matchPosition = computed(() => matches.value.length && currentMatchIndex.value >= 0
28
+ ? `${currentMatchIndex.value + 1}/${matches.value.length}`
29
+ : `0/${matches.value.length}`);
30
+
31
+ const scrollToCurrentNode = async () => {
32
+ await nextTick();
33
+ containerRef.value
34
+ ?.querySelector('.el-tree-node.is-current')
35
+ ?.scrollIntoView({ block: 'center', inline: 'nearest' });
36
+ };
37
+
38
+ const focusMatch = (index: number) => {
39
+ if (!matches.value.length) return;
40
+ currentMatchIndex.value = (index + matches.value.length) % matches.value.length;
41
+ const node = matches.value[currentMatchIndex.value];
42
+ treeRef.value?.setCurrentKey(node.id);
43
+ emit('select', node);
44
+ void scrollToCurrentNode();
45
+ };
46
+
47
+ const refreshSearch = async () => {
48
+ treeRef.value?.filter(query.value);
49
+ await nextTick();
50
+ if (!query.value.trim() || !matches.value.length) {
51
+ currentMatchIndex.value = -1;
52
+ return;
53
+ }
54
+ const selectedMatchIndex = matches.value.findIndex(node => node.id === props.selectedId);
55
+ focusMatch(selectedMatchIndex >= 0 ? selectedMatchIndex : 0);
56
+ };
57
+
58
+ watch(query, refreshSearch);
59
+ watch(() => props.tree, refreshSearch);
23
60
 
24
61
  watch(
25
62
  () => props.selectedId,
26
63
  async () => {
27
- query.value = '';
64
+ if (query.value.trim()) {
65
+ const selectedMatchIndex = matches.value.findIndex(node => node.id === props.selectedId);
66
+ if (selectedMatchIndex >= 0) currentMatchIndex.value = selectedMatchIndex;
67
+ else query.value = '';
68
+ }
28
69
  treeRef.value?.setCurrentKey(props.selectedId);
29
- await nextTick();
30
- containerRef.value
31
- ?.querySelector('.el-tree-node.is-current')
32
- ?.scrollIntoView({ block: 'center', inline: 'nearest' });
70
+ await scrollToCurrentNode();
33
71
  },
34
72
  );
35
73
  </script>
36
74
 
37
75
  <template>
38
76
  <div class="appium-tree-container">
39
- <el-input v-model="query" size="small" :prefix-icon="Search" clearable placeholder="搜索文字、ID、组件类型" aria-label="搜索组件" />
77
+ <div class="appium-tree-search">
78
+ <el-input
79
+ v-model="query"
80
+ size="small"
81
+ :prefix-icon="Search"
82
+ clearable
83
+ placeholder="搜索文字、ID、组件类型"
84
+ aria-label="搜索组件"
85
+ @keydown.enter.prevent="focusMatch(currentMatchIndex + ($event.shiftKey ? -1 : 1))"
86
+ @keydown.up.prevent="focusMatch(currentMatchIndex - 1)"
87
+ @keydown.down.prevent="focusMatch(currentMatchIndex + 1)"
88
+ />
89
+ <span class="appium-tree-search__count" aria-live="polite">{{ matchPosition }}</span>
90
+ <el-tooltip content="上一个匹配项(Shift + Enter)" placement="top" :show-after="200">
91
+ <el-button :icon="ArrowUp" size="small" aria-label="上一个匹配项" :disabled="!matches.length" @click="focusMatch(currentMatchIndex - 1)" />
92
+ </el-tooltip>
93
+ <el-tooltip content="下一个匹配项(Enter)" placement="top" :show-after="200">
94
+ <el-button :icon="ArrowDown" size="small" aria-label="下一个匹配项" :disabled="!matches.length" @click="focusMatch(currentMatchIndex + 1)" />
95
+ </el-tooltip>
96
+ </div>
40
97
  <div ref="containerRef" class="appium-tree">
41
98
  <el-empty v-if="!tree" description="暂无组件树" />
42
99
  <el-tree
@@ -70,6 +127,11 @@ watch(
70
127
 
71
128
  <style scoped>
72
129
  .appium-tree-container { display: flex; flex-direction: column; gap: 8px; min-width: 0; min-height: 0; }
130
+ .appium-tree-search { display: flex; align-items: center; gap: 2px; }
131
+ .appium-tree-search .el-input { flex: 1; min-width: 0; }
132
+ .appium-tree-search__count { min-width: 28px; padding: 0 2px; text-align: center; font-size: 11px; color: var(--ui-text-secondary); font-variant-numeric: tabular-nums; }
133
+ .appium-tree-search .el-button { flex: none; width: 24px; height: 24px; min-height: 24px; padding: 0; border-radius: 5px; }
134
+ .appium-tree-search .el-button + .el-button { margin-left: 0; }
73
135
  .appium-tree { flex: 1; height: auto; overflow: auto; }
74
136
  .appium-tree :deep(.el-tree) { width: max-content; min-width: 100%; }
75
137
  .appium-tree-node { flex: 1 0 auto; }
@@ -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>
@@ -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" />
package/src/style.css CHANGED
@@ -3296,11 +3296,81 @@ body {
3296
3296
  }
3297
3297
 
3298
3298
  .el-message-box {
3299
+ --el-messagebox-padding-primary: 0;
3300
+ padding: 0;
3301
+ overflow: hidden;
3299
3302
  border: 1px solid var(--ui-border);
3300
3303
  border-radius: var(--ui-radius);
3301
3304
  box-shadow: 0 18px 48px rgba(15, 23, 42, 0.18);
3302
3305
  }
3303
3306
 
3307
+ .el-message-box__header {
3308
+ box-sizing: border-box;
3309
+ min-height: 56px;
3310
+ padding: 16px 20px;
3311
+ border-bottom: 1px solid var(--ui-border);
3312
+ }
3313
+
3314
+ .el-message-box__header.show-close {
3315
+ padding-right: 64px;
3316
+ }
3317
+
3318
+ .el-message-box__headerbtn {
3319
+ top: 8px;
3320
+ right: 12px;
3321
+ border-radius: var(--ui-radius-control);
3322
+ }
3323
+
3324
+ .el-message-box__headerbtn:hover {
3325
+ background: #eff6ff;
3326
+ }
3327
+
3328
+ .el-message-box__title {
3329
+ color: var(--ui-text);
3330
+ font-size: 16px;
3331
+ font-weight: 650;
3332
+ line-height: 24px;
3333
+ }
3334
+
3335
+ .el-message-box__content {
3336
+ display: grid;
3337
+ align-content: center;
3338
+ box-sizing: border-box;
3339
+ min-height: 88px;
3340
+ padding: 18px 24px;
3341
+ }
3342
+
3343
+ .el-message-box__container {
3344
+ justify-content: center;
3345
+ width: 100%;
3346
+ }
3347
+
3348
+ .el-message-box__message {
3349
+ text-align: center;
3350
+ }
3351
+
3352
+ .el-message-box__input {
3353
+ width: 100%;
3354
+ padding-top: 14px;
3355
+ }
3356
+
3357
+ .el-message-box__btns {
3358
+ gap: 10px;
3359
+ min-height: 68px;
3360
+ padding: 14px 20px;
3361
+ border-top: 1px solid var(--ui-border);
3362
+ background: #f8fafc;
3363
+ }
3364
+
3365
+ .el-message-box__btns .el-button + .el-button {
3366
+ margin-left: 0;
3367
+ }
3368
+
3369
+ @media (max-width: 520px) {
3370
+ .el-message-box__content { padding: 16px 18px; }
3371
+ .el-message-box__btns { padding: 12px 16px; }
3372
+ }
3373
+
3304
3374
  .el-tabs__item {
3305
3375
  color: var(--ui-text-secondary);
3306
3376
  font-weight: 500;
@@ -3955,10 +4025,85 @@ button:focus-visible { outline: 2px solid var(--ui-primary); outline-offset: -2p
3955
4025
  box-shadow: var(--ui-shadow-inset);
3956
4026
  }
3957
4027
  .layout .subnav__item--active, .layout .trend-tabs button.active,
3958
- .el-tabs:not(.el-tabs--card):not(.el-tabs--border-card) > .el-tabs__header .el-tabs__item.is-active,
3959
- .el-radio-group .el-radio-button.is-active .el-radio-button__inner {
4028
+ .el-tabs:not(.el-tabs--card):not(.el-tabs--border-card) > .el-tabs__header .el-tabs__item.is-active {
3960
4029
  box-shadow: var(--ui-shadow-control);
3961
4030
  }
4031
+
4032
+ .el-radio-group:has(.el-radio-button) {
4033
+ display: inline-flex;
4034
+ align-items: stretch;
4035
+ gap: 2px;
4036
+ box-sizing: border-box;
4037
+ width: fit-content;
4038
+ max-width: 100%;
4039
+ padding: 2px;
4040
+ border: 1px solid #dbe3ee;
4041
+ border-radius: 14px;
4042
+ background: linear-gradient(180deg, #fff, #f8fafc);
4043
+ box-shadow: inset 0 1px 0 rgb(255 255 255 / 95%), 0 4px 12px -4px rgb(15 23 42 / 20%);
4044
+ }
4045
+
4046
+ .el-radio-group:has(.el-radio-button) .el-radio-button {
4047
+ flex: 1 1 auto;
4048
+ min-width: 0;
4049
+ }
4050
+
4051
+ .el-radio-group:has(.el-radio-button) .el-radio-button__inner {
4052
+ display: flex;
4053
+ align-items: center;
4054
+ justify-content: center;
4055
+ box-sizing: border-box;
4056
+ min-height: 32px;
4057
+ padding: 6px 12px;
4058
+ border: 0 !important;
4059
+ border-radius: 11px !important;
4060
+ background: transparent;
4061
+ box-shadow: none !important;
4062
+ color: var(--ui-text-secondary);
4063
+ font-weight: 500;
4064
+ line-height: 1.25;
4065
+ white-space: normal;
4066
+ text-align: center;
4067
+ transition: color 160ms ease, background-color 160ms ease, box-shadow 160ms ease, transform 160ms ease;
4068
+ }
4069
+
4070
+ .el-radio-group:has(.el-radio-button) .el-radio-button:not(.is-active):not(.is-disabled) .el-radio-button__inner:hover {
4071
+ background: #f1f5f9;
4072
+ color: var(--ui-primary);
4073
+ }
4074
+
4075
+ .el-radio-group:has(.el-radio-button) .el-radio-button.is-active .el-radio-button__inner {
4076
+ background: linear-gradient(180deg, #438cf5, #2574e8);
4077
+ box-shadow: inset 0 1px 0 rgb(255 255 255 / 30%), 0 4px 12px rgb(37 116 232 / 34%) !important;
4078
+ color: #fff;
4079
+ font-weight: 600;
4080
+ }
4081
+
4082
+ .el-radio-group:has(.el-radio-button) .el-radio-button.is-active:not(.is-disabled) .el-radio-button__inner:active {
4083
+ box-shadow: inset 0 2px 5px rgb(15 23 42 / 20%) !important;
4084
+ transform: translateY(1px);
4085
+ }
4086
+
4087
+ .el-radio-group:has(.el-radio-button) .el-radio-button.is-disabled .el-radio-button__inner {
4088
+ background: transparent;
4089
+ color: var(--el-disabled-text-color);
4090
+ }
4091
+
4092
+ .el-radio-group:has(.el-radio-button) .el-radio-button.is-active.is-disabled .el-radio-button__inner {
4093
+ background: #dbeafe;
4094
+ color: #64748b;
4095
+ }
4096
+
4097
+ .el-radio-group:has(.el-radio-button) .el-radio-button__original-radio:focus-visible + .el-radio-button__inner {
4098
+ outline: 2px solid var(--ui-primary);
4099
+ outline-offset: 1px;
4100
+ }
4101
+
4102
+ .el-radio-group:has(.el-radio-button) .el-radio-button--small .el-radio-button__inner {
4103
+ min-height: 27px;
4104
+ padding: 4px 9px;
4105
+ font-size: 12px;
4106
+ }
3962
4107
  .el-dialog, .el-message-box, .el-popper.is-light { box-shadow: var(--ui-shadow-popover); }
3963
4108
  .el-dialog__header, .el-message-box__header { background: linear-gradient(180deg, #fff, #f8fafc); }
3964
4109
  .el-table {